From e79094e2625c4e29b4adfde238f2f0ec7bf18f68 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 3 Aug 2026 15:47:57 -0700 Subject: [PATCH] Add DocumentStore and SourceProvider ports with in-package fakes (CL-5226) Export port types, fake implementations, and mount overrides so a host can wire storage and live sources without Postgres. Config is optional when documentStore is provided. MemoryProvider is a type stub only until M3. --- src/index.ts | 52 ++++++++- src/knowledge.ts | 200 +++++++++++++++++++++++++++++++++- src/ports/fakes.test.ts | 132 ++++++++++++++++++++++ src/ports/fakes.ts | 197 +++++++++++++++++++++++++++++++++ src/ports/index.ts | 12 ++ src/ports/mount-fakes.test.ts | 161 +++++++++++++++++++++++++++ src/ports/types.ts | 132 ++++++++++++++++++++++ 7 files changed, 880 insertions(+), 6 deletions(-) create mode 100644 src/ports/fakes.test.ts create mode 100644 src/ports/fakes.ts create mode 100644 src/ports/index.ts create mode 100644 src/ports/mount-fakes.test.ts create mode 100644 src/ports/types.ts diff --git a/src/index.ts b/src/index.ts index 50cad03..777ece4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,14 @@ import { createKnowledgePlane, type Generate, type KnowledgePlane, + type KnowledgePlaneOptions, + type TextExtractor, } from "./knowledge.ts"; +import type { + DocumentStore, + MemoryProvider, + SourceProvider, +} from "./ports/types.ts"; import { mountKnowledgeRoutes, type GrantConfig, @@ -58,6 +65,24 @@ export type { VisibilitySpec, } from "./knowledge.ts"; export { KnowledgeError, KnowledgeNotPermittedError } from "./knowledge.ts"; +// Ports (M2) — pluggable storage + live sources; MemoryProvider type stub for M3 +export type { + DocumentStore, + DocumentStoreAddParams, + DocumentStoreFindItem, + DocumentStoreFindParams, + DocumentStoreFindResult, + DocumentStoreRecentEvent, + DocumentStoreRecentParams, + LiveSearchItem, + MemoryProvider, + SourceProvider, +} from "./ports/types.ts"; +export { + createFakeDocumentStore, + createFakeMemoryProvider, + createFakeSourceProvider, +} from "./ports/fakes.ts"; // Migrations export { runKnowledgeMigrations } from "./migrations.ts"; // Degrade metrics — no metrics dependency exists in this package (see @@ -80,7 +105,11 @@ export { export { mountKnowledgeRoutes, type GrantConfig } from "./routes/mount.ts"; export type MountKnowledgeEngineOptions = { - config: KnowledgeConfig; + /** + * Engine config (DB + model endpoints). Optional when `documentStore` is + * provided — a host can mount with fakes only. + */ + config?: KnowledgeConfig; /** * The host's grant store + condition registry — the same pair it passes to * `createApp`/`createRequireGrant`. Required: HTTP routes are guarded with @@ -97,6 +126,14 @@ export type MountKnowledgeEngineOptions = { * retry, audit and authz gates. Wire this to that rather than to a bare fetch. */ generate?: Generate; + /** Required for `add({ file })` via HTTP or plane. */ + textExtractor?: TextExtractor; + /** Override durable storage (default: engine pgvector store). */ + documentStore?: DocumentStore; + /** Live source connectors (merge wired in CL-5227). */ + sources?: SourceProvider[]; + /** Memory port accepted for wiring; product in M3. */ + memory?: MemoryProvider; }; export type MountedKnowledgeEngine = { @@ -111,9 +148,18 @@ export function mountKnowledgeEngine( // Rerank config validation runs inside createKnowledgePlane so standalone // construction and the mount path share one check. Pass grants + generate so // the returned plane's ask() is grant-checked and can synthesize answers. - const knowledge = createKnowledgePlane(options.config, options.grants, { + const planeOptions: KnowledgePlaneOptions = { ...(options.generate ? { generate: options.generate } : {}), - }); + ...(options.textExtractor ? { textExtractor: options.textExtractor } : {}), + ...(options.documentStore ? { documentStore: options.documentStore } : {}), + ...(options.sources ? { sources: options.sources } : {}), + ...(options.memory ? { memory: options.memory } : {}), + }; + const knowledge = createKnowledgePlane( + options.config, + options.grants, + planeOptions, + ); const deps: RouteDeps = { knowledge, grants: options.grants, diff --git a/src/knowledge.ts b/src/knowledge.ts index 038b5d2..bc841df 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -30,11 +30,23 @@ import { } from "./services/timeline.ts"; import type { KnowledgeConfig } from "./mount-config.ts"; import type { GrantConfig } from "./routes/deps.ts"; +import type { + DocumentStore, + MemoryProvider, + SourceProvider, +} from "./ports/types.ts"; // Re-export so hosts typing plane results don't reach into services/. export type { HybridSearchResult } from "./services/search.ts"; export type { SearchHit } from "./core/schemas/search.ts"; export type { VisibilitySpec } from "./core/schemas/document.ts"; +export type { + DocumentStore, + DocumentStoreAddParams, + LiveSearchItem, + MemoryProvider, + SourceProvider, +} from "./ports/types.ts"; export type ChatMessage = { role: "system" | "user" | "assistant"; @@ -291,6 +303,20 @@ export type KnowledgePlaneOptions = { generate?: Generate; /** Required for `add({ file })`; omit if the host only adds text content. */ textExtractor?: TextExtractor; + /** + * Override durable storage. When set, the plane does not open Postgres or + * call embed/rerank endpoints — useful for fakes and alternate backends. + */ + documentStore?: DocumentStore; + /** + * Live source connectors. Wired into find/ask merge in CL-5227; accepted + * here so mounts can declare them early. + */ + sources?: SourceProvider[]; + /** + * Memory port type accepted for mount wiring; remember/recall product is M3. + */ + memory?: MemoryProvider; }; function resolveFindLimit(limit: number | undefined): number { @@ -407,12 +433,180 @@ function resolveShareAndVisibility(params: KnowledgeAddParams): { * * - `grants` is required for `ask()` (in-process capability check). Standalone * add/find callers may omit it — same as #8's out-of-band plane. - * - Rerank config is validated at construction (same as mount). + * - Rerank config is validated at construction (same as mount) when using the + * default Postgres-backed store. + * - Pass `options.documentStore` to skip Postgres entirely (fakes / overrides). + * When a store is provided, `config` may be omitted. */ export function createKnowledgePlane( - config: KnowledgeConfig, + config: KnowledgeConfig | undefined, grants?: GrantConfig, options: KnowledgePlaneOptions = {}, +): KnowledgePlane { + if (options.documentStore) { + return createPlaneFromStore(options.documentStore, grants, options); + } + if (!config) { + throw new KnowledgeError( + 500, + "KnowledgeConfig is required when documentStore is not provided", + ); + } + return createPlaneFromEngine(config, grants, options); +} + +/** Plane backed by an injected DocumentStore (fake or host override). */ +function createPlaneFromStore( + store: DocumentStore, + grants: GrantConfig | undefined, + options: KnowledgePlaneOptions, +): KnowledgePlane { + // sources/memory held for mount completeness; merge/memory product later. + void options.sources; + void options.memory; + + const plane: KnowledgePlane = { + async find(params) { + const limit = resolveFindLimit(params.limit); + return store.find({ + tenantId: params.tenantId, + principalId: params.principalId, + query: params.query, + limit, + ...(params.includeEvidence !== undefined + ? { includeEvidence: params.includeEvidence } + : {}), + }); + }, + + async ask(params) { + if (!grants) { + throw new KnowledgeError( + 501, + "ask() requires a GrantConfig. Pass grants to " + + "createKnowledgePlane/mountKnowledgeEngine.", + ); + } + const decision = await authorize( + grants.grantStore, + params.principalId, + params.tenantId, + "knowledge", + "find", + grants.conditionRegistry, + ); + if (decision.effect !== "allow") { + const effect = decision.effect ?? "no-matching-grant"; + log.info( + `ask: denied knowledge:find for ${params.principalId} (effect=${effect})`, + { + principalId: params.principalId, + effect, + }, + ); + throw new KnowledgeNotPermittedError(); + } + if (!options.generate) { + throw new KnowledgeError( + 501, + "ask() requires a `generate` function. Pass one to " + + "createKnowledgePlane/mountKnowledgeEngine, wired to your " + + "inference layer.", + ); + } + const findResult = await plane.find({ + tenantId: params.tenantId, + principalId: params.principalId, + query: params.query, + includeEvidence: true, + ...(params.limit !== undefined ? { limit: params.limit } : {}), + }); + return synthesizeAnswer( + params.query, + { + hits: findItemsToHits(findResult.items), + evidence: findResult.evidence ?? "none", + }, + options.generate, + ); + }, + + async add(params) { + const hasContent = params.content !== undefined; + const hasFile = params.file !== undefined; + if (hasContent === hasFile) { + throw new KnowledgeError( + 400, + "provide exactly one of content or file", + ); + } + + let title: string; + let text: string; + if (params.content) { + title = params.content.title; + text = params.content.text; + } else { + const file = params.file!; + if (!options.textExtractor) { + throw new KnowledgeError( + 400, + "file requires a textExtractor on the knowledge plane", + ); + } + const extracted = await options.textExtractor.extract({ + bytes: file.bytes, + ...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}), + ...(file.filename !== undefined ? { filename: file.filename } : {}), + }); + text = extracted.text; + title = + file.title ?? extracted.title ?? file.filename ?? "untitled"; + } + + const { visibility, blockPrincipalIds } = + resolveShareAndVisibility(params); + + return store.add({ + tenantId: params.tenantId, + principalId: params.principalId, + title, + text, + visibility, + ...(blockPrincipalIds !== undefined ? { blockPrincipalIds } : {}), + ...(params.attributes !== undefined + ? { attributes: params.attributes } + : {}), + ...(params.externalRef !== undefined + ? { externalRef: params.externalRef } + : {}), + }); + }, + + async recent(params) { + const limit = resolveRecentLimit(params.limit); + return store.recent({ + tenantId: params.tenantId, + principalId: params.principalId, + ...(limit !== undefined ? { limit } : {}), + }); + }, + + async close() { + await store.close(); + }, + }; + + return plane; +} + +/** + * Default plane: engine pgvector store + hybrid search. + */ +function createPlaneFromEngine( + config: KnowledgeConfig, + grants: GrantConfig | undefined, + options: KnowledgePlaneOptions, ): KnowledgePlane { // Catch a chunk-size / reranker-limit mismatch at construction time, rather // than silently on every find once the reranker starts rejecting batches. @@ -561,7 +755,7 @@ export function createKnowledgePlane( // data layers are independent and BOTH must allow. Per-document // visibility (enforced inside `find`) is not a substitute for "may // this principal search at all". -// Same action as HTTP find/ask/recent: knowledge:find. + // Same action as HTTP find/ask/recent: knowledge:find. if (!grants) { throw new KnowledgeError( 501, diff --git a/src/ports/fakes.test.ts b/src/ports/fakes.test.ts new file mode 100644 index 0000000..0850572 --- /dev/null +++ b/src/ports/fakes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "bun:test"; + +import { + createFakeDocumentStore, + createFakeSourceProvider, +} from "./fakes.ts"; + +const TENANT = "t1"; +const PRINCIPAL = "p1"; +const OTHER = "p2"; + +describe("createFakeDocumentStore", () => { + it("round-trips add → find → recent", async () => { + const store = createFakeDocumentStore(); + const { documentId } = await store.add({ + tenantId: TENANT, + principalId: PRINCIPAL, + title: "standup notes", + text: "shipped the ports foundation", + visibility: { mode: "tenant" }, + }); + expect(documentId).toMatch(/^fake_doc_/); + + const found = await store.find({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "ports foundation", + includeEvidence: true, + }); + expect(found.items).toHaveLength(1); + expect(found.items[0]?.documentId).toBe(documentId); + expect(found.evidence).toBe("weak"); + + const events = await store.recent({ + tenantId: TENANT, + principalId: PRINCIPAL, + }); + expect(events.map((e) => e.title)).toEqual(["standup notes"]); + await store.close(); + }); + + it("respects private visibility", async () => { + const store = createFakeDocumentStore(); + await store.add({ + tenantId: TENANT, + principalId: PRINCIPAL, + title: "secret", + text: "classified payload", + visibility: { mode: "private", principalIds: [PRINCIPAL] }, + }); + const asOwner = await store.find({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "classified", + }); + const asOther = await store.find({ + tenantId: TENANT, + principalId: OTHER, + query: "classified", + }); + expect(asOwner.items).toHaveLength(1); + expect(asOther.items).toHaveLength(0); + await store.close(); + }); + + it("honours block list", async () => { + const store = createFakeDocumentStore(); + await store.add({ + tenantId: TENANT, + principalId: PRINCIPAL, + title: "blocked from other", + text: "visible body", + visibility: { mode: "tenant" }, + blockPrincipalIds: [OTHER], + }); + const asOther = await store.find({ + tenantId: TENANT, + principalId: OTHER, + query: "visible", + }); + expect(asOther.items).toHaveLength(0); + await store.close(); + }); +}); + +describe("createFakeSourceProvider", () => { + it("searchLive filters catalog by query", async () => { + const source = createFakeSourceProvider("linear", [ + { + adapter: "linear", + externalRef: "CL-1", + title: "ports foundation", + snippet: "DocumentStore + SourceProvider", + score: 0.9, + kind: "issue", + citation: { + adapter: "linear", + external_ref: "CL-1", + open: { + type: "issue", + id: "CL-1", + url: "https://linear.app/x/issue/CL-1", + }, + }, + }, + { + adapter: "linear", + externalRef: "CL-2", + title: "unrelated", + snippet: "something else", + score: 0.1, + kind: "issue", + citation: { + adapter: "linear", + external_ref: "CL-2", + open: { + type: "issue", + id: "CL-2", + url: "https://linear.app/x/issue/CL-2", + }, + }, + }, + ]); + const hits = await source.searchLive!({ + query: "ports", + tenantId: TENANT, + principalId: PRINCIPAL, + }); + expect(hits).toHaveLength(1); + expect(hits[0]?.externalRef).toBe("CL-1"); + }); +}); diff --git a/src/ports/fakes.ts b/src/ports/fakes.ts new file mode 100644 index 0000000..dfb974f --- /dev/null +++ b/src/ports/fakes.ts @@ -0,0 +1,197 @@ +/** + * In-package fakes for DocumentStore and SourceProvider. + * Enough for hosts/tests to mount without Postgres or embed endpoints. + */ +import type { + DocumentStore, + DocumentStoreAddParams, + DocumentStoreFindParams, + DocumentStoreFindResult, + DocumentStoreRecentEvent, + DocumentStoreRecentParams, + LiveSearchItem, + MemoryProvider, + SourceProvider, +} from "./types.ts"; + +type StoredDoc = { + documentId: string; + tenantId: string; + principalId: string; + title: string; + text: string; + visibility: DocumentStoreAddParams["visibility"]; + blockPrincipalIds: string[]; + externalRef?: string; + createdAt: string; +}; + +function visibleTo(doc: StoredDoc, principalId: string): boolean { + if (doc.blockPrincipalIds.includes(principalId)) return false; + const v = doc.visibility; + if (v.mode === "tenant") return true; + if (v.mode === "private" || v.mode === "principals") { + return (v.principalIds ?? []).includes(principalId); + } + return false; +} + +function scoreMatch(query: string, title: string, text: string): number { + const q = query.toLowerCase().trim(); + if (!q) return 0; + const hay = `${title}\n${text}`.toLowerCase(); + if (!hay.includes(q)) return 0; + if (title.toLowerCase().includes(q)) return 1; + return 0.5; +} + +/** + * In-memory DocumentStore. ACL-aware substring match; no embeddings. + */ +export function createFakeDocumentStore(): DocumentStore { + const docs: StoredDoc[] = []; + let seq = 0; + + return { + async add(params) { + const documentId = `fake_doc_${++seq}`; + const row: StoredDoc = { + documentId, + tenantId: params.tenantId, + principalId: params.principalId, + title: params.title, + text: params.text, + visibility: params.visibility, + blockPrincipalIds: params.blockPrincipalIds ?? [], + createdAt: new Date().toISOString(), + }; + if (params.externalRef !== undefined) { + row.externalRef = params.externalRef; + } + docs.push(row); + return { documentId }; + }, + + async find( + params: DocumentStoreFindParams, + ): Promise { + const limit = params.limit ?? 8; + const items = docs + .filter( + (d) => + d.tenantId === params.tenantId && + visibleTo(d, params.principalId), + ) + .map((d) => { + const score = scoreMatch(params.query, d.title, d.text); + return { d, score }; + }) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ d, score }) => ({ + documentId: d.documentId, + title: d.title, + snippet: d.text.slice(0, 240), + score, + kind: "note", + citation: { + adapter: "fake", + external_ref: d.externalRef ?? d.documentId, + open: { + type: "document", + id: d.documentId, + url: `fake://${d.documentId}`, + }, + }, + })); + + if (params.includeEvidence) { + return { + items, + evidence: items.length === 0 ? "none" : "weak", + }; + } + return { items }; + }, + + async recent( + params: DocumentStoreRecentParams, + ): Promise { + const limit = params.limit ?? 50; + return docs + .filter( + (d) => + d.tenantId === params.tenantId && + visibleTo(d, params.principalId), + ) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) + .slice(0, limit) + .map((d) => ({ + at: d.createdAt, + title: d.title, + source: "fake", + tenantId: d.tenantId, + principalId: d.principalId, + })); + }, + + async close() { + docs.length = 0; + }, + }; +} + +/** + * SourceProvider with an optional fixed live catalog for tests. + */ +export function createFakeSourceProvider( + id: string, + catalog: LiveSearchItem[] = [], +): SourceProvider { + return { + id, + async searchLive(params) { + const q = params.query.toLowerCase(); + const limit = params.limit ?? 8; + return catalog + .filter( + (item) => + item.adapter === id && + (item.title.toLowerCase().includes(q) || + item.snippet.toLowerCase().includes(q)), + ) + .slice(0, limit); + }, + }; +} + +/** In-memory MemoryProvider for tests (M3 product wire still required). */ +export function createFakeMemoryProvider(): MemoryProvider { + const mem: Array<{ + tenantId: string; + principalId: string; + text: string; + }> = []; + return { + async remember(params) { + mem.push({ + tenantId: params.tenantId, + principalId: params.principalId, + text: params.text, + }); + }, + async recall(params) { + const q = params.query.toLowerCase(); + return mem + .filter( + (m) => + m.tenantId === params.tenantId && + m.principalId === params.principalId && + m.text.toLowerCase().includes(q), + ) + .slice(0, params.limit ?? 5) + .map((m) => ({ text: m.text, score: 1 })); + }, + }; +} diff --git a/src/ports/index.ts b/src/ports/index.ts new file mode 100644 index 0000000..e07a1b3 --- /dev/null +++ b/src/ports/index.ts @@ -0,0 +1,12 @@ +export type { + DocumentStore, + DocumentStoreAddParams, + LiveSearchItem, + MemoryProvider, + SourceProvider, +} from "./types.ts"; +export { + createFakeDocumentStore, + createFakeMemoryProvider, + createFakeSourceProvider, +} from "./fakes.ts"; diff --git a/src/ports/mount-fakes.test.ts b/src/ports/mount-fakes.test.ts new file mode 100644 index 0000000..cd9b289 --- /dev/null +++ b/src/ports/mount-fakes.test.ts @@ -0,0 +1,161 @@ +/** + * Acceptance: a host can mount with only fakes and get working + * add / find / ask / recent — proves the port boundary is real. + */ +import { describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { + createInMemoryGrantStore, + type GrantRule, +} from "@intx/authz"; + +import { + createFakeDocumentStore, + createFakeSourceProvider, + mountKnowledgeEngine, +} from "../index.ts"; + +const TENANT = "tenant_fake"; +const PRINCIPAL = "principal_fake"; + +function grant(action: string): GrantRule { + return { + id: `g-${action}`, + resource: "knowledge", + action, + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: PRINCIPAL, + }; +} + +function appWithPrincipal() { + const app = new Hono(); + app.use("*", async (c, next) => { + // Interchange's tenant middleware puts both principal + tenant on the + // context; requireGrant reads tenant.id, our caller() reads principal. + c.set("principal", { + id: PRINCIPAL, + tenantId: TENANT, + kind: "user", + refId: "u1", + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }); + c.set("tenant", { + id: TENANT, + name: "T1", + slug: "t1", + domain: "t1.test", + parentId: null, + config: {}, + createdAt: new Date(0), + updatedAt: new Date(0), + }); + await next(); + }); + return app; +} + +describe("mount with fakes only", () => { + it("add → find → recent → ask without Postgres or embed config", async () => { + const store = createFakeDocumentStore(); + const sources = [ + createFakeSourceProvider("linear", [ + { + adapter: "linear", + externalRef: "CL-99", + title: "live only issue", + snippet: "should not appear until merge lands", + score: 0.99, + kind: "issue", + citation: { + adapter: "linear", + external_ref: "CL-99", + open: { + type: "issue", + id: "CL-99", + url: "https://linear.app/x/issue/CL-99", + }, + }, + }, + ]), + ]; + const app = appWithPrincipal(); + const { knowledge } = mountKnowledgeEngine(app, { + grants: { + grantStore: createInMemoryGrantStore([grant("add"), grant("find")]), + conditionRegistry: {}, + }, + documentStore: store, + sources, + generate: async () => "Answer from local store [1].", + }); + + const { documentId } = await knowledge.add({ + tenantId: TENANT, + principalId: PRINCIPAL, + content: { + title: "ports note", + text: "DocumentStore override works end to end", + }, + }); + expect(documentId).toMatch(/^fake_doc_/); + + const found = await knowledge.find({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "DocumentStore override", + includeEvidence: true, + }); + expect(found.items).toHaveLength(1); + expect(found.items[0]?.documentId).toBe(documentId); + + const recent = await knowledge.recent({ + tenantId: TENANT, + principalId: PRINCIPAL, + }); + expect(recent.some((e) => e.title === "ports note")).toBe(true); + + const asked = await knowledge.ask({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "DocumentStore override", + }); + expect(asked.text).toContain("Answer from local store"); + expect(asked.citations.length).toBeGreaterThan(0); + + // HTTP surface also works without engine config + const addRes = await app.request("/api/knowledge/add", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "via http", + text: "http path uses the same store", + }), + }); + expect(addRes.status).toBe(200); + const addBody = (await addRes.json()) as { documentId: string }; + expect(addBody.documentId).toMatch(/^fake_doc_/); + + const findRes = await app.request("/api/knowledge/find", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "http path" }), + }); + expect(findRes.status).toBe(200); + const findBody = (await findRes.json()) as { + items: Array<{ documentId: string }>; + }; + expect( + findBody.items.some((i) => i.documentId === addBody.documentId), + ).toBe(true); + + await knowledge.close(); + }); +}); diff --git a/src/ports/types.ts b/src/ports/types.ts new file mode 100644 index 0000000..a49385a --- /dev/null +++ b/src/ports/types.ts @@ -0,0 +1,132 @@ +/** + * Port contracts for pluggable storage and live sources. + * + * DocumentStore + SourceProvider are the M2 foundation. MemoryProvider is a + * type stub only until M3 wires remember/recall product behavior. + */ +import type { VisibilitySpec } from "../core/schemas/document.ts"; +import type { + SearchEvidence, + SearchHit, + SearchHitCitation, +} from "../core/schemas/search.ts"; +import type { DegradeFlag } from "../core/hybrid-search.ts"; + +/** Input the plane hands the store after content/file/share resolution. */ +export type DocumentStoreAddParams = { + tenantId: string; + principalId: string; + title: string; + text: string; + visibility: VisibilitySpec; + blockPrincipalIds?: string[]; + attributes?: Record; + externalRef?: string; +}; + +export type DocumentStoreFindParams = { + tenantId: string; + principalId: string; + query: string; + limit?: number; + includeEvidence?: boolean; +}; + +export type DocumentStoreFindItem = { + documentId: string; + title: string; + snippet: string; + score: number; + kind: string; + citation: SearchHitCitation; + /** When set, used by merge dedupe (`adapter:externalRef`). */ + adapter?: string; + externalRef?: string; + updatedAt?: string; +}; + +export type DocumentStoreFindResult = { + items: DocumentStoreFindItem[]; + evidence?: SearchEvidence; + degraded?: DegradeFlag[]; +}; + +export type DocumentStoreRecentParams = { + tenantId: string; + principalId: string; + limit?: number; +}; + +export type DocumentStoreRecentEvent = { + at: string; + title: string; + source: string; + tenantId: string; + principalId: string; +}; + +/** + * Durable local document plane. Default implementation is the engine's + * pgvector store; hosts may inject a fake or alternate backend. + */ +export type DocumentStore = { + add(params: DocumentStoreAddParams): Promise<{ documentId: string }>; + find(params: DocumentStoreFindParams): Promise; + recent( + params: DocumentStoreRecentParams, + ): Promise; + close(): Promise; +}; + +/** + * One live hit from a SourceProvider.searchLive call. + * Dedupe key for merge is `adapter:externalRef` (CL-5227). + */ +export type LiveSearchItem = { + adapter: string; + externalRef: string; + title: string; + snippet: string; + score: number; + kind: string; + citation: SearchHitCitation; + /** ISO timestamp for recency prior when present. */ + updatedAt?: string; +}; + +/** + * Thin connector port. By default a provider only supplies capture inputs + * (adapter id + mapping live outside this type). `searchLive` is optional — + * providers that can answer live queries implement it. + */ +export type SourceProvider = { + readonly id: string; + searchLive?(params: { + query: string; + tenantId: string; + principalId: string; + limit?: number; + }): Promise; +}; + +/** + * M2 stub type only. remember/recall product wire is M3 (CL-5228). + * Adapters implement this in packages/*; core never imports vendor SDKs. + */ +export type MemoryProvider = { + remember(params: { + tenantId: string; + principalId: string; + text: string; + metadata?: Record; + }): Promise; + recall(params: { + tenantId: string; + principalId: string; + query: string; + limit?: number; + }): Promise>; +}; + +// Keep SearchHit import used if needed by consumers re-exporting citation shapes. +export type { SearchHit };