Skip to content

Commit e72c98e

Browse files
committed
docs(api): align provider extension docs and contract test
1 parent eb44859 commit e72c98e

13 files changed

Lines changed: 227 additions & 30 deletions

File tree

CONTRIBUTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,22 @@ For a deeper look at the backend layout and domain split, read [`docs/ARCHITECTU
6060
- TypeScript strict mode throughout
6161
- Zod for request validation (API)
6262
- React Query for data fetching (Web)
63-
- Web stays on React 18 while the Ink TUI currently tracks React 19
63+
- Web and the Ink TUI both target React 19
6464
- No hardcoded absolute paths — use `os.homedir()` / `path.resolve()` dynamically
6565

6666
### Safety Rules
6767

6868
- **Never** delete local thread/session data without a token-verified flow
6969
- Keep backward compatibility for existing `/api/*` responses
7070
- Prefer incremental migration over large rewrites
71-
- Keep tracked docs and tracked scripts free of machine-specific paths and maintainer-only notes
71+
- Keep tracked docs and tracked scripts free of machine-specific paths and local-only notes
7272

7373
## Documentation
7474

7575
Keep public docs focused on product behavior, public architecture, and reproducible setup. If your change touches tracked markdown:
7676

77-
- No local paths, machine-specific traces, or internal codenames
78-
- No maintainer-only operating notes — those belong outside the tracked doc surface
77+
- No local paths, machine-specific traces, or non-public project labels
78+
- No local-only operating notes — those belong outside the tracked doc surface
7979

8080
## Reporting Issues
8181

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Provider Extension Guide
2+
3+
This domain uses an in-repo, reviewed-source provider model. A provider is not
4+
loaded dynamically at runtime; adding one is a small code change with explicit
5+
registry, capability, and path-safety review.
6+
7+
## Ownership
8+
9+
- `adapters/`: provider-specific facts and optional probes.
10+
- `services/`: cross-provider engines for search, actions, transcripts, and
11+
matrix data. Do not put provider-specific branches here unless the common
12+
engine needs a new extension point.
13+
- `shared/file-roots.ts`: canonical provider root specs and scan root specs.
14+
- `shared/`: other provider-domain helpers for backup roots and fail-closed
15+
path safety.
16+
- `capabilities.ts`: API-facing policy for which implemented providers are
17+
searchable, transcript-readable, and cleanup-capable.
18+
- `registry.ts`: adapter registration and the `ProviderAdapter` contract.
19+
20+
## Minimum Provider
21+
22+
For a file-backed provider, add:
23+
24+
1. `packages/shared-contracts/src/index.ts`
25+
- Add the provider to `PROVIDER_REGISTRY` with its label and capabilities.
26+
2. `apps/api-ts/src/domains/providers/capabilities.ts`
27+
- Add the id to `IMPLEMENTED_PROVIDER_IDS`.
28+
3. `apps/api-ts/src/domains/providers/registry.ts`
29+
- Register it in `PROVIDER_ADAPTERS`.
30+
4. `apps/api-ts/src/domains/providers/shared/file-roots.ts`
31+
- Add root specs and scan root specs.
32+
5. Provider tests or fixtures
33+
- Add the smallest fixture or unit test that proves sessions are discovered
34+
and parsed.
35+
36+
Optional provider-specific files live under `adapters/<provider>/` only when
37+
they contain real provider-specific behavior, for example:
38+
39+
- `roots.ts`
40+
- `title.ts`
41+
- `health.ts`
42+
- `transcript.ts`
43+
44+
Do not create empty pass-through files just to match a folder template.
45+
46+
This minimum path only proves file-backed session discovery. It does not prove
47+
full product support for DB-backed providers such as SQLite session stores.
48+
49+
For a DB-backed provider, do not fake every session as the same database file
50+
path. Add the missing product contract first:
51+
52+
- A stable session locator that includes the provider session id.
53+
- A transcript reader for the provider's storage shape.
54+
- API routing that can read transcripts by that locator.
55+
- UI selection/detail keys that do not collapse multiple sessions sharing one
56+
backing database file.
57+
58+
Until those pieces exist, a DB-backed provider can be treated only as a scan
59+
spike, not as a supported UI provider.
60+
61+
## Guardrails
62+
63+
- Keep provider-specific filesystem rules out of `services/`.
64+
- Keep shared pure utilities in `src/lib`; provider-domain helpers belong in
65+
`domains/providers/shared`.
66+
- All destructive actions must flow through `services/actions`.
67+
- Do not add unsupported providers as hidden registry entries. If a
68+
provider is not supported, leave it out of the registry.
69+
- Run `pnpm docs:provider-support` after changing capabilities.
70+
71+
## Verification
72+
73+
Run the nearest checks after adding a provider:
74+
75+
```bash
76+
pnpm --filter @threadlens/shared-contracts test
77+
pnpm --filter @threadlens/api test -- src/domains/providers/provider-extension-contract.test.ts
78+
pnpm qa:provider
79+
```
80+
81+
Use `provider-extension-contract.test.ts` as the first failure to read when a
82+
new provider is only partially wired.

apps/api-ts/src/domains/providers/probe.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe("provider probe helpers", () => {
1212
expect(inferSessionId("/tmp/12345678-1234-1234-1234-1234567890ab.json")).toBe(
1313
"12345678-1234-1234-1234-1234567890ab",
1414
);
15-
expect(inferSessionId("C:\\Users\\hwan\\.codex\\sessions\\win-session.jsonl")).toBe(
15+
expect(inferSessionId("C:\\Users\\example\\.codex\\sessions\\win-session.jsonl")).toBe(
1616
"win-session",
1717
);
1818
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
getProviderCapability,
3+
PROVIDER_IDS,
4+
} from "@threadlens/shared-contracts";
5+
import { describe, expect, it } from "vitest";
6+
import {
7+
IMPLEMENTED_PROVIDER_IDS,
8+
listProviderActionProviderIds,
9+
listProviderIds,
10+
listSearchableProviderIds,
11+
listTranscriptReadableProviderIds,
12+
} from "./capabilities.js";
13+
import {
14+
getProviderAdapter,
15+
listProviderAdapters,
16+
PROVIDER_ADAPTERS,
17+
} from "./registry.js";
18+
import { providerRootSpecs } from "./path-safety.js";
19+
20+
describe("provider extension contract", () => {
21+
it("keeps shared contracts, implemented ids, and adapters in lockstep", () => {
22+
expect(IMPLEMENTED_PROVIDER_IDS).toEqual(PROVIDER_IDS);
23+
expect(listProviderIds()).toEqual([...IMPLEMENTED_PROVIDER_IDS]);
24+
expect(Object.keys(PROVIDER_ADAPTERS)).toEqual([...IMPLEMENTED_PROVIDER_IDS]);
25+
expect(listProviderAdapters().map((adapter) => adapter.id)).toEqual([
26+
...IMPLEMENTED_PROVIDER_IDS,
27+
]);
28+
});
29+
30+
it("wires every implemented provider to a labeled adapter and root specs", () => {
31+
for (const provider of IMPLEMENTED_PROVIDER_IDS) {
32+
const capability = getProviderCapability(provider);
33+
const adapter = getProviderAdapter(provider);
34+
35+
expect(adapter, `missing adapter for ${provider}`).toBeDefined();
36+
if (!adapter) continue;
37+
38+
expect(adapter.id).toBe(provider);
39+
expect(adapter.label).toBe(capability.label);
40+
expect(adapter.roots()).toEqual(providerRootSpecs(provider));
41+
if (!adapter.scanSessions) {
42+
expect(adapter.roots().length, `${provider} root specs`).toBeGreaterThan(0);
43+
}
44+
for (const spec of adapter.roots()) {
45+
expect(spec.source, `${provider} root source`).toBeTruthy();
46+
expect(spec.root, `${provider} root path`).toBeTruthy();
47+
expect(Array.isArray(spec.exts), `${provider} root extensions`).toBe(true);
48+
}
49+
}
50+
});
51+
52+
it("derives route policy lists from shared provider capabilities", () => {
53+
const expectedSearchable = IMPLEMENTED_PROVIDER_IDS.filter((provider) => {
54+
const capability = getProviderCapability(provider);
55+
return (
56+
capability.search_scope_visibility === "public" &&
57+
capability.read_sessions &&
58+
capability.analyze_context
59+
);
60+
});
61+
const expectedTranscriptReadable = IMPLEMENTED_PROVIDER_IDS.filter(
62+
(provider) => getProviderCapability(provider).read_transcript,
63+
);
64+
const expectedActionable = IMPLEMENTED_PROVIDER_IDS.filter((provider) => {
65+
const capability = getProviderCapability(provider);
66+
return capability.safe_cleanup || capability.hard_delete;
67+
});
68+
69+
expect(listSearchableProviderIds()).toEqual(expectedSearchable);
70+
expect(listTranscriptReadableProviderIds()).toEqual(expectedTranscriptReadable);
71+
expect(listProviderActionProviderIds()).toEqual(expectedActionable);
72+
});
73+
74+
it("keeps destructive action capabilities fail-closed", () => {
75+
for (const provider of IMPLEMENTED_PROVIDER_IDS) {
76+
const capability = getProviderCapability(provider);
77+
if (capability.hard_delete) {
78+
expect(capability.safe_cleanup, `${provider} hard_delete requires safe_cleanup`).toBe(true);
79+
}
80+
}
81+
});
82+
});

apps/api-ts/src/domains/providers/services/search/row-search.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ describe("searchConversationRows", () => {
3737
it("stops before loading later transcripts once metadata hits fill the result limit", async () => {
3838
const metadataRow = makeRow({
3939
display_title: "Unrelated title",
40-
session_id: "rollout-2026-03-25T10-00-00-019d-obsidian-review",
41-
file_path: "/tmp/obsidian-review.jsonl",
40+
session_id: "rollout-2026-03-25T10-00-00-019d-project-notes-review",
41+
file_path: "/tmp/project-notes-review.jsonl",
4242
mtime: "2026-03-25T10:05:00.000Z",
4343
});
4444
const transcriptRow = makeRow({
@@ -54,7 +54,7 @@ describe("searchConversationRows", () => {
5454
{
5555
idx: 0,
5656
role: "assistant",
57-
text: "obsidian transcript fallback",
57+
text: "project-notes transcript fallback",
5858
ts: "2026-03-25T10:00:00.000Z",
5959
source_type: "response_item.message",
6060
},
@@ -64,7 +64,7 @@ describe("searchConversationRows", () => {
6464

6565
const result = await searchConversationRows(
6666
[metadataRow, transcriptRow],
67-
"obsidian",
67+
"project-notes",
6868
{
6969
limit: 1,
7070
transcriptLoader,
@@ -74,7 +74,7 @@ describe("searchConversationRows", () => {
7474
expect(result.results).toHaveLength(1);
7575
expect(result.results[0]).toMatchObject({
7676
match_kind: "title",
77-
session_id: "obsidian-review",
77+
session_id: "project-notes-review",
7878
});
7979
expect(transcriptLoader).not.toHaveBeenCalled();
8080
});

apps/api-ts/src/domains/providers/transcript.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ afterEach(async () => {
1919

2020
describe("buildSessionTranscript", () => {
2121
it("infers transcript session ids from Windows-style paths on POSIX runtimes", () => {
22-
expect(inferSessionId("C:\\Users\\hwan\\.codex\\sessions\\win-transcript.jsonl")).toBe(
22+
expect(inferSessionId("C:\\Users\\example\\.codex\\sessions\\win-transcript.jsonl")).toBe(
2323
"win-transcript",
2424
);
2525
});

apps/tui/src/cli.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ ${messages.cli.usageLabel}
1212
1313
${messages.cli.examplesLabel}
1414
threadlens-tui
15-
threadlens-tui --query obsidian
16-
threadlens-tui --query obsidian --results
15+
threadlens-tui --query project-notes
16+
threadlens-tui --query project-notes --results
1717
threadlens-tui --view sessions --provider codex
1818
threadlens-tui --view cleanup --filter risk
1919
threadlens-tui --locale ko

docs/ADDING_PROVIDER.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ session discovery, transcript parsing, search, diagnostics, archive, backup, and
55
delete behavior, so new providers need both capability metadata and path-safety
66
coverage.
77

8-
This guide describes the current internal extension path. It is not an external
8+
This guide describes the current in-repo extension path. It is not an external
99
plugin API, and new provider code should stay in reviewed source until a separate
1010
security model exists.
1111

@@ -44,12 +44,17 @@ folder under `apps/api-ts/src/domains/providers/`.
4444

4545
Touch these files deliberately:
4646

47+
- `packages/shared-contracts/src/index.ts` for the shared provider capability
48+
source of truth
4749
- `apps/api-ts/src/domains/providers/registry.ts` for the explicit implemented
4850
adapter registration
4951
- `apps/api-ts/src/domains/providers/capabilities.ts` for route/search/report
5052
exposure policy
53+
- `apps/api-ts/src/domains/providers/shared/file-roots.ts` for canonical file
54+
root specs and scan root specs
5155
- `apps/api-ts/src/domains/providers/adapters/<provider>/` for provider-specific
52-
root discovery, title, transcript, or health evidence
56+
root discovery, title, transcript, or health evidence when real
57+
provider-specific behavior exists
5358
- `apps/api-ts/src/domains/providers/shared/` only for provider-neutral path/root
5459
helpers
5560

@@ -74,22 +79,31 @@ export type ProviderAdapter = {
7479
label: string;
7580
roots(): ProviderRootSpec[];
7681
scanRoots?(): Promise<ProviderRootSpec[]>;
82+
scanSessions?(): Promise<ProviderSessionCandidate[] | ProviderSessionRow[]>;
83+
health?(): Promise<ProviderHealthEvidence>;
7784
};
7885
```
7986

8087
Do not duplicate capabilities in adapters. Capabilities must remain sourced from
8188
`getProviderCapability(adapter.id)`.
8289

90+
Do not create empty `adapters/<provider>/transcript.ts`, `title.ts`, or
91+
`health.ts` files just to match a template. Optional adapter modules should
92+
exist only when they hold real provider-specific behavior.
93+
8394
Providers that do not store sessions as standalone files should use a session
84-
locator model in later PRs instead of encoding provider-specific ids into fake
85-
file paths:
95+
locator model instead of encoding provider-specific ids into fake file paths:
8696

8797
```ts
8898
export type ProviderSessionLocator =
8999
| { kind: "file"; file_path: string }
90100
| { kind: "sqlite"; db_path: string; session_id: string };
91101
```
92102

103+
The locator type exists in the registry, but full DB-backed product support
104+
also needs transcript routing and UI selection keys that do not collapse
105+
multiple sessions sharing one backing database file.
106+
93107
## 3. Add Search and Transcript Support
94108

95109
Most providers can use the existing JSON/JSONL transcript flow. Provider-neutral
@@ -158,6 +172,7 @@ At minimum, cover:
158172

159173
Useful existing tests:
160174

175+
- `apps/api-ts/src/domains/providers/provider-extension-contract.test.ts`
161176
- `apps/api-ts/src/domains/providers/path-safety.test.ts`
162177
- `apps/api-ts/src/domains/providers/parser-fixtures.test.ts`
163178
- `apps/api-ts/src/domains/providers/services/search/session-search.test.ts`
@@ -170,7 +185,7 @@ Useful existing tests:
170185

171186
## Adapter Direction
172187

173-
The provider boundary is an internal provider adapter registry, not an external
188+
The provider boundary is an in-repo provider adapter registry, not an external
174189
plugin system.
175190

176191
The adapter boundary starts with provider identity and root discovery. Keep
@@ -198,10 +213,7 @@ code unless a separate security model is designed.
198213
Before opening a provider PR:
199214

200215
```sh
201-
pnpm docs:provider-support
202-
pnpm --filter @threadlens/shared-contracts test
203-
pnpm --filter @threadlens/api test
204-
pnpm lint:deps
216+
pnpm qa:provider
205217
```
206218

207219
If provider UI copy changes are included, also run the relevant web or TUI tests.

docs/ARCHITECTURE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ app/
4545
actions.ts
4646
matrix.ts
4747
search.ts
48+
schemas.ts
4849
sessions.ts
4950
transcript.ts
5051
system/
@@ -57,10 +58,13 @@ app/
5758
forensics.ts
5859
open-folder.ts
5960
query.ts
61+
schemas.ts
6062
state-actions.ts
6163
transcript.ts
64+
types.ts
6265
domains/
6366
providers/
67+
README.md
6468
adapters/
6569
services/
6670
actions/
@@ -71,6 +75,7 @@ domains/
7175
capabilities.ts
7276
constants.ts
7377
index.ts
78+
provider-roots.ts
7479
registry.ts
7580
types.ts
7681
threads/
@@ -133,6 +138,16 @@ i18n/ localized message catalogs and locale loading
133138
- Provider services do not import provider-specific adapter modules directly; they go through the registry
134139
- Web, TUI, and desktop reuse the same API contracts
135140

141+
## Provider Extension Model
142+
143+
- `packages/shared-contracts/src/index.ts` is the shared provider capability source of truth
144+
- `domains/providers/capabilities.ts` narrows shared capabilities to api-ts implemented providers and route exposure
145+
- `domains/providers/registry.ts` registers reviewed-source adapters; ThreadLens does not load third-party provider code dynamically
146+
- `domains/providers/adapters/<provider>/` contains optional provider-specific behavior only when it is real behavior, not empty template files
147+
- `domains/providers/services/` contains cross-provider engines for search, actions, transcripts, and matrix data
148+
- `domains/providers/shared/` contains provider-domain root and path-safety helpers
149+
- `domains/providers/provider-extension-contract.test.ts` keeps shared ids, api implemented ids, adapters, roots, and route policy lists in lockstep
150+
136151
## Safety
137152

138153
- Destructive actions use `dry-run -> confirm token -> execute`

0 commit comments

Comments
 (0)