diff --git a/packages/knowledge-adapter-mem0/README.md b/packages/knowledge-adapter-mem0/README.md new file mode 100644 index 0000000..d843e52 --- /dev/null +++ b/packages/knowledge-adapter-mem0/README.md @@ -0,0 +1,72 @@ +# `@corbits/knowledge-adapter-mem0` + +Mem0-backed [`MemoryProvider`](https://github.com/corbitsdev/corbits-knowledge-engine) for `@corbits/knowledge-engine`. + +Pure HTTP (`fetch`) against the Mem0 Platform API — **no** `mem0ai` SDK dependency. Vendor code stays out of the knowledge-engine core. + +## Identity mapping + +Mem0 scopes memories by `user_id`. This adapter never sends a bare principal: + +```ts +mapUser(tenantId, principalId) // → `${tenantId}::${principalId}` +``` + +Empty/missing `tenantId` or `principalId` throws. Same principal under two tenants gets two distinct Mem0 users. + +## Usage + +```ts +import { createMem0MemoryProvider } from "@corbits/knowledge-adapter-mem0"; +import { createKnowledgePlane } from "@corbits/knowledge-engine"; + +const memory = createMem0MemoryProvider({ + apiKey: process.env.MEM0_API_KEY!, + // baseUrl: "https://api.mem0.ai", // optional + // fetch: customFetch, // optional (tests / proxies) +}); + +const plane = createKnowledgePlane(db, authz, { + documentStore, + memory, +}); + +await plane.remember({ + tenantId: "acme", + principalId: "user-42", + text: "Prefers TypeScript strict mode", +}); + +// ask() recalls only when includeMemory: true +const answer = await plane.ask({ + tenantId: "acme", + principalId: "user-42", + query: "What language preferences do I have?", + includeMemory: true, +}); +``` + +## Options + +| Option | Required | Description | +| --------- | -------- | ------------------------------------------------ | +| `apiKey` | yes | Mem0 API key (`Authorization: Token …`) | +| `baseUrl` | no | API origin (default `https://api.mem0.ai`) | +| `fetch` | no | Injectable `fetch` for tests / custom transports | + +## HTTP surface + +| Op | Method | Path | +| -------- | ------ | ---------------------- | +| remember | POST | `/v3/memories/add/` | +| recall | POST | `/v3/memories/search/` | + +Search filters always include `user_id: mapUser(tenantId, principalId)`. + +## Tests + +```bash +bun test +``` + +All tests use a mocked `fetch` — no live network. diff --git a/packages/knowledge-adapter-mem0/package.json b/packages/knowledge-adapter-mem0/package.json new file mode 100644 index 0000000..643940e --- /dev/null +++ b/packages/knowledge-adapter-mem0/package.json @@ -0,0 +1,47 @@ +{ + "name": "@corbits/knowledge-adapter-mem0", + "version": "0.1.0", + "description": "Mem0 MemoryProvider adapter for @corbits/knowledge-engine (pure fetch, no SDK)", + "type": "module", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "engines": { + "bun": ">=1.2.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.0" + }, + "license": "LGPL-2.1-only", + "author": "Sawyer Cutler ", + "repository": { + "type": "git", + "url": "git+https://github.com/corbitsdev/corbits-knowledge-engine.git", + "directory": "packages/knowledge-adapter-mem0" + }, + "homepage": "https://github.com/corbitsdev/corbits-knowledge-engine#readme", + "bugs": { + "url": "https://github.com/corbitsdev/corbits-knowledge-engine/issues" + }, + "keywords": [ + "mem0", + "memory", + "knowledge", + "corbits" + ], + "files": [ + "src", + "!src/**/*.test.ts", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.test.ts b/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.test.ts new file mode 100644 index 0000000..1e4abf5 --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "bun:test"; + +import { + createMem0MemoryProvider, + parseSearchResults, +} from "./create-mem0-memory-provider.ts"; + +type Captured = { + url: string; + method: string; + headers: Record; + body: unknown; +}; + +function mockFetch( + handler: (req: Captured) => { status?: number; json?: unknown }, +): { fetch: typeof fetch; calls: Captured[] } { + const calls: Captured[] = []; + const fetchImpl = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const headers: Record = {}; + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => { + headers[k] = v; + }); + } + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } + const cap: Captured = { + url, + method: init?.method ?? "GET", + headers, + body, + }; + calls.push(cap); + const result = handler(cap); + const status = result.status ?? 200; + const payload = + result.json === undefined ? "" : JSON.stringify(result.json); + return new Response(payload, { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { fetch: fetchImpl, calls }; +} + +describe("createMem0MemoryProvider", () => { + it("rejects missing apiKey", () => { + expect(() => createMem0MemoryProvider({ apiKey: "" })).toThrow(/apiKey/); + }); + + it("remember sends mapped user_id and never bare principal", async () => { + const { fetch, calls } = mockFetch(() => ({ + status: 200, + json: { event_id: "e1", status: "PENDING" }, + })); + const provider = createMem0MemoryProvider({ + apiKey: "test-key", + fetch, + }); + + await provider.remember({ + tenantId: "t1", + principalId: "p1", + text: "Prefers dark mode", + metadata: { source: "settings" }, + }); + + expect(calls).toHaveLength(1); + const call = calls[0]!; + expect(call.method).toBe("POST"); + expect(call.url).toBe("https://api.mem0.ai/v3/memories/add/"); + expect(call.headers["authorization"] ?? call.headers["Authorization"]).toBe( + "Token test-key", + ); + const body = call.body as Record; + expect(body.user_id).toBe("t1::p1"); + expect(body.user_id).not.toBe("p1"); + expect(body.messages).toEqual([ + { role: "user", content: "Prefers dark mode" }, + ]); + expect(body.metadata).toEqual({ source: "settings" }); + expect(body.infer).toBe(false); + }); + + it("recall scopes search filters by mapped user_id", async () => { + const { fetch, calls } = mockFetch(() => ({ + status: 200, + json: { + results: [ + { id: "m1", memory: "Lives in SF", score: 0.91 }, + { id: "m2", memory: "Works remote", score: 0.7 }, + ], + }, + })); + const provider = createMem0MemoryProvider({ + apiKey: "k", + baseUrl: "https://mem0.example.com/", + fetch, + }); + + const hits = await provider.recall({ + tenantId: "acme", + principalId: "bob", + query: "where do I live?", + limit: 3, + }); + + expect(calls).toHaveLength(1); + const call = calls[0]!; + expect(call.url).toBe("https://mem0.example.com/v3/memories/search/"); + const body = call.body as Record; + expect(body.query).toBe("where do I live?"); + expect(body.top_k).toBe(3); + expect(body.filters).toEqual({ user_id: "acme::bob" }); + expect(hits).toEqual([ + { text: "Lives in SF", score: 0.91 }, + { text: "Works remote", score: 0.7 }, + ]); + }); + + it("remember/recall reject empty identity (no silent default)", async () => { + const { fetch, calls } = mockFetch(() => ({ status: 200, json: {} })); + const provider = createMem0MemoryProvider({ apiKey: "k", fetch }); + + await expect( + provider.remember({ + tenantId: "", + principalId: "p", + text: "x", + }), + ).rejects.toThrow(/tenantId/); + + await expect( + provider.recall({ + tenantId: "t", + principalId: "", + query: "q", + }), + ).rejects.toThrow(/principalId/); + + expect(calls).toHaveLength(0); + }); + + it("throws on non-OK HTTP from Mem0", async () => { + const { fetch } = mockFetch(() => ({ + status: 401, + json: { detail: "Unauthorized" }, + })); + const provider = createMem0MemoryProvider({ apiKey: "bad", fetch }); + await expect( + provider.remember({ + tenantId: "t", + principalId: "p", + text: "x", + }), + ).rejects.toThrow(/HTTP 401/); + }); + + it("tenant isolation: same principal different tenants → distinct user_id", async () => { + const { fetch, calls } = mockFetch(() => ({ + status: 200, + json: { results: [] }, + })); + const provider = createMem0MemoryProvider({ apiKey: "k", fetch }); + + await provider.recall({ + tenantId: "tenant-a", + principalId: "alice", + query: "prefs", + }); + await provider.recall({ + tenantId: "tenant-b", + principalId: "alice", + query: "prefs", + }); + + const userIds = calls.map( + (c) => (c.body as { filters: { user_id: string } }).filters.user_id, + ); + expect(userIds).toEqual(["tenant-a::alice", "tenant-b::alice"]); + expect(userIds[0]).not.toBe(userIds[1]); + }); +}); + +describe("parseSearchResults", () => { + it("reads results[].memory", () => { + expect( + parseSearchResults({ + results: [{ memory: "a", score: 0.5 }], + }), + ).toEqual([{ text: "a", score: 0.5 }]); + }); + + it("handles empty / null", () => { + expect(parseSearchResults(null)).toEqual([]); + expect(parseSearchResults(undefined)).toEqual([]); + expect(parseSearchResults({})).toEqual([]); + }); +}); diff --git a/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.ts b/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.ts new file mode 100644 index 0000000..3c95270 --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/create-mem0-memory-provider.ts @@ -0,0 +1,139 @@ +import { mapUser } from "./map-user.ts"; +import type { Mem0MemoryProviderOptions, MemoryProvider } from "./types.ts"; + +const DEFAULT_BASE_URL = "https://api.mem0.ai"; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +async function readErrorBody(res: Response): Promise { + try { + const text = await res.text(); + return text.length > 500 ? `${text.slice(0, 500)}…` : text; + } catch { + return ""; + } +} + +/** + * Create a MemoryProvider backed by the Mem0 Platform HTTP API (v3). + * + * Uses pure fetch — no mem0 SDK. Inject `fetch` in tests. + */ +export function createMem0MemoryProvider( + opts: Mem0MemoryProviderOptions, +): MemoryProvider { + if (typeof opts.apiKey !== "string" || opts.apiKey.trim() === "") { + throw new Error( + "createMem0MemoryProvider: apiKey is required and must be a non-empty string", + ); + } + + const baseUrl = normalizeBaseUrl(opts.baseUrl ?? DEFAULT_BASE_URL); + const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis); + const authHeader = `Token ${opts.apiKey}`; + + async function mem0Post( + path: string, + body: Record, + ): Promise { + const url = `${baseUrl}${path}`; + const res = await doFetch(url, { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error( + `Mem0 API ${path} failed: HTTP ${res.status}${detail ? ` — ${detail}` : ""}`, + ); + } + + // 204 / empty body + if (res.status === 204) return undefined; + const text = await res.text(); + if (!text) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } + } + + return { + async remember(params) { + const userId = mapUser(params.tenantId, params.principalId); + const body: Record = { + messages: [{ role: "user", content: params.text }], + user_id: userId, + // Host already decided the fact; store verbatim. + infer: false, + }; + if (params.metadata !== undefined) { + body.metadata = params.metadata; + } + await mem0Post("/v3/memories/add/", body); + }, + + async recall(params) { + const userId = mapUser(params.tenantId, params.principalId); + const topK = params.limit ?? 5; + const raw = await mem0Post("/v3/memories/search/", { + query: params.query, + filters: { user_id: userId }, + top_k: topK, + }); + + return parseSearchResults(raw); + }, + }; +} + +/** Normalize Mem0 search JSON into MemoryProvider recall hits. */ +export function parseSearchResults( + raw: unknown, +): Array<{ text: string; score?: number }> { + if (raw == null) return []; + + let items: unknown[] = []; + if (Array.isArray(raw)) { + items = raw; + } else if (typeof raw === "object" && raw !== null) { + const obj = raw as Record; + if (Array.isArray(obj.results)) { + items = obj.results; + } else if (Array.isArray(obj.memories)) { + items = obj.memories; + } + } + + const out: Array<{ text: string; score?: number }> = []; + for (const item of items) { + if (item == null || typeof item !== "object") continue; + const row = item as Record; + const text = + typeof row.memory === "string" + ? row.memory + : typeof row.text === "string" + ? row.text + : null; + if (text == null) continue; + const score = + typeof row.score === "number" && Number.isFinite(row.score) + ? row.score + : undefined; + if (score === undefined) { + out.push({ text }); + } else { + out.push({ text, score }); + } + } + return out; +} diff --git a/packages/knowledge-adapter-mem0/src/index.ts b/packages/knowledge-adapter-mem0/src/index.ts new file mode 100644 index 0000000..22a8217 --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/index.ts @@ -0,0 +1,6 @@ +export type { MemoryProvider, Mem0MemoryProviderOptions } from "./types.ts"; +export { mapUser } from "./map-user.ts"; +export { + createMem0MemoryProvider, + parseSearchResults, +} from "./create-mem0-memory-provider.ts"; diff --git a/packages/knowledge-adapter-mem0/src/map-user.test.ts b/packages/knowledge-adapter-mem0/src/map-user.test.ts new file mode 100644 index 0000000..5afde6e --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/map-user.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; + +import { mapUser } from "./map-user.ts"; + +describe("mapUser", () => { + it("joins tenantId::principalId", () => { + expect(mapUser("tenant-a", "user-1")).toBe("tenant-a::user-1"); + }); + + it("isolates same principal across tenants", () => { + const a = mapUser("tenant-a", "alice"); + const b = mapUser("tenant-b", "alice"); + expect(a).toBe("tenant-a::alice"); + expect(b).toBe("tenant-b::alice"); + expect(a).not.toBe(b); + }); + + it("rejects empty tenantId", () => { + expect(() => mapUser("", "alice")).toThrow(/tenantId/); + expect(() => mapUser(" ", "alice")).toThrow(/tenantId/); + }); + + it("rejects empty principalId", () => { + expect(() => mapUser("tenant-a", "")).toThrow(/principalId/); + expect(() => mapUser("tenant-a", " ")).toThrow(/principalId/); + }); +}); diff --git a/packages/knowledge-adapter-mem0/src/map-user.ts b/packages/knowledge-adapter-mem0/src/map-user.ts new file mode 100644 index 0000000..6c53e43 --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/map-user.ts @@ -0,0 +1,19 @@ +/** + * Map Corbits (tenantId, principalId) → Mem0 user_id. + * + * Always `tenantId::principalId` so the same principal in different tenants + * never shares a Mem0 user. Bare principal is forbidden. + */ +export function mapUser(tenantId: string, principalId: string): string { + if (typeof tenantId !== "string" || tenantId.trim() === "") { + throw new Error( + "mapUser: tenantId is required and must be a non-empty string", + ); + } + if (typeof principalId !== "string" || principalId.trim() === "") { + throw new Error( + "mapUser: principalId is required and must be a non-empty string", + ); + } + return `${tenantId}::${principalId}`; +} diff --git a/packages/knowledge-adapter-mem0/src/types.ts b/packages/knowledge-adapter-mem0/src/types.ts new file mode 100644 index 0000000..c9a739e --- /dev/null +++ b/packages/knowledge-adapter-mem0/src/types.ts @@ -0,0 +1,27 @@ +/** + * MemoryProvider port — defined locally so this adapter never imports + * runtime from @corbits/knowledge-engine. Shape matches core ports/types. + */ +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>; +}; + +export type Mem0MemoryProviderOptions = { + /** Mem0 platform API key (sent as `Authorization: Token …`). */ + apiKey: string; + /** API origin; default `https://api.mem0.ai`. */ + baseUrl?: string; + /** Injectable fetch for tests; defaults to global fetch. */ + fetch?: typeof fetch; +}; diff --git a/packages/knowledge-adapter-mem0/tsconfig.json b/packages/knowledge-adapter-mem0/tsconfig.json new file mode 100644 index 0000000..20b1834 --- /dev/null +++ b/packages/knowledge-adapter-mem0/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/packages/knowledge-adapter-supermemory/README.md b/packages/knowledge-adapter-supermemory/README.md new file mode 100644 index 0000000..03afc7c --- /dev/null +++ b/packages/knowledge-adapter-supermemory/README.md @@ -0,0 +1,59 @@ +# `@corbits/knowledge-adapter-supermemory` + +Supermemory adapter for the Corbits Knowledge Engine `MemoryProvider` port. + +Pure `fetch` HTTP — **no** `supermemory` npm SDK. + +## Install + +```bash +bun add @corbits/knowledge-adapter-supermemory +``` + +## Usage + +```ts +import { + createSupermemoryMemoryProvider, + containerTag, +} from "@corbits/knowledge-adapter-supermemory"; + +const memory = createSupermemoryMemoryProvider({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + // baseUrl?: "https://api.supermemory.ai" // or self-hosted + // fetch?: myFetch // injectable for tests +}); + +// Mount on the knowledge plane +// createKnowledgePlane({ …, memory }) +``` + +### Container tags + +Tenant isolation maps to Supermemory `containerTag`: + +``` +t_{tenantId}_u_{principalId} +``` + +Example: `containerTag("acme", "alice")` → `t_acme_u_alice`. + +Empty `tenantId` / `principalId` are rejected. + +### Recall + +`recall` always sends `searchMode: "memories"` (extracted facts only). It never +relies on the API default. + +| Method | HTTP | +| -------- | ---------------------------- | +| remember | `POST /v3/documents` | +| recall | `POST /v4/search` | + +## Tests + +```bash +bun test +``` + +All network is mocked; no live Supermemory calls. diff --git a/packages/knowledge-adapter-supermemory/package.json b/packages/knowledge-adapter-supermemory/package.json new file mode 100644 index 0000000..d1f68b4 --- /dev/null +++ b/packages/knowledge-adapter-supermemory/package.json @@ -0,0 +1,46 @@ +{ + "name": "@corbits/knowledge-adapter-supermemory", + "version": "0.1.0", + "description": "Supermemory MemoryProvider adapter for Corbits Knowledge Engine (pure fetch, no vendor SDK)", + "exports": { + ".": "./src/index.ts" + }, + "license": "LGPL-2.1-only", + "type": "module", + "module": "src/index.ts", + "engines": { + "bun": ">=1.2.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test ./src" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.0" + }, + "author": "Sawyer Cutler ", + "repository": { + "type": "git", + "url": "git+https://github.com/corbitsdev/corbits-knowledge-engine.git", + "directory": "packages/knowledge-adapter-supermemory" + }, + "homepage": "https://github.com/corbitsdev/corbits-knowledge-engine#readme", + "bugs": { + "url": "https://github.com/corbitsdev/corbits-knowledge-engine/issues" + }, + "keywords": [ + "knowledge", + "memory", + "supermemory", + "corbits" + ], + "files": [ + "src", + "!src/**/*.test.ts", + "README.md" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/knowledge-adapter-supermemory/src/index.test.ts b/packages/knowledge-adapter-supermemory/src/index.test.ts new file mode 100644 index 0000000..54b949c --- /dev/null +++ b/packages/knowledge-adapter-supermemory/src/index.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { + containerTag, + createSupermemoryMemoryProvider, +} from "./index.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("containerTag", () => { + it("maps tenant + principal to t_{tenant}_u_{principal}", () => { + expect(containerTag("acme", "alice")).toBe("t_acme_u_alice"); + expect(containerTag("org-1", "user-42")).toBe("t_org-1_u_user-42"); + }); + + it("produces distinct tags per tenant for the same principal", () => { + const a = containerTag("tenant-a", "user-1"); + const b = containerTag("tenant-b", "user-1"); + expect(a).toBe("t_tenant-a_u_user-1"); + expect(b).toBe("t_tenant-b_u_user-1"); + expect(a).not.toBe(b); + }); + + it("rejects empty tenantId or principalId", () => { + expect(() => containerTag("", "alice")).toThrow(/non-empty/); + expect(() => containerTag("acme", "")).toThrow(/non-empty/); + expect(() => containerTag("", "")).toThrow(/non-empty/); + }); +}); + +describe("createSupermemoryMemoryProvider", () => { + it("rejects empty identity on remember", async () => { + const fetchImpl = mock(() => Promise.resolve(jsonResponse({ id: "x" }))); + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + fetch: fetchImpl as unknown as typeof fetch, + }); + + await expect( + provider.remember({ + tenantId: "", + principalId: "alice", + text: "hello", + }), + ).rejects.toThrow(/non-empty/); + + await expect( + provider.remember({ + tenantId: "acme", + principalId: "", + text: "hello", + }), + ).rejects.toThrow(/non-empty/); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("rejects empty identity on recall", async () => { + const fetchImpl = mock(() => Promise.resolve(jsonResponse({ results: [] }))); + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + fetch: fetchImpl as unknown as typeof fetch, + }); + + await expect( + provider.recall({ + tenantId: "", + principalId: "alice", + query: "prefs", + }), + ).rejects.toThrow(/non-empty/); + + await expect( + provider.recall({ + tenantId: "acme", + principalId: "", + query: "prefs", + }), + ).rejects.toThrow(/non-empty/); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("remember posts to /v3/documents with containerTag", async () => { + const fetchImpl = mock((url: string, init?: RequestInit) => { + expect(url).toBe("https://api.supermemory.ai/v3/documents"); + expect(init?.method).toBe("POST"); + const headers = init?.headers as Record; + expect(headers.authorization).toBe("Bearer test-key"); + const body = JSON.parse(init?.body as string) as { + content: string; + containerTag: string; + metadata?: Record; + }; + expect(body.content).toBe("prefers dark mode"); + expect(body.containerTag).toBe("t_acme_u_alice"); + expect(body.metadata).toEqual({ source: "chat" }); + return Promise.resolve(jsonResponse({ id: "doc_1", status: "queued" })); + }); + + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + fetch: fetchImpl as unknown as typeof fetch, + }); + + await provider.remember({ + tenantId: "acme", + principalId: "alice", + text: "prefers dark mode", + metadata: { source: "chat" }, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("recall always sends searchMode: memories (never default)", async () => { + const fetchImpl = mock((url: string, init?: RequestInit) => { + expect(url).toBe("https://api.supermemory.ai/v4/search"); + expect(init?.method).toBe("POST"); + const body = JSON.parse(init?.body as string) as { + q: string; + containerTag: string; + searchMode: string; + limit?: number; + }; + expect(body.q).toBe("preferences"); + expect(body.containerTag).toBe("t_acme_u_alice"); + expect(body.searchMode).toBe("memories"); + expect(body.limit).toBe(3); + // Must not omit searchMode (would fall through to API default). + expect("searchMode" in body).toBe(true); + return Promise.resolve( + jsonResponse({ + results: [ + { + id: "mem_1", + memory: "User prefers dark mode", + similarity: 0.92, + }, + ], + }), + ); + }); + + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + fetch: fetchImpl as unknown as typeof fetch, + }); + + const hits = await provider.recall({ + tenantId: "acme", + principalId: "alice", + query: "preferences", + limit: 3, + }); + + expect(hits).toEqual([ + { text: "User prefers dark mode", score: 0.92 }, + ]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("scopes container tags distinctly per tenant on remember", async () => { + const tags: string[] = []; + const fetchImpl = mock((_url: string, init?: RequestInit) => { + const body = JSON.parse(init?.body as string) as { containerTag: string }; + tags.push(body.containerTag); + return Promise.resolve(jsonResponse({ id: "x" })); + }); + + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + fetch: fetchImpl as unknown as typeof fetch, + }); + + await provider.remember({ + tenantId: "tenant-a", + principalId: "user-1", + text: "fact a", + }); + await provider.remember({ + tenantId: "tenant-b", + principalId: "user-1", + text: "fact b", + }); + + expect(tags).toEqual(["t_tenant-a_u_user-1", "t_tenant-b_u_user-1"]); + }); + + it("uses custom baseUrl when provided", async () => { + const fetchImpl = mock((url: string) => { + expect(url).toBe("http://localhost:6767/v4/search"); + return Promise.resolve(jsonResponse({ results: [] })); + }); + + const provider = createSupermemoryMemoryProvider({ + apiKey: "test-key", + baseUrl: "http://localhost:6767/", + fetch: fetchImpl as unknown as typeof fetch, + }); + + await provider.recall({ + tenantId: "t", + principalId: "u", + query: "q", + }); + }); + + it("throws when apiKey is empty", () => { + expect(() => + createSupermemoryMemoryProvider({ apiKey: "" }), + ).toThrow(/apiKey/); + }); +}); diff --git a/packages/knowledge-adapter-supermemory/src/index.ts b/packages/knowledge-adapter-supermemory/src/index.ts new file mode 100644 index 0000000..21e9707 --- /dev/null +++ b/packages/knowledge-adapter-supermemory/src/index.ts @@ -0,0 +1,161 @@ +/** + * Supermemory MemoryProvider adapter. + * + * Pure fetch HTTP against the Supermemory REST API — no vendor SDK. + * MemoryProvider is defined locally so this package never imports the + * knowledge-engine runtime. + */ + +/** Local port contract (mirrors knowledge-engine MemoryProvider). */ +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>; +}; + +const DEFAULT_BASE_URL = "https://api.supermemory.ai"; + +/** + * Map tenant + principal to a Supermemory containerTag. + * Format: `t_{tenantId}_u_{principalId}` + */ +export function containerTag(tenantId: string, principalId: string): string { + if (tenantId === "" || principalId === "") { + throw new Error( + "containerTag requires non-empty tenantId and principalId", + ); + } + return `t_${tenantId}_u_${principalId}`; +} + +export type SupermemoryMemoryProviderOpts = { + apiKey: string; + /** API root (no trailing slash). Default: https://api.supermemory.ai */ + baseUrl?: string; + /** Injectable fetch for tests. Default: globalThis.fetch */ + fetch?: typeof fetch; +}; + +function requireIdentity(tenantId: string, principalId: string): void { + if (tenantId === "" || principalId === "") { + throw new Error( + "tenantId and principalId must be non-empty strings", + ); + } +} + +function jsonHeaders(apiKey: string): Record { + return { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }; +} + +async function readErrorBody(res: Response): Promise { + try { + const text = await res.text(); + return text.length > 200 ? `${text.slice(0, 200)}…` : text; + } catch { + return ""; + } +} + +/** + * Create a MemoryProvider backed by Supermemory (v3 documents + v4 search). + * + * recall always sends `searchMode: "memories"` so only extracted facts are + * returned — never hybrid/documents defaults. + */ +export function createSupermemoryMemoryProvider( + opts: SupermemoryMemoryProviderOpts, +): MemoryProvider { + const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl = opts.fetch ?? globalThis.fetch; + const { apiKey } = opts; + + if (!apiKey) { + throw new Error("createSupermemoryMemoryProvider requires a non-empty apiKey"); + } + + return { + async remember(params) { + requireIdentity(params.tenantId, params.principalId); + const tag = containerTag(params.tenantId, params.principalId); + const body: Record = { + content: params.text, + containerTag: tag, + }; + if (params.metadata !== undefined) { + body.metadata = params.metadata; + } + + const res = await fetchImpl(`${baseUrl}/v3/documents`, { + method: "POST", + headers: jsonHeaders(apiKey), + body: JSON.stringify(body), + }); + if (!res.ok) { + const snippet = await readErrorBody(res); + throw new Error( + `Supermemory remember failed HTTP ${res.status}: ${snippet}`, + ); + } + }, + + async recall(params) { + requireIdentity(params.tenantId, params.principalId); + const tag = containerTag(params.tenantId, params.principalId); + const body: Record = { + q: params.query, + containerTag: tag, + // Always memories — never rely on API default. + searchMode: "memories", + }; + if (params.limit !== undefined) { + body.limit = params.limit; + } + + const res = await fetchImpl(`${baseUrl}/v4/search`, { + method: "POST", + headers: jsonHeaders(apiKey), + body: JSON.stringify(body), + }); + if (!res.ok) { + const snippet = await readErrorBody(res); + throw new Error( + `Supermemory recall failed HTTP ${res.status}: ${snippet}`, + ); + } + + const data = (await res.json()) as { + results?: Array<{ + memory?: string; + chunk?: string; + similarity?: number; + }>; + }; + + const results = data.results ?? []; + return results + .map((r) => { + const text = r.memory ?? r.chunk ?? ""; + if (text === "") return null; + const item: { text: string; score?: number } = { text }; + if (typeof r.similarity === "number") { + item.score = r.similarity; + } + return item; + }) + .filter((x): x is { text: string; score?: number } => x !== null); + }, + }; +} diff --git a/packages/knowledge-adapter-supermemory/tsconfig.json b/packages/knowledge-adapter-supermemory/tsconfig.json new file mode 100644 index 0000000..20b1834 --- /dev/null +++ b/packages/knowledge-adapter-supermemory/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/packages/knowledge-source-linear/README.md b/packages/knowledge-source-linear/README.md new file mode 100644 index 0000000..2821390 --- /dev/null +++ b/packages/knowledge-source-linear/README.md @@ -0,0 +1,67 @@ +# @corbits/knowledge-source-linear + +Thin `SourceProvider` mapper for Linear. **Host owns OAuth, webhook signature +verification, and cron/reconciliation** — this package authenticates nothing. + +## What this is + +- `createLinearSourceProvider` — optional live search (`searchLive`) against the + Linear GraphQL API using a host-supplied access token. +- Webhook mappers (`mapIssueCreated` / `mapIssueUpdated` / `mapIssueRemoved`, or + `mapLinearWebhook`) — turn Linear issue webhook payloads into `AdaptedDocument` + shapes ready for `knowledge.capture()`. + +## What the host does + +1. **OAuth / tokens** — obtain and refresh Linear access tokens; pass + `accessToken` into the provider factory. +2. **Webhook verify** — validate Linear webhook signatures before calling a + mapper; never trust raw body bytes without verification. +3. **Cron / backfill** — schedule reconciliation pulls if needed; call capture + with mapped documents on a schedule. +4. **Capture** — call `knowledge.capture({ adapter: "linear", document })` (or + the HTTP capture route) with the mapped document. + +## Visibility rules (overshare guard) + +| Linear issue | Mapped visibility | +| --- | --- | +| Private (`private: true` or `team.private: true`) | `private` (single principal) or `principals` (creator + assignee + subscribers only). **Never `tenant`.** | +| Team-visible | `tenant` (company-brain default). Never `source_acl`. | + +Actor kind on all sync writes is always `adapter` — never the webhook installer's +human identity. + +## Usage + +```ts +import { + createLinearSourceProvider, + mapLinearWebhook, +} from "@corbits/knowledge-source-linear"; + +// Live search (host injects token; inject fetch in tests) +const linear = createLinearSourceProvider({ + accessToken: process.env.LINEAR_TOKEN!, + teamId: "optional-team-filter", +}); +// plane options.sources = [linear] + +// Webhook path (host already verified the signature) +const mapped = mapLinearWebhook(payload); +if (mapped) { + await knowledge.capture({ + adapter: "linear", + document: mapped.document, + }); +} +``` + +## Tests + +```bash +bun test +``` + +All network is mocked; fixtures under `fixtures/` drive golden AdaptedDocument +assertions. diff --git a/packages/knowledge-source-linear/fixtures/issue-created.json b/packages/knowledge-source-linear/fixtures/issue-created.json new file mode 100644 index 0000000..3ce4ef4 --- /dev/null +++ b/packages/knowledge-source-linear/fixtures/issue-created.json @@ -0,0 +1,31 @@ +{ + "action": "create", + "type": "Issue", + "createdAt": "2026-03-01T12:00:00.000Z", + "url": "https://linear.app/acme/issue/CL-100", + "data": { + "id": "issue-uuid-100", + "identifier": "CL-100", + "title": "Wire Linear SourceProvider", + "description": "Thin mapper package; host owns OAuth and webhooks.", + "url": "https://linear.app/acme/issue/CL-100", + "priority": 2, + "teamId": "team-uuid-1", + "creatorId": "user-alice", + "assigneeId": "user-bob", + "subscriberIds": ["user-alice", "user-bob", "user-carol"], + "stateId": "state-todo", + "createdAt": "2026-03-01T12:00:00.000Z", + "updatedAt": "2026-03-01T12:00:00.000Z", + "private": false, + "team": { + "id": "team-uuid-1", + "key": "CL", + "name": "Corbits", + "private": false + }, + "creator": { "id": "user-alice", "name": "Alice" }, + "assignee": { "id": "user-bob", "name": "Bob" }, + "state": { "id": "state-todo", "name": "Todo", "type": "unstarted" } + } +} diff --git a/packages/knowledge-source-linear/fixtures/issue-removed.json b/packages/knowledge-source-linear/fixtures/issue-removed.json new file mode 100644 index 0000000..7482009 --- /dev/null +++ b/packages/knowledge-source-linear/fixtures/issue-removed.json @@ -0,0 +1,31 @@ +{ + "action": "remove", + "type": "Issue", + "createdAt": "2026-03-03T16:00:00.000Z", + "url": "https://linear.app/acme/issue/CL-100", + "data": { + "id": "issue-uuid-100", + "identifier": "CL-100", + "title": "Wire Linear SourceProvider (done)", + "description": "Thin mapper package; host owns OAuth and webhooks. Shipped.", + "url": "https://linear.app/acme/issue/CL-100", + "priority": 2, + "teamId": "team-uuid-1", + "creatorId": "user-alice", + "assigneeId": "user-bob", + "subscriberIds": ["user-alice", "user-bob"], + "stateId": "state-done", + "createdAt": "2026-03-01T12:00:00.000Z", + "updatedAt": "2026-03-03T16:00:00.000Z", + "private": false, + "team": { + "id": "team-uuid-1", + "key": "CL", + "name": "Corbits", + "private": false + }, + "creator": { "id": "user-alice", "name": "Alice" }, + "assignee": { "id": "user-bob", "name": "Bob" }, + "state": { "id": "state-done", "name": "Done", "type": "completed" } + } +} diff --git a/packages/knowledge-source-linear/fixtures/issue-updated.json b/packages/knowledge-source-linear/fixtures/issue-updated.json new file mode 100644 index 0000000..1e4ac10 --- /dev/null +++ b/packages/knowledge-source-linear/fixtures/issue-updated.json @@ -0,0 +1,35 @@ +{ + "action": "update", + "type": "Issue", + "createdAt": "2026-03-02T09:30:00.000Z", + "url": "https://linear.app/acme/issue/CL-100", + "updatedFrom": { + "title": "Wire Linear SourceProvider", + "updatedAt": "2026-03-01T12:00:00.000Z" + }, + "data": { + "id": "issue-uuid-100", + "identifier": "CL-100", + "title": "Wire Linear SourceProvider (done)", + "description": "Thin mapper package; host owns OAuth and webhooks. Shipped.", + "url": "https://linear.app/acme/issue/CL-100", + "priority": 2, + "teamId": "team-uuid-1", + "creatorId": "user-alice", + "assigneeId": "user-bob", + "subscriberIds": ["user-alice", "user-bob"], + "stateId": "state-done", + "createdAt": "2026-03-01T12:00:00.000Z", + "updatedAt": "2026-03-02T09:30:00.000Z", + "private": false, + "team": { + "id": "team-uuid-1", + "key": "CL", + "name": "Corbits", + "private": false + }, + "creator": { "id": "user-alice", "name": "Alice" }, + "assignee": { "id": "user-bob", "name": "Bob" }, + "state": { "id": "state-done", "name": "Done", "type": "completed" } + } +} diff --git a/packages/knowledge-source-linear/fixtures/private-issue-created.json b/packages/knowledge-source-linear/fixtures/private-issue-created.json new file mode 100644 index 0000000..6823f26 --- /dev/null +++ b/packages/knowledge-source-linear/fixtures/private-issue-created.json @@ -0,0 +1,31 @@ +{ + "action": "create", + "type": "Issue", + "createdAt": "2026-03-01T14:00:00.000Z", + "url": "https://linear.app/acme/issue/CL-PRIV-1", + "data": { + "id": "issue-uuid-priv-1", + "identifier": "CL-PRIV-1", + "title": "Confidential hiring plan", + "description": "Private team issue — must not become tenant-visible.", + "url": "https://linear.app/acme/issue/CL-PRIV-1", + "priority": 1, + "teamId": "team-private-hr", + "creatorId": "user-alice", + "assigneeId": "user-dave", + "subscriberIds": ["user-alice", "user-dave"], + "stateId": "state-todo", + "createdAt": "2026-03-01T14:00:00.000Z", + "updatedAt": "2026-03-01T14:00:00.000Z", + "private": true, + "team": { + "id": "team-private-hr", + "key": "HR", + "name": "HR Private", + "private": true + }, + "creator": { "id": "user-alice", "name": "Alice" }, + "assignee": { "id": "user-dave", "name": "Dave" }, + "state": { "id": "state-todo", "name": "Todo", "type": "unstarted" } + } +} diff --git a/packages/knowledge-source-linear/fixtures/private-issue-solo.json b/packages/knowledge-source-linear/fixtures/private-issue-solo.json new file mode 100644 index 0000000..7d8434b --- /dev/null +++ b/packages/knowledge-source-linear/fixtures/private-issue-solo.json @@ -0,0 +1,23 @@ +{ + "action": "create", + "type": "Issue", + "createdAt": "2026-03-01T15:00:00.000Z", + "data": { + "id": "issue-uuid-priv-solo", + "identifier": "CL-PRIV-2", + "title": "Solo private note", + "description": "Only the creator is a principal.", + "priority": 4, + "teamId": "team-private-hr", + "creatorId": "user-alice", + "assigneeId": "user-alice", + "subscriberIds": ["user-alice"], + "private": true, + "team": { + "id": "team-private-hr", + "private": true + }, + "creator": { "id": "user-alice", "name": "Alice" }, + "assignee": { "id": "user-alice", "name": "Alice" } + } +} diff --git a/packages/knowledge-source-linear/package.json b/packages/knowledge-source-linear/package.json new file mode 100644 index 0000000..9950045 --- /dev/null +++ b/packages/knowledge-source-linear/package.json @@ -0,0 +1,37 @@ +{ + "name": "@corbits/knowledge-source-linear", + "version": "0.1.0", + "description": "Thin Linear SourceProvider mapper for @corbits/knowledge-engine. Host owns OAuth, webhooks, and cron.", + "license": "LGPL-2.1-only", + "type": "module", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "engines": { + "bun": ">=1.2.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test ./src ./fixtures" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.0" + }, + "files": [ + "src", + "!src/**/*.test.ts", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "keywords": [ + "linear", + "knowledge", + "source-provider", + "corbits" + ] +} diff --git a/packages/knowledge-source-linear/src/hash.ts b/packages/knowledge-source-linear/src/hash.ts new file mode 100644 index 0000000..d8f6ee5 --- /dev/null +++ b/packages/knowledge-source-linear/src/hash.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortValue); + } + if (value !== null && typeof value === "object") { + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortValue((value as Record)[key]); + } + return sorted; + } + return value; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +/** NOOP key for AdaptedDocument — same logical content → same hash. */ +export function contentHash(parts: { + title: string; + kind: string; + externalRef: string; + attributes: Record; + chunkTexts: readonly string[]; +}): string { + const raw = [ + parts.title, + parts.kind, + parts.externalRef, + stableStringify(parts.attributes), + parts.chunkTexts.join(""), + ].join(" "); + return createHash("sha256").update(raw).digest("hex"); +} diff --git a/packages/knowledge-source-linear/src/index.ts b/packages/knowledge-source-linear/src/index.ts new file mode 100644 index 0000000..606eaba --- /dev/null +++ b/packages/knowledge-source-linear/src/index.ts @@ -0,0 +1,35 @@ +/** + * @corbits/knowledge-source-linear + * + * Thin Linear SourceProvider + webhook → AdaptedDocument mappers. + * Host owns OAuth, webhook signature verification, and cron. + */ + +export { createLinearSourceProvider } from "./provider.ts"; +export { + mapIssueCreated, + mapIssueUpdated, + mapIssueRemoved, + mapLinearWebhook, + mapIssueToAdaptedDocument, + ADAPTER, +} from "./map-webhook.ts"; +export { + mapIssueVisibility, + collectPrincipalIds, + isPrivateIssue, +} from "./visibility.ts"; +export { contentHash, stableStringify } from "./hash.ts"; + +export type { + LiveSearchItem, + SourceProvider, + AdaptedDocument, + VisibilitySpec, + LinearIssueData, + LinearWebhookEvent, + LinearWebhookAction, + MappedWebhookResult, + CreateLinearSourceProviderOpts, + FetchLike, +} from "./types.ts"; diff --git a/packages/knowledge-source-linear/src/map-webhook.test.ts b/packages/knowledge-source-linear/src/map-webhook.test.ts new file mode 100644 index 0000000..66b7e92 --- /dev/null +++ b/packages/knowledge-source-linear/src/map-webhook.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + mapIssueCreated, + mapIssueRemoved, + mapIssueUpdated, + mapLinearWebhook, +} from "./map-webhook.ts"; +import type { AdaptedDocument, LinearWebhookEvent } from "./types.ts"; + +const FIXTURES = join(import.meta.dir, "..", "fixtures"); + +function loadFixture(name: string): LinearWebhookEvent { + const raw = readFileSync(join(FIXTURES, name), "utf8"); + return JSON.parse(raw) as LinearWebhookEvent; +} + +function assertAdapterActor(doc: AdaptedDocument) { + expect(doc.actor.kind).toBe("adapter"); + expect(doc.actor.principalId).toBeUndefined(); +} + +function assertNeverSourceAcl(doc: AdaptedDocument) { + expect((doc.visibility as { mode: string }).mode).not.toBe("source_acl"); +} + +describe("fixture → AdaptedDocument goldens", () => { + it("mapIssueCreated: team-visible issue → tenant visibility, adapter actor", () => { + const event = loadFixture("issue-created.json"); + const doc = mapIssueCreated(event); + + expect(doc.kind).toBe("issue"); + expect(doc.title).toBe("Wire Linear SourceProvider"); + expect(doc.externalRef).toBe("CL-100"); + expect(doc.visibility).toEqual({ mode: "tenant" }); + assertNeverSourceAcl(doc); + assertAdapterActor(doc); + expect(doc.chunks).toHaveLength(1); + expect(doc.chunks[0]?.ordinal).toBe(0); + expect(doc.chunks[0]?.text).toContain("Wire Linear SourceProvider"); + expect(doc.chunks[0]?.text).toContain("Thin mapper package"); + expect(doc.attributes?.linear_id).toBe("issue-uuid-100"); + expect(doc.attributes?.identifier).toBe("CL-100"); + expect(doc.attributes?.removed).toBeUndefined(); + expect(doc.contentHash).toMatch(/^[a-f0-9]{64}$/); + expect(doc.entityHints.length).toBeGreaterThanOrEqual(1); + }); + + it("mapIssueUpdated: reflects new title/description and new contentHash", () => { + const created = mapIssueCreated(loadFixture("issue-created.json")); + const updated = mapIssueUpdated(loadFixture("issue-updated.json")); + + expect(updated.title).toBe("Wire Linear SourceProvider (done)"); + expect(updated.externalRef).toBe("CL-100"); + expect(updated.visibility).toEqual({ mode: "tenant" }); + assertAdapterActor(updated); + expect(updated.attributes?.state_name).toBe("Done"); + expect(updated.chunks[0]?.text).toContain("Shipped"); + expect(updated.contentHash).not.toBe(created.contentHash); + }); + + it("mapIssueRemoved: empty chunks, removed attribute, same externalRef", () => { + const doc = mapIssueRemoved(loadFixture("issue-removed.json")); + + expect(doc.externalRef).toBe("CL-100"); + expect(doc.title).toBe("Wire Linear SourceProvider (done)"); + expect(doc.chunks).toEqual([]); + expect(doc.attributes?.removed).toBe(true); + expect(doc.visibility).toEqual({ mode: "tenant" }); + assertAdapterActor(doc); + expect(doc.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it("overshare guard: private multi-principal issue never maps to tenant", () => { + const doc = mapIssueCreated(loadFixture("private-issue-created.json")); + + expect(doc.visibility.mode).not.toBe("tenant"); + expect(doc.visibility.mode).toBe("principals"); + expect(doc.visibility.principalIds).toEqual( + expect.arrayContaining(["user-alice", "user-dave"]), + ); + expect(doc.visibility.principalIds).toHaveLength(2); + assertNeverSourceAcl(doc); + assertAdapterActor(doc); + expect(doc.externalRef).toBe("CL-PRIV-1"); + }); + + it("overshare guard: private solo issue → private mode, never tenant", () => { + const doc = mapIssueCreated(loadFixture("private-issue-solo.json")); + + expect(doc.visibility.mode).toBe("private"); + expect(doc.visibility.mode).not.toBe("tenant"); + expect(doc.visibility.principalIds).toEqual(["user-alice"]); + assertAdapterActor(doc); + }); + + it("private via team.private alone (no issue.private) still not tenant", () => { + const event = loadFixture("private-issue-created.json"); + // Simulate team-private without top-level private flag + const data = { + ...event.data, + private: false, + team: { ...event.data.team, private: true }, + }; + const doc = mapIssueCreated({ ...event, data }); + expect(doc.visibility.mode).not.toBe("tenant"); + expect(["private", "principals"]).toContain(doc.visibility.mode); + }); +}); + +describe("mapLinearWebhook dispatcher", () => { + it("dispatches create/update/remove", () => { + const c = mapLinearWebhook(loadFixture("issue-created.json")); + expect(c?.action).toBe("create"); + expect(c?.document.externalRef).toBe("CL-100"); + + const u = mapLinearWebhook(loadFixture("issue-updated.json")); + expect(u?.action).toBe("update"); + expect(u?.document.title).toContain("done"); + + const r = mapLinearWebhook(loadFixture("issue-removed.json")); + expect(r?.action).toBe("remove"); + expect(r?.document.attributes?.removed).toBe(true); + }); + + it("returns null for non-Issue types", () => { + const event = loadFixture("issue-created.json"); + expect(mapLinearWebhook({ ...event, type: "Comment" })).toBeNull(); + }); + + it("returns null for unknown actions", () => { + const event = loadFixture("issue-created.json"); + expect(mapLinearWebhook({ ...event, action: "restore" })).toBeNull(); + }); +}); + +describe("deterministic goldens (stable contentHash)", () => { + it("same fixture maps to identical contentHash twice", () => { + const a = mapIssueCreated(loadFixture("issue-created.json")); + const b = mapIssueCreated(loadFixture("issue-created.json")); + expect(a.contentHash).toBe(b.contentHash); + expect(a).toEqual(b); + }); +}); diff --git a/packages/knowledge-source-linear/src/map-webhook.ts b/packages/knowledge-source-linear/src/map-webhook.ts new file mode 100644 index 0000000..f83c718 --- /dev/null +++ b/packages/knowledge-source-linear/src/map-webhook.ts @@ -0,0 +1,156 @@ +import { contentHash } from "./hash.ts"; +import type { + AdaptedDocument, + LinearIssueData, + LinearWebhookAction, + LinearWebhookEvent, + MappedWebhookResult, +} from "./types.ts"; +import { mapIssueVisibility } from "./visibility.ts"; + +const ADAPTER = "linear"; +const KIND_ISSUE = "issue"; + +function externalRef(data: LinearIssueData): string { + return data.identifier?.trim() || data.id; +} + +function issueTitle(data: LinearIssueData): string { + const t = data.title?.trim(); + return t && t.length > 0 ? t : externalRef(data); +} + +function issueUrl(data: LinearIssueData, eventUrl?: string): string | undefined { + return data.url ?? eventUrl ?? undefined; +} + +function buildAttributes( + data: LinearIssueData, + opts: { removed?: boolean } = {}, +): Record { + const attrs: Record = { + linear_id: data.id, + }; + if (data.identifier != null) attrs.identifier = data.identifier; + if (data.priority != null) attrs.priority = data.priority; + if (data.teamId != null) attrs.team_id = data.teamId; + if (data.stateId != null) attrs.state_id = data.stateId; + if (data.state?.name != null) attrs.state_name = data.state.name; + if (data.state?.type != null) attrs.state_type = data.state.type; + if (data.assigneeId != null) attrs.assignee_id = data.assigneeId; + if (data.creatorId != null) attrs.creator_id = data.creatorId; + const url = issueUrl(data); + if (url != null) attrs.url = url; + if (opts.removed) attrs.removed = true; + return attrs; +} + +function buildChunks(data: LinearIssueData, removed: boolean): Array<{ + ordinal: number; + text: string; +}> { + if (removed) return []; + const parts: string[] = []; + const title = issueTitle(data); + parts.push(title); + const desc = data.description?.trim(); + if (desc) parts.push(desc); + const text = parts.join("\n\n"); + if (!text) return []; + return [{ ordinal: 0, text }]; +} + +function entityHints(data: LinearIssueData): unknown[] { + const hints: unknown[] = []; + const assigneeName = data.assignee?.name; + const assigneeId = data.assigneeId ?? data.assignee?.id; + if (assigneeId) { + hints.push({ + kind: "person", + identifier: assigneeId, + ...(assigneeName ? { label: assigneeName } : {}), + }); + } + const creatorName = data.creator?.name; + const creatorId = data.creatorId ?? data.creator?.id; + if (creatorId && creatorId !== assigneeId) { + hints.push({ + kind: "person", + identifier: creatorId, + ...(creatorName ? { label: creatorName } : {}), + }); + } + return hints; +} + +/** + * Core mapper: Linear issue data → AdaptedDocument. + * Actor is always `adapter` for sync writes (never webhook installer identity). + */ +export function mapIssueToAdaptedDocument( + data: LinearIssueData, + opts: { removed?: boolean } = {}, +): AdaptedDocument { + const removed = opts.removed === true; + const title = issueTitle(data); + const ref = externalRef(data); + const attributes = buildAttributes(data, { removed }); + const chunks = buildChunks(data, removed); + const visibility = mapIssueVisibility(data); + + const doc: AdaptedDocument = { + kind: KIND_ISSUE, + title, + externalRef: ref, + visibility, + entityHints: entityHints(data), + chunks, + // Sync writes always attribute to the adapter, never the installer. + actor: { kind: "adapter" }, + contentHash: contentHash({ + title, + kind: KIND_ISSUE, + externalRef: ref, + attributes, + chunkTexts: chunks.map((c) => c.text), + }), + attributes, + }; + return doc; +} + +export function mapIssueCreated(event: LinearWebhookEvent): AdaptedDocument { + return mapIssueToAdaptedDocument(event.data, { removed: false }); +} + +export function mapIssueUpdated(event: LinearWebhookEvent): AdaptedDocument { + return mapIssueToAdaptedDocument(event.data, { removed: false }); +} + +export function mapIssueRemoved(event: LinearWebhookEvent): AdaptedDocument { + return mapIssueToAdaptedDocument(event.data, { removed: true }); +} + +/** + * Dispatch by webhook action. Returns null for non-Issue types or unknown actions. + */ +export function mapLinearWebhook( + event: LinearWebhookEvent, +): MappedWebhookResult | null { + const type = (event.type ?? "Issue").toLowerCase(); + if (type !== "issue") return null; + + const action = event.action as LinearWebhookAction | string; + if (action === "create") { + return { action: "create", document: mapIssueCreated(event) }; + } + if (action === "update") { + return { action: "update", document: mapIssueUpdated(event) }; + } + if (action === "remove") { + return { action: "remove", document: mapIssueRemoved(event) }; + } + return null; +} + +export { ADAPTER }; diff --git a/packages/knowledge-source-linear/src/provider.test.ts b/packages/knowledge-source-linear/src/provider.test.ts new file mode 100644 index 0000000..532cbc0 --- /dev/null +++ b/packages/knowledge-source-linear/src/provider.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createLinearSourceProvider } from "./provider.ts"; + +type FetchFn = NonNullable< + Parameters[0]["fetch"] +>; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function asFetch(fn: (...args: Parameters) => Promise): FetchFn { + return fn as FetchFn; +} + +describe("createLinearSourceProvider", () => { + it("id is linear", () => { + const provider = createLinearSourceProvider({ + accessToken: "test-token", + fetch: asFetch(() => Promise.resolve(jsonResponse({ data: {} }))), + }); + expect(provider.id).toBe("linear"); + }); + + it("searchLive maps GraphQL nodes to LiveSearchItem (mocked, no network)", async () => { + const fetchMock = mock( + asFetch((url, init) => { + expect(String(url)).toBe("https://api.linear.app/graphql"); + expect(init?.method).toBe("POST"); + const headers = init?.headers as Record; + expect(headers.authorization).toBe("Bearer test-token"); + const body = JSON.parse(String(init?.body)); + expect(body.variables.term).toBe("ports"); + expect(body.variables.first).toBe(5); + + return Promise.resolve( + jsonResponse({ + data: { + searchIssues: { + nodes: [ + { + id: "uuid-1", + identifier: "CL-1", + title: "ports foundation", + description: "DocumentStore + SourceProvider", + url: "https://linear.app/x/issue/CL-1", + updatedAt: "2026-03-01T00:00:00.000Z", + team: { id: "team-a" }, + }, + { + id: "uuid-2", + identifier: "CL-2", + title: "unrelated", + description: "something else", + url: "https://linear.app/x/issue/CL-2", + team: { id: "team-a" }, + }, + ], + }, + }, + }), + ); + }), + ); + + const provider = createLinearSourceProvider({ + accessToken: "test-token", + fetch: fetchMock as FetchFn, + }); + + const hits = await provider.searchLive!({ + query: "ports", + tenantId: "t1", + principalId: "p1", + limit: 5, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(hits).toHaveLength(2); + expect(hits[0]).toMatchObject({ + adapter: "linear", + externalRef: "CL-1", + title: "ports foundation", + kind: "issue", + snippet: "DocumentStore + SourceProvider", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(hits[0]?.citation).toEqual({ + adapter: "linear", + external_ref: "CL-1", + open: { + type: "issue", + id: "CL-1", + url: "https://linear.app/x/issue/CL-1", + }, + }); + expect(hits[0]!.score).toBeGreaterThan(hits[1]!.score); + }); + + it("searchLive filters by teamId when set", async () => { + const fetchMock = mock( + asFetch(() => + Promise.resolve( + jsonResponse({ + data: { + searchIssues: { + nodes: [ + { + id: "a", + identifier: "CL-1", + title: "in team", + team: { id: "team-keep" }, + }, + { + id: "b", + identifier: "CL-2", + title: "other team", + team: { id: "team-drop" }, + }, + ], + }, + }, + }), + ), + ), + ); + + const provider = createLinearSourceProvider({ + accessToken: "tok", + teamId: "team-keep", + fetch: fetchMock as FetchFn, + }); + + const hits = await provider.searchLive!({ + query: "team", + tenantId: "t", + principalId: "p", + }); + expect(hits).toHaveLength(1); + expect(hits[0]?.externalRef).toBe("CL-1"); + }); + + it("searchLive uses custom baseUrl", async () => { + const fetchMock = mock( + asFetch((url) => { + expect(String(url)).toBe("https://example.test/graphql"); + return Promise.resolve( + jsonResponse({ data: { searchIssues: { nodes: [] } } }), + ); + }), + ); + + const provider = createLinearSourceProvider({ + accessToken: "tok", + baseUrl: "https://example.test/graphql", + fetch: fetchMock as FetchFn, + }); + + const hits = await provider.searchLive!({ + query: "x", + tenantId: "t", + principalId: "p", + }); + expect(hits).toEqual([]); + }); + + it("searchLive throws on HTTP error (no silent empty)", async () => { + const provider = createLinearSourceProvider({ + accessToken: "tok", + fetch: asFetch(() => + Promise.resolve(new Response("nope", { status: 401 })), + ), + }); + + await expect( + provider.searchLive!({ + query: "x", + tenantId: "t", + principalId: "p", + }), + ).rejects.toThrow(/401/); + }); + + it("searchLive throws on GraphQL errors", async () => { + const provider = createLinearSourceProvider({ + accessToken: "tok", + fetch: asFetch(() => + Promise.resolve( + jsonResponse({ + errors: [{ message: "rate limited" }], + }), + ), + ), + }); + + await expect( + provider.searchLive!({ + query: "x", + tenantId: "t", + principalId: "p", + }), + ).rejects.toThrow(/rate limited/); + }); +}); diff --git a/packages/knowledge-source-linear/src/provider.ts b/packages/knowledge-source-linear/src/provider.ts new file mode 100644 index 0000000..95db57e --- /dev/null +++ b/packages/knowledge-source-linear/src/provider.ts @@ -0,0 +1,133 @@ +import type { + CreateLinearSourceProviderOpts, + LiveSearchItem, + SourceProvider, +} from "./types.ts"; + +const DEFAULT_BASE_URL = "https://api.linear.app/graphql"; +const ADAPTER_ID = "linear"; + +const SEARCH_ISSUES_QUERY = ` +query SearchIssues($term: String!, $first: Int) { + searchIssues(term: $term, first: $first) { + nodes { + id + identifier + title + description + url + updatedAt + team { + id + } + } + } +} +`; + +type LinearSearchNode = { + id: string; + identifier?: string | null; + title?: string | null; + description?: string | null; + url?: string | null; + updatedAt?: string | null; + team?: { id?: string } | null; +}; + +type LinearSearchResponse = { + data?: { + searchIssues?: { + nodes?: LinearSearchNode[]; + }; + }; + errors?: Array<{ message: string }>; +}; + +function snippetFrom(node: LinearSearchNode): string { + const desc = node.description?.trim(); + if (desc && desc.length > 0) { + return desc.length > 240 ? `${desc.slice(0, 237)}...` : desc; + } + return node.title?.trim() || node.identifier || node.id; +} + +function scoreForRank(index: number, total: number): number { + if (total <= 1) return 1; + return 1 - index / total; +} + +/** + * Thin Linear SourceProvider. Host supplies accessToken; OAuth and token + * refresh live outside this package. + */ +export function createLinearSourceProvider( + opts: CreateLinearSourceProviderOpts, +): SourceProvider { + const fetchFn = opts.fetch ?? globalThis.fetch; + const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL; + const teamId = opts.teamId; + + return { + id: ADAPTER_ID, + async searchLive(params): Promise { + const limit = params.limit ?? 8; + const res = await fetchFn(baseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${opts.accessToken}`, + }, + body: JSON.stringify({ + query: SEARCH_ISSUES_QUERY, + variables: { term: params.query, first: limit }, + }), + }); + + if (!res.ok) { + throw new Error( + `Linear GraphQL HTTP ${res.status}: ${await res.text()}`, + ); + } + + const body = (await res.json()) as LinearSearchResponse; + if (body.errors?.length) { + throw new Error( + `Linear GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`, + ); + } + + let nodes = body.data?.searchIssues?.nodes ?? []; + if (teamId) { + nodes = nodes.filter((n) => n.team?.id === teamId); + } + nodes = nodes.slice(0, limit); + + return nodes.map((node, i) => { + const externalRef = node.identifier?.trim() || node.id; + const title = node.title?.trim() || externalRef; + const item: LiveSearchItem = { + adapter: ADAPTER_ID, + externalRef, + title, + snippet: snippetFrom(node), + score: scoreForRank(i, nodes.length), + kind: "issue", + citation: { + adapter: ADAPTER_ID, + external_ref: externalRef, + open: { + type: "issue", + id: externalRef, + ...(node.url ? { url: node.url } : {}), + }, + }, + }; + if (node.updatedAt) { + item.updatedAt = node.updatedAt; + } + return item; + }); + }, + }; +} diff --git a/packages/knowledge-source-linear/src/types.ts b/packages/knowledge-source-linear/src/types.ts new file mode 100644 index 0000000..92abe0a --- /dev/null +++ b/packages/knowledge-source-linear/src/types.ts @@ -0,0 +1,108 @@ +/** + * Local port types matching @corbits/knowledge-engine SourceProvider / + * AdaptedDocument contracts. Defined here so this package has no hard + * dependency on the engine (plugin boundary). + */ + +export type LiveSearchItem = { + adapter: string; + externalRef: string; + title: string; + snippet: string; + score: number; + kind: string; + citation: { + adapter: string; + external_ref: string; + open: { type: string; id: string; url?: string }; + }; + updatedAt?: string; +}; + +export type SourceProvider = { + readonly id: string; + searchLive?(params: { + query: string; + tenantId: string; + principalId: string; + limit?: number; + }): Promise; +}; + +export type VisibilitySpec = { + mode: "private" | "tenant" | "principals"; + principalIds?: string[]; +}; + +/** Capture-ready document shape (AdaptedDocument-ish). */ +export type AdaptedDocument = { + kind: string; + title: string; + externalRef: string; + visibility: VisibilitySpec; + entityHints: unknown[]; + chunks: Array<{ ordinal: number; text: string }>; + actor: { kind: "adapter" | "human"; principalId?: string }; + contentHash: string; + attributes?: Record; +}; + +/** Linear issue fields we read from webhooks / GraphQL (subset). */ +export type LinearIssueData = { + id: string; + identifier?: string | null; + title?: string | null; + description?: string | null; + url?: string | null; + priority?: number | null; + teamId?: string | null; + creatorId?: string | null; + assigneeId?: string | null; + subscriberIds?: string[] | null; + stateId?: string | null; + updatedAt?: string | null; + createdAt?: string | null; + /** Explicit private flag when present on the payload. */ + private?: boolean | null; + team?: { + id?: string; + key?: string | null; + name?: string | null; + private?: boolean | null; + } | null; + creator?: { id?: string; name?: string | null } | null; + assignee?: { id?: string; name?: string | null } | null; + state?: { id?: string; name?: string | null; type?: string | null } | null; +}; + +export type LinearWebhookAction = "create" | "update" | "remove"; + +export type LinearWebhookEvent = { + action: LinearWebhookAction | string; + type?: string; + data: LinearIssueData; + url?: string; + createdAt?: string; + updatedFrom?: Record; +}; + +export type MappedWebhookResult = { + action: LinearWebhookAction; + document: AdaptedDocument; +}; + +/** Minimal fetch shape so tests/mocks need not implement full Fetch API. */ +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export type CreateLinearSourceProviderOpts = { + accessToken: string; + /** Optional team filter for live search. */ + teamId?: string; + /** Injectable fetch (tests mock Linear GraphQL). */ + fetch?: FetchLike; + /** Default https://api.linear.app/graphql */ + baseUrl?: string; +}; diff --git a/packages/knowledge-source-linear/src/visibility.test.ts b/packages/knowledge-source-linear/src/visibility.test.ts new file mode 100644 index 0000000..3b8f935 --- /dev/null +++ b/packages/knowledge-source-linear/src/visibility.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { + collectPrincipalIds, + isPrivateIssue, + mapIssueVisibility, +} from "./visibility.ts"; +import type { LinearIssueData } from "./types.ts"; + +const base: LinearIssueData = { + id: "i1", + identifier: "CL-1", + title: "t", + creatorId: "c1", + assigneeId: "a1", + subscriberIds: ["c1", "a1", "s1"], +}; + +describe("mapIssueVisibility", () => { + it("team-visible → tenant, never source_acl", () => { + const v = mapIssueVisibility({ + ...base, + private: false, + team: { private: false }, + }); + expect(v).toEqual({ mode: "tenant" }); + }); + + it("private multi-principal → principals (never tenant)", () => { + const v = mapIssueVisibility({ ...base, private: true }); + expect(v.mode).toBe("principals"); + expect(v.mode).not.toBe("tenant"); + expect(v.principalIds).toEqual(["c1", "a1", "s1"]); + }); + + it("private single principal → private mode", () => { + const v = mapIssueVisibility({ + id: "i2", + creatorId: "solo", + assigneeId: "solo", + subscriberIds: ["solo"], + private: true, + }); + expect(v).toEqual({ mode: "private", principalIds: ["solo"] }); + }); + + it("team.private without issue.private is still private", () => { + expect( + isPrivateIssue({ + id: "i3", + private: false, + team: { private: true }, + }), + ).toBe(true); + const v = mapIssueVisibility({ + id: "i3", + creatorId: "c", + private: false, + team: { private: true }, + }); + expect(v.mode).not.toBe("tenant"); + }); +}); + +describe("collectPrincipalIds", () => { + it("dedupes creator/assignee/subscribers and nested objects", () => { + const ids = collectPrincipalIds({ + id: "x", + creatorId: "a", + assigneeId: "b", + subscriberIds: ["a", "c"], + creator: { id: "a" }, + assignee: { id: "b" }, + }); + expect(ids.sort()).toEqual(["a", "b", "c"]); + }); +}); diff --git a/packages/knowledge-source-linear/src/visibility.ts b/packages/knowledge-source-linear/src/visibility.ts new file mode 100644 index 0000000..b22142f --- /dev/null +++ b/packages/knowledge-source-linear/src/visibility.ts @@ -0,0 +1,54 @@ +import type { LinearIssueData, VisibilitySpec } from "./types.ts"; + +/** + * Collect principal ids known from the issue payload. + * Only ids present on the webhook/GraphQL object — never invent team rosters. + */ +export function collectPrincipalIds(data: LinearIssueData): string[] { + const ids = new Set(); + if (data.creatorId) ids.add(data.creatorId); + if (data.assigneeId) ids.add(data.assigneeId); + if (data.creator?.id) ids.add(data.creator.id); + if (data.assignee?.id) ids.add(data.assignee.id); + for (const id of data.subscriberIds ?? []) { + if (id) ids.add(id); + } + return [...ids]; +} + +/** + * Private when the issue or its team is marked private. + * Linear private teams must not expand to tenant-wide visibility. + */ +export function isPrivateIssue(data: LinearIssueData): boolean { + if (data.private === true) return true; + if (data.team?.private === true) return true; + return false; +} + +/** + * Map Linear issue visibility → KE VisibilitySpec. + * + * Rules: + * 1. Private issues → `private` (one principal) or `principals` (creator / + * assignee / subscribers only). NEVER `tenant` (overshare guard). + * 2. Team-visible → `tenant` (company-brain default). Never `source_acl` + * (no aspirational ACL level without a read path). + */ +export function mapIssueVisibility(data: LinearIssueData): VisibilitySpec { + const principalIds = collectPrincipalIds(data); + + if (isPrivateIssue(data)) { + if (principalIds.length <= 1) { + const spec: VisibilitySpec = { mode: "private" }; + if (principalIds.length === 1) { + spec.principalIds = principalIds; + } + return spec; + } + return { mode: "principals", principalIds }; + } + + // Team-visible company knowledge. Prefer tenant; never source_acl. + return { mode: "tenant" }; +} diff --git a/packages/knowledge-source-linear/tsconfig.json b/packages/knowledge-source-linear/tsconfig.json new file mode 100644 index 0000000..7f98225 --- /dev/null +++ b/packages/knowledge-source-linear/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun"] + }, + "include": ["src", "fixtures"] +}