diff --git a/packages/core/package.json b/packages/core/package.json index 7e4d19ca3..93d23546f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,7 @@ ], "scripts": { "clean": "rm -rf dist tsconfig.tsbuildinfo", - "build": "pnpm exec vite build && pnpm exec tsc --build --force", + "build": "pnpm exec vite build && rm -f tsconfig.tsbuildinfo && pnpm exec tsc --build && test -f dist/index.d.ts && test -f dist/internal/cloudflare-coordinator.d.ts", "typecheck": "pnpm exec tsc --noEmit", "generate:test-schema": "pnpm exec tsx scripts/generate-test-schema.ts" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2ee204e26..a34fbd716 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -581,6 +581,7 @@ export { startMaintenanceJob, updateMaintenanceJob, } from "./maintenance-jobs.js"; +export * from "./memory-filter-schema.js"; export * from "./memory-kinds.js"; export type { DerivedMemoryRole, @@ -683,6 +684,19 @@ export { mapPiEventPayload, PI_FLUSH_ONLY_EVENTS, } from "./pi-hooks.js"; +export type { + PiObserverResolveErr, + PiObserverResolveInput, + PiObserverResolveOk, + PiObserverResolveReason, + PiObserverResolveResult, +} from "./pi-observer-config.js"; +export { + describePiObserverStatus, + hasExplicitObserverEnvOverride, + resolvePiAgentDir, + resolvePiObserverConfig, +} from "./pi-observer-config.js"; export type { BlockedPolicyTeamDeviceEligibilityResult, DerivePolicyTeamDeviceEligibilityInput, diff --git a/packages/core/src/memory-filter-schema.ts b/packages/core/src/memory-filter-schema.ts new file mode 100644 index 000000000..cf2122f3a --- /dev/null +++ b/packages/core/src/memory-filter-schema.ts @@ -0,0 +1,71 @@ +/** + * Tool-exposed memory filter contract. + * + * Single source of truth for the filter keys and value types accepted by both + * memory tool surfaces: the MCP server tool schemas (pinned to this catalog by + * an exact parity test in @codemem/mcp-server) and the viewer-server HTTP + * routes (validated directly against this catalog). Keeping one catalog means + * a filter added here cannot be silently omitted from either surface, so + * exclusion filters can never fail open and return broader results than the + * client requested. + * + * Insertion order mirrors the MCP tool schema key order. + */ + +export type MemoryFilterFieldType = + | "string" + | "string-array" + | "int" + | "number" + | "boolean-or-string"; + +export const MEMORY_FILTER_FIELD_TYPES = { + kind: "string", + project: "string", + scope_id: "string", + include_scope_ids: "string-array", + exclude_scope_ids: "string-array", + visibility: "string-array", + include_visibility: "string-array", + exclude_visibility: "string-array", + include_workspace_ids: "string-array", + exclude_workspace_ids: "string-array", + include_workspace_kinds: "string-array", + exclude_workspace_kinds: "string-array", + include_actor_ids: "string-array", + exclude_actor_ids: "string-array", + include_trust_states: "string-array", + exclude_trust_states: "string-array", + ownership_scope: "string", + personal_first: "boolean-or-string", + trust_bias: "string", + widen_shared_when_weak: "boolean-or-string", + widen_shared_min_personal_results: "int", + widen_shared_min_personal_score: "number", +} as const satisfies Record; + +export type MemoryFilterName = keyof typeof MEMORY_FILTER_FIELD_TYPES; + +/** Sorted filter names exposed by memory_schema on both surfaces. */ +export const MEMORY_FILTER_NAMES = Object.keys( + MEMORY_FILTER_FIELD_TYPES, +).toSorted() as MemoryFilterName[]; + +/** Check a raw request value against a filter field's declared type. */ +export function memoryFilterValueMatchesType( + value: unknown, + fieldType: MemoryFilterFieldType, +): boolean { + switch (fieldType) { + case "string": + return typeof value === "string"; + case "string-array": + return Array.isArray(value) && value.every((item) => typeof item === "string"); + case "int": + return typeof value === "number" && Number.isInteger(value); + case "number": + return typeof value === "number" && Number.isFinite(value); + case "boolean-or-string": + return typeof value === "boolean" || typeof value === "string"; + } +} diff --git a/packages/core/src/observer-auth.test.ts b/packages/core/src/observer-auth.test.ts index 7297691d1..9d0f3777d 100644 --- a/packages/core/src/observer-auth.test.ts +++ b/packages/core/src/observer-auth.test.ts @@ -34,6 +34,22 @@ describe("ObserverAuthAdapter", () => { expect(result.source).toBe("oauth"); }); + it("falls back to pi token after env/oauth", () => { + const adapter = new ObserverAuthAdapter(); + const result = adapter.resolve({ piToken: "tok-pi" }); + expect(result.token).toBe("tok-pi"); + expect(result.source).toBe("pi"); + }); + + it("explicit and env beat pi token", () => { + const adapter = new ObserverAuthAdapter(); + expect(adapter.resolve({ explicitToken: "tok-explicit", piToken: "tok-pi" }).source).toBe( + "explicit", + ); + expect(adapter.resolve({ envTokens: ["tok-env"], piToken: "tok-pi" }).source).toBe("env"); + expect(adapter.resolve({ oauthToken: "tok-oauth", piToken: "tok-pi" }).source).toBe("oauth"); + }); + it("returns no token with source=none", () => { const adapter = new ObserverAuthAdapter({ source: "none" }); const result = adapter.resolve({ explicitToken: "ignored" }); diff --git a/packages/core/src/observer-auth.ts b/packages/core/src/observer-auth.ts index bdb5128f6..5f3d6eb6a 100644 --- a/packages/core/src/observer-auth.ts +++ b/packages/core/src/observer-auth.ts @@ -275,12 +275,17 @@ export interface ObserverAuthResolveOptions { explicitToken?: string | null; envTokens?: string[]; oauthToken?: string | null; + /** + * In-memory credential from pi auth.json (D8). Used only when no explicit, + * env, or oauth token is available. Never persist or log this value. + */ + piToken?: string | null; forceRefresh?: boolean; } /** * Resolves auth credentials through a configurable cascade: - * explicit → env → oauth → file → command. + * explicit → env → oauth → pi → file → command. * * Results from file/command sources are cached for `cacheTtlS` seconds. */ @@ -314,6 +319,7 @@ export class ObserverAuthAdapter { const explicitToken = opts?.explicitToken ?? null; const envTokens = opts?.envTokens ?? []; const oauthToken = opts?.oauthToken ?? null; + const piToken = opts?.piToken ?? null; const forceRefresh = opts?.forceRefresh ?? false; if (source === "none") return noAuth(); @@ -342,6 +348,11 @@ export class ObserverAuthAdapter { token = oauthToken; tokenSource = "oauth"; } + // D8: pi auth.json credential at point of use (after explicit/env/oauth). + if (!token && piToken) { + token = piToken; + tokenSource = "pi"; + } } else if (source === "env") { token = envTokens.find((t) => !!t) ?? null; if (token) tokenSource = "env"; diff --git a/packages/core/src/observer-client.test.ts b/packages/core/src/observer-client.test.ts index 4da93e6e3..d8ce9240a 100644 --- a/packages/core/src/observer-client.test.ts +++ b/packages/core/src/observer-client.test.ts @@ -54,15 +54,24 @@ describe("loadObserverConfig", () => { "CODEMEM_OBSERVER_MAX_CHARS", "CODEMEM_OBSERVER_MAX_TOKENS", "CODEMEM_OBSERVER_HEADERS", + "HOME", + "PI_CODING_AGENT_DIR", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION", ]; const saved: Record = {}; + let isolatedHome: string | undefined; beforeEach(() => { for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; } + // Isolate from the developer's real pi/opencode installs so D8 pi + // derivation does not fill defaults from ~/.pi/agent. + isolatedHome = mkdtempSync(join(tmpdir(), "codemem-obs-cfg-home-")); + process.env.HOME = isolatedHome; }); afterEach(() => { @@ -73,6 +82,8 @@ describe("loadObserverConfig", () => { process.env[k] = saved[k]; } } + if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true }); + isolatedHome = undefined; }); it("returns defaults when no config file exists", () => { @@ -272,6 +283,26 @@ describe("loadObserverConfig", () => { // --------------------------------------------------------------------------- describe("ObserverClient", () => { + const savedHome: { HOME?: string; PI_CODING_AGENT_DIR?: string } = {}; + let isolatedHome: string | undefined; + + beforeEach(() => { + savedHome.HOME = process.env.HOME; + savedHome.PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR; + isolatedHome = mkdtempSync(join(tmpdir(), "codemem-obs-client-home-")); + process.env.HOME = isolatedHome; + delete process.env.PI_CODING_AGENT_DIR; + }); + + afterEach(() => { + if (savedHome.HOME === undefined) delete process.env.HOME; + else process.env.HOME = savedHome.HOME; + if (savedHome.PI_CODING_AGENT_DIR === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = savedHome.PI_CODING_AGENT_DIR; + if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true }); + isolatedHome = undefined; + }); + describe("constructor", () => { it("defaults tier routing on for capability-safe api_http providers when not explicitly set", () => { const client = new ObserverClient({ @@ -2441,3 +2472,319 @@ describe("shouldAutoSelectCodexSidecar", () => { expect(shouldAutoSelectCodexSidecar({ ...base, codexAuthExists: false })).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// D8: pi-derived observer credentials at point of use (C1) +// --------------------------------------------------------------------------- + +const PI_FIXTURE_KEY = "sk-fixture-pi-client-auth-key-do-not-leak"; + +describe("ObserverClient — pi-derived auth (D8)", () => { + const envKeys = [ + "CODEMEM_CONFIG", + "CODEMEM_OBSERVER_PROVIDER", + "CODEMEM_OBSERVER_MODEL", + "CODEMEM_OBSERVER_RUNTIME", + "CODEMEM_OBSERVER_API_KEY", + "CODEMEM_OBSERVER_BASE_URL", + "CODEMEM_OBSERVER_OPENAI_USE_RESPONSES", + "CODEMEM_CODEX_COMMAND", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENCODE_API_KEY", + "CODEX_API_KEY", + "HOME", + "PI_CODING_AGENT_DIR", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION", + ]; + const saved: Record = {}; + let tmpHome: string | undefined; + let piDir: string | undefined; + + beforeEach(() => { + for (const k of envKeys) { + saved[k] = process.env[k]; + delete process.env[k]; + } + // Isolate from the real home's pi / opencode / codex installs. + tmpHome = mkdtempSync(join(tmpdir(), "codemem-pi-auth-home-")); + process.env.HOME = tmpHome; + process.env.CODEMEM_CONFIG = join(tmpHome, "no-such-codemem-config.json"); + piDir = join(tmpHome, ".pi", "agent"); + mkdirSync(piDir, { recursive: true }); + }); + + afterEach(() => { + for (const k of envKeys) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + if (tmpHome) rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = undefined; + piDir = undefined; + }); + + function writePiApiKeyFixture(opts?: { provider?: string; model?: string; baseUrl?: string }) { + const provider = opts?.provider ?? "acme"; + const model = opts?.model ?? "gpt-mini"; + const baseUrl = opts?.baseUrl ?? "https://api.acme.test/v1"; + if (!piDir) throw new Error("piDir unset"); + writeFileSync( + join(piDir, "settings.json"), + JSON.stringify({ defaultProvider: provider, defaultModel: `${provider}/${model}` }), + ); + writeFileSync( + join(piDir, "models.json"), + JSON.stringify({ + providers: { + [provider]: { + baseUrl, + api: "openai-completions", + models: [{ id: model }, { id: "gpt-premium-ultra" }], + }, + }, + }), + ); + writeFileSync( + join(piDir, "auth.json"), + JSON.stringify({ [provider]: { type: "api_key", key: PI_FIXTURE_KEY } }), + ); + } + + it("uses pi auth.json api key when no explicit observer key/env is set", () => { + writePiApiKeyFixture(); + // Simulate setup having written provider/model/baseUrl but NEVER the key. + const client = new ObserverClient({ + observerProvider: "acme", + observerModel: "gpt-mini", + observerBaseUrl: "https://api.acme.test/v1", + observerRuntime: "api_http", + observerApiKey: null, + observerMaxChars: 12_000, + observerMaxTokens: 4_000, + observerHeaders: {}, + observerAuthSource: "auto", + observerAuthFile: null, + observerAuthCommand: [], + observerAuthTimeoutMs: 1500, + observerAuthCacheTtlS: 300, + }); + + const status = client.getStatus(); + expect(status.auth.hasToken).toBe(true); + expect(status.auth.source).toBe("pi"); + // Status must never echo the secret. + expect(JSON.stringify(status)).not.toContain(PI_FIXTURE_KEY); + // toConfig must not promote the pi key into observerApiKey (persist risk). + expect(client.toConfig().observerApiKey).toBeNull(); + }); + + it("loadObserverConfig fills unset provider/model/baseUrl from pi without copying the key", () => { + writePiApiKeyFixture({ + provider: "fw", + model: "flash-lite", + baseUrl: "https://api.fw.test/v1", + }); + const cfg = loadObserverConfig(); + expect(cfg.observerProvider).toBe("fw"); + expect(cfg.observerModel).toBe("flash-lite"); + expect(cfg.observerBaseUrl).toBe("https://api.fw.test/v1"); + // Key stays off the config object — resolved only inside ObserverClient. + expect(cfg.observerApiKey).toBeNull(); + + const client = new ObserverClient(cfg); + expect(client.getStatus().auth.hasToken).toBe(true); + expect(client.getStatus().auth.source).toBe("pi"); + expect(client.toConfig().observerApiKey).toBeNull(); + }); + + it("explicit CODEMEM_OBSERVER_API_KEY wins over pi", () => { + writePiApiKeyFixture(); + process.env.CODEMEM_OBSERVER_API_KEY = "tok-explicit-env"; + const client = new ObserverClient({ + observerProvider: "acme", + observerModel: "gpt-mini", + observerBaseUrl: "https://api.acme.test/v1", + observerRuntime: "api_http", + observerApiKey: null, + observerMaxChars: 12_000, + observerMaxTokens: 4_000, + observerHeaders: {}, + observerAuthSource: "auto", + observerAuthFile: null, + observerAuthCommand: [], + observerAuthTimeoutMs: 1500, + observerAuthCacheTtlS: 300, + }); + expect(client.getStatus().auth.source).toBe("env"); + expect(client.auth.token).toBe("tok-explicit-env"); + }); + + it("explicit observerApiKey on config wins over pi", () => { + writePiApiKeyFixture(); + const client = new ObserverClient({ + observerProvider: "acme", + observerModel: "gpt-mini", + observerBaseUrl: "https://api.acme.test/v1", + observerRuntime: "api_http", + observerApiKey: "tok-config-explicit", + observerMaxChars: 12_000, + observerMaxTokens: 4_000, + observerHeaders: {}, + observerAuthSource: "auto", + observerAuthFile: null, + observerAuthCommand: [], + observerAuthTimeoutMs: 1500, + observerAuthCacheTtlS: 300, + }); + expect(client.getStatus().auth.source).toBe("explicit"); + expect(client.auth.token).toBe("tok-config-explicit"); + }); + + function apiHttpClient(provider: string) { + return new ObserverClient({ + observerProvider: provider, + observerModel: provider === "anthropic" ? "claude-haiku-4-5" : "gpt-mini", + observerRuntime: "api_http", + observerApiKey: null, + observerBaseUrl: null, + observerMaxChars: 12_000, + observerMaxTokens: 4_000, + observerHeaders: {}, + observerAuthSource: "auto", + observerAuthFile: null, + observerAuthCommand: [], + observerAuthTimeoutMs: 1500, + observerAuthCacheTtlS: 300, + }); + } + + it("does not send an unrelated pi key to an explicit Anthropic observer", () => { + writePiApiKeyFixture({ provider: "acme" }); + const client = apiHttpClient("anthropic"); + const status = client.getStatus(); + expect(status.auth.source).not.toBe("pi"); + expect(status.auth.hasToken).toBe(false); + expect(client.auth.token).not.toBe(PI_FIXTURE_KEY); + expect(JSON.stringify(status)).not.toContain(PI_FIXTURE_KEY); + expect(client.toConfig().observerApiKey).toBeNull(); + }); + + it("does not send an unrelated pi key to an explicit OpenAI observer", () => { + writePiApiKeyFixture({ provider: "acme" }); + const client = apiHttpClient("openai"); + const status = client.getStatus(); + expect(status.auth.source).not.toBe("pi"); + expect(status.auth.hasToken).toBe(false); + expect(client.auth.token).not.toBe(PI_FIXTURE_KEY); + expect(JSON.stringify(status)).not.toContain(PI_FIXTURE_KEY); + expect(client.toConfig().observerApiKey).toBeNull(); + }); + + it("matching anthropic pi provider may supply the key", () => { + writePiApiKeyFixture({ + provider: "anthropic", + model: "claude-haiku-4-5", + baseUrl: "https://api.anthropic.com", + }); + const client = apiHttpClient("anthropic"); + expect(client.getStatus().auth.source).toBe("pi"); + expect(client.getStatus().auth.hasToken).toBe(true); + expect(client.toConfig().observerApiKey).toBeNull(); + expect(JSON.stringify(client.getStatus())).not.toContain(PI_FIXTURE_KEY); + }); + + it("matching openai pi provider may supply the key (case-insensitive)", () => { + writePiApiKeyFixture({ + provider: "OpenAI", + model: "gpt-mini", + baseUrl: "https://api.openai.com/v1", + }); + const client = apiHttpClient("openai"); + expect(client.getStatus().auth.source).toBe("pi"); + expect(client.getStatus().auth.hasToken).toBe(true); + expect(client.toConfig().observerApiKey).toBeNull(); + }); + + it("oauth-only pi install does not invent a token (status stays clean)", () => { + if (!piDir) throw new Error("piDir unset"); + writeFileSync(join(piDir, "settings.json"), JSON.stringify({ defaultModel: "openai/gpt-x" })); + writeFileSync( + join(piDir, "models-store.json"), + JSON.stringify({ + openai: { + models: [ + { + id: "gpt-x", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + }), + ); + writeFileSync( + join(piDir, "auth.json"), + JSON.stringify({ openai: { type: "oauth", access: "oauth-access-not-usable" } }), + ); + + const client = new ObserverClient({ + observerProvider: "openai", + observerModel: "gpt-x", + observerRuntime: "api_http", + observerApiKey: null, + observerBaseUrl: null, + observerMaxChars: 12_000, + observerMaxTokens: 4_000, + observerHeaders: {}, + observerAuthSource: "auto", + observerAuthFile: null, + observerAuthCommand: [], + observerAuthTimeoutMs: 1500, + observerAuthCacheTtlS: 300, + }); + // No API-key path from pi; no env keys → no token (not a silent 401 with a bogus key). + expect(client.getStatus().auth.hasToken).toBe(false); + expect(client.getStatus().auth.source).toBe("none"); + expect(JSON.stringify(client.getStatus())).not.toContain("oauth-access-not-usable"); + }); + + it("does not suppress claude_sidecar auto-select when only a pi api key is present", () => { + // Dual-install: user runs Claude Code AND has pi auth.json with a key. + // The pi key feeds api_http credential resolution only — it must NOT gate + // sidecar auto-select (sidecar auth goes through the claude/codex CLI). + writePiApiKeyFixture(); + process.env.CLAUDE_CODE_ENTRYPOINT = "cli"; + // No explicit observer key / provider env keys (cleared in beforeEach). + const cfg = loadObserverConfig(); + expect(cfg.observerRuntime).toBe("claude_sidecar"); + // Key stays off the config object (api_http-only, resolved in-memory). + expect(cfg.observerApiKey).toBeNull(); + }); + + it("still suppresses claude_sidecar auto-select when an explicit env API key is set", () => { + writePiApiKeyFixture(); + process.env.CLAUDE_CODE_ENTRYPOINT = "cli"; + process.env.ANTHROPIC_API_KEY = "sk-explicit-anthropic"; + const cfg = loadObserverConfig(); + expect(cfg.observerRuntime).not.toBe("claude_sidecar"); + }); + + it("does not suppress codex_sidecar auto-select when only a pi api key is present", () => { + // Dual-install twin of the claude I4 case: pi auth.json key must not + // steal runtime toward api_http when codex sidecar preconditions hold. + writePiApiKeyFixture(); + if (!tmpHome) throw new Error("tmpHome unset"); + const codexDir = join(tmpHome, ".codex"); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(codexDir, "auth.json"), JSON.stringify({ tokens: { access: "x" } })); + const fakeCodex = join(tmpHome, "fake-codex"); + writeFileSync(fakeCodex, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + process.env.CODEMEM_CODEX_COMMAND = fakeCodex; + // No CLAUDE_CODE_* markers, no explicit observer/provider env keys. + const cfg = loadObserverConfig(); + expect(cfg.observerRuntime).toBe("codex_sidecar"); + expect(cfg.observerApiKey).toBeNull(); + }); +}); diff --git a/packages/core/src/observer-client.ts b/packages/core/src/observer-client.ts index 6470740c9..e2e6f5a7a 100644 --- a/packages/core/src/observer-client.ts +++ b/packages/core/src/observer-client.ts @@ -44,6 +44,7 @@ import { stripTrailingCommas, } from "./observer-config.js"; import type { ObserverEnvelopeFailureReason } from "./observer-output-schema.js"; +import { resolvePiObserverConfig } from "./pi-observer-config.js"; // --------------------------------------------------------------------------- // Constants @@ -429,6 +430,62 @@ function codexCliAvailable(command: string): boolean { } } +/** + * Project unset observer fields from pi agent config (D8). + * + * When provider is entirely unset, fill provider/model/baseUrl/wire from pi. + * When provider is already set (setup or user), only fill a missing model if + * it matches the pi provider — never rewrite an explicit openai/anthropic + * baseUrl with a pi custom endpoint. + * + * Does NOT copy the api key onto the config object — credentials are resolved + * in-memory at point of use via {@link resolvePiApiKeyForObserver}. + * The pi API key is intentionally NOT surfaced here: it feeds api_http + * credential resolution only and must never gate claude/codex sidecar + * auto-selection (sidecar auth goes through the local CLI). + */ +function applyPiDerivedObserverFields(cfg: ObserverConfig): void { + let pi: ReturnType; + try { + pi = resolvePiObserverConfig(); + } catch { + return; + } + if (!pi.ok) return; + + if (!cfg.observerProvider) { + cfg.observerProvider = pi.provider; + if (!cfg.observerModel) cfg.observerModel = pi.model; + if (!cfg.observerBaseUrl && pi.baseUrl) cfg.observerBaseUrl = pi.baseUrl; + if (cfg.observerOpenAIUseResponses === undefined) { + cfg.observerOpenAIUseResponses = pi.openAIUseResponses; + } + } else if ( + (cfg.observerProvider ?? "").toLowerCase() === pi.provider.toLowerCase() && + !cfg.observerModel + ) { + cfg.observerModel = pi.model; + } +} + +/** + * Resolve a pi API-key credential in memory for the observer auth cascade. + * Returns a key only when the pi provider matches the effective observer + * provider (case-insensitive). Null when unconfigured, oauth-only, no key, + * or provider mismatch. NEVER log or persist the returned value. + */ +function resolvePiApiKeyForObserver(observerProvider: string): string | null { + try { + const pi = resolvePiObserverConfig(); + if (!pi.ok || !pi.apiKey) return null; + if (!observerProvider) return null; + if (pi.provider.toLowerCase() !== observerProvider.toLowerCase()) return null; + return pi.apiKey; + } catch { + return null; + } +} + /** * Load observer config from `~/.config/codemem/config.json{c}`. * @@ -669,6 +726,13 @@ export function loadObserverConfig(): ObserverConfig { const envCodexCmd = coerceObserverCommand(process.env.CODEMEM_CODEX_COMMAND); if (envCodexCmd) cfg.codexCommand = envCodexCmd; + // D8: fill unset observer provider/model/baseUrl/wire from pi. Credential is + // NOT copied onto cfg — resolved in-memory at ObserverClient auth time. + // Pi API keys must NOT participate in sidecar auto-select gates below: a + // dual-install user (Claude Code / Codex CLI + pi auth.json) should still + // get claude_sidecar / codex_sidecar. The pi key only feeds api_http auth. + applyPiDerivedObserverFields(cfg); + // Auto-detect Claude environment for runtime default. // If running inside Claude Code (CLAUDE_CODE_ENTRYPOINT or CLAUDE_CODE_SESSION set), // no explicit runtime configured, and no API key available from any provider, @@ -1367,6 +1431,8 @@ export class ObserverClient { private _customBaseUrl: string | null; private _customBaseUrlAllowsNoAuth: boolean; private readonly _apiKey: string | null; + /** In-memory pi auth.json key (D8). Never persisted or logged. */ + private _piApiKey: string | null = null; // Claude sidecar state private readonly _claudeCommand: string[]; @@ -1591,6 +1657,31 @@ export class ObserverClient { }); this.auth = { token: null, authType: "none", source: "none" }; + // D8: resolve pi credential in memory at point of use. Never assign onto + // cfg.observerApiKey (that would look "explicit" and could be persisted + // by callers of toConfig()). Only used as a lower-priority cascade source. + if (!this._apiKey) { + this._piApiKey = resolvePiApiKeyForObserver(this.provider); + } + // Custom pi providers need a baseUrl. Only fill for non-builtin providers + // that match pi — never redirect official openai/anthropic endpoints. + if ( + !this._customBaseUrl && + this.provider !== "openai" && + this.provider !== "anthropic" && + this.provider !== "opencode" + ) { + try { + const pi = resolvePiObserverConfig(); + if (pi.ok && pi.baseUrl && pi.provider.toLowerCase() === this.provider.toLowerCase()) { + this._customBaseUrl = pi.baseUrl; + this._customBaseUrlAllowsNoAuth = false; + } + } catch { + /* ignore */ + } + } + // Initialize provider client state — skip for sidecar runtimes (no API // key needed; auth is delegated to the local Claude/Codex CLI). const isSidecarRuntime = this.runtime === "claude_sidecar" || this.runtime === "codex_sidecar"; @@ -1977,6 +2068,7 @@ export class ObserverClient { this.auth = this.authAdapter.resolve({ explicitToken: apiKey, envTokens: [process.env.CODEMEM_OBSERVER_API_KEY ?? ""], + piToken: this._piApiKey, forceRefresh, }); } else if (this.provider === "anthropic") { @@ -1984,6 +2076,7 @@ export class ObserverClient { explicitToken: this._apiKey, envTokens: [process.env.ANTHROPIC_API_KEY ?? ""], oauthToken: oauthAccess, + piToken: this._piApiKey, forceRefresh, }); if (this.auth.source === "oauth" && oauthAccess) { @@ -1999,6 +2092,7 @@ export class ObserverClient { process.env.CODEX_API_KEY ?? "", ], oauthToken: oauthAccess, + piToken: this._piApiKey, forceRefresh, }); if (this.auth.source === "oauth" && oauthAccess) { diff --git a/packages/core/src/pi-observer-config.test.ts b/packages/core/src/pi-observer-config.test.ts new file mode 100644 index 000000000..87cee24e2 --- /dev/null +++ b/packages/core/src/pi-observer-config.test.ts @@ -0,0 +1,653 @@ +/** + * Tests for pi-observer-config.ts — derive observer settings from pi agent config. + * + * Fixtures cover: api-key happy path, oauth-only, mixed auth, relocated home + * (PI_CODING_AGENT_DIR / piDir), wire-api mapping, cheap-first selection, + * explicit observer_* override detection, and secret non-leakage in status. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + describePiObserverStatus, + hasExplicitObserverEnvOverride, + type PiObserverResolveResult, + resolvePiAgentDir, + resolvePiObserverConfig, +} from "./pi-observer-config.js"; + +const FIXTURE_KEY = "sk-fixture-pi-observer-test-key-do-not-leak"; +const OTHER_KEY = "sk-fixture-other-provider-key-secret"; + +function writeJson(path: string, data: unknown): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +function makePiDir(label: string): string { + const root = mkdtempSync(join(tmpdir(), `codemem-pi-obs-${label}-`)); + return root; +} + +function collectStrings(value: unknown, out: string[] = []): string[] { + if (typeof value === "string") { + out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out); + return out; + } + if (value != null && typeof value === "object") { + for (const item of Object.values(value as Record)) { + collectStrings(item, out); + } + } + return out; +} + +function assertNoSecretLeak(result: PiObserverResolveResult, ...secrets: string[]): void { + const status = describePiObserverStatus(result); + for (const secret of secrets) { + expect(status, `status must not contain ${secret}`).not.toContain(secret); + } + // Serialize the public-facing fields only (ok path still holds apiKey in memory — + // status helper and error details must stay clean). + if (!result.ok) { + const blob = JSON.stringify(result); + for (const secret of secrets) { + expect(blob, `error result must not contain ${secret}`).not.toContain(secret); + } + } else { + // ok path: apiKey is intentionally present on the object; status must redact. + expect(status).not.toMatch(/sk-/); + const { apiKey: _apiKey, ...publicFields } = result; + const blob = JSON.stringify(publicFields); + for (const secret of secrets) { + expect(blob).not.toContain(secret); + } + } +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +describe("resolvePiAgentDir", () => { + it("defaults to ~/.pi/agent under HOME", () => { + const dir = resolvePiAgentDir({ env: { HOME: "/tmp/fake-home" } }); + expect(dir).toBe(join("/tmp/fake-home", ".pi", "agent")); + }); + + it("honors PI_CODING_AGENT_DIR", () => { + const dir = resolvePiAgentDir({ + env: { HOME: "/tmp/fake-home", PI_CODING_AGENT_DIR: "/tmp/relocated-pi" }, + }); + expect(dir).toBe("/tmp/relocated-pi"); + }); + + it("prefers explicit piDir over env", () => { + const dir = resolvePiAgentDir({ + piDir: "/explicit/pi", + env: { PI_CODING_AGENT_DIR: "/tmp/relocated-pi" }, + }); + expect(dir).toBe("/explicit/pi"); + }); +}); + +// --------------------------------------------------------------------------- +// API-key happy path + wire API +// --------------------------------------------------------------------------- + +describe("resolvePiObserverConfig — api-key happy path", () => { + it("resolves OpenAI-completions provider endpoint, credential, and model id", () => { + const piDir = makePiDir("happy"); + try { + writeJson(join(piDir, "settings.json"), { + defaultProvider: "acme", + defaultModel: "acme/gpt-premium-ultra", + enabledModels: ["acme/gpt-premium-ultra", "acme/gpt-4o-mini"], + }); + writeJson(join(piDir, "models.json"), { + providers: { + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [{ id: "gpt-premium-ultra" }, { id: "gpt-4o-mini" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe("acme"); + expect(result.model).toBe("gpt-4o-mini"); // cheap-first, not default + expect(result.baseUrl).toBe("https://api.acme.test/v1"); + expect(result.apiKey).toBe(FIXTURE_KEY); + expect(result.wireApi).toBe("openai-completions"); + expect(result.openAIUseResponses).toBe(false); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("maps openai-responses → openAIUseResponses true", () => { + const piDir = makePiDir("responses"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + oai: { + baseUrl: "https://api.openai.test/v1", + api: "openai-responses", + models: [{ id: "gpt-4.1-mini" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + oai: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.wireApi).toBe("openai-responses"); + expect(result.openAIUseResponses).toBe(true); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("maps anthropic-messages wire API", () => { + const piDir = makePiDir("anthropic"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + anth: { + baseUrl: "https://api.anthropic.test", + api: "anthropic-messages", + models: [{ id: "claude-haiku-4-5" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + anth: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.wireApi).toBe("anthropic-messages"); + expect(result.openAIUseResponses).toBe(false); + expect(result.model).toBe("claude-haiku-4-5"); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("accepts apiKey embedded in models.json without auth.json entry", () => { + const piDir = makePiDir("embedded-key"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + local: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + apiKey: FIXTURE_KEY, + models: [{ id: "llama3.1:8b" }], + }, + }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe("local"); + expect(result.apiKey).toBe(FIXTURE_KEY); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("reads models-store.json catalog when models.json is absent", () => { + const piDir = makePiDir("store-only"); + try { + writeJson(join(piDir, "settings.json"), { + enabledModels: ["fw/accounts/fw/models/deepseek-flash"], + }); + writeJson(join(piDir, "models-store.json"), { + fw: { + models: [ + { + id: "accounts/fw/models/deepseek-flash", + api: "openai-completions", + baseUrl: "https://api.fw.test/v1", + cost: { input: 0.1, output: 0.2 }, + }, + { + id: "accounts/fw/models/deepseek-pro", + api: "openai-completions", + baseUrl: "https://api.fw.test/v1", + cost: { input: 2, output: 4 }, + }, + ], + }, + }); + writeJson(join(piDir, "auth.json"), { + fw: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe("fw"); + expect(result.model).toBe("accounts/fw/models/deepseek-flash"); + expect(result.baseUrl).toBe("https://api.fw.test/v1"); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Cheap-first selection (never interactive default) +// --------------------------------------------------------------------------- + +describe("resolvePiObserverConfig — cheap-first model selection", () => { + it("does not auto-use premium defaultModel when a cheaper model exists", () => { + const piDir = makePiDir("cheap"); + try { + writeJson(join(piDir, "settings.json"), { + defaultProvider: "acme", + defaultModel: "acme/claude-opus-4", + }); + writeJson(join(piDir, "models.json"), { + providers: { + acme: { + baseUrl: "https://api.acme.test", + api: "anthropic-messages", + models: [ + { id: "claude-opus-4" }, + { id: "claude-haiku-4-5" }, + { id: "claude-sonnet-4" }, + ], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.model).toBe("claude-haiku-4-5"); + expect(result.model).not.toBe("claude-opus-4"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("falls back to defaultModel only when it is the sole eligible candidate", () => { + const piDir = makePiDir("only-default"); + try { + writeJson(join(piDir, "settings.json"), { + defaultModel: "acme/gpt-premium-ultra", + }); + writeJson(join(piDir, "models.json"), { + providers: { + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [{ id: "gpt-premium-ultra" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.model).toBe("gpt-premium-ultra"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("prefers lower declared cost from models-store over name alone", () => { + const piDir = makePiDir("cost"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models-store.json"), { + acme: { + models: [ + { + id: "model-a", + api: "openai-completions", + baseUrl: "https://api.acme.test/v1", + cost: { input: 5, output: 10 }, + }, + { + id: "model-b", + api: "openai-completions", + baseUrl: "https://api.acme.test/v1", + cost: { input: 0.05, output: 0.1 }, + }, + ], + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.model).toBe("model-b"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("breaks same-tier/cost ties with localeCompare, never defaultModel", () => { + // Two candidates, identical declared cost and name tier, neither cheaper. + // Winner must be the localeCompare-first model id — and must NOT be the + // interactive defaultModel even when it sorts later. + const piDir = makePiDir("tie"); + try { + writeJson(join(piDir, "settings.json"), { + defaultModel: "acme/zeta-model", + }); + writeJson(join(piDir, "models.json"), { + providers: { + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [ + // Insert default first so array order alone would pick it. + { id: "zeta-model", cost: { input: 1, output: 1 } }, + { id: "alpha-model", cost: { input: 1, output: 1 } }, + { id: "beta-model", cost: { input: 1, output: 1 } }, + ], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + // Non-default candidates sort before default; among non-defaults, + // localeCompare("alpha-model", "beta-model") < 0 → alpha wins. + expect(result.model).toBe("alpha-model"); + expect(result.model).not.toBe("zeta-model"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// OAuth-only + mixed +// --------------------------------------------------------------------------- + +describe("resolvePiObserverConfig — oauth and mixed auth", () => { + it("surfaces oauth-only when every provider is OAuth", () => { + const piDir = makePiDir("oauth"); + try { + writeJson(join(piDir, "settings.json"), { + defaultModel: "openai-codex/gpt-5.4", + }); + writeJson(join(piDir, "models-store.json"), { + "openai-codex": { + models: [ + { + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ], + }, + }); + writeJson(join(piDir, "auth.json"), { + "openai-codex": { + type: "oauth", + access: "oauth-access-token-fixture", + refresh: "oauth-refresh-token-fixture", + expires: Date.now() + 60_000, + }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("oauth-only"); + expect(describePiObserverStatus(result)).toBe( + "unconfigured (oauth-only); set observer_provider/observer_model explicitly", + ); + assertNoSecretLeak(result, "oauth-access-token-fixture", "oauth-refresh-token-fixture"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("skips OAuth providers and uses an api-key peer in mixed installs", () => { + const piDir = makePiDir("mixed"); + try { + writeJson(join(piDir, "settings.json"), { + defaultModel: "openai-codex/gpt-5.4", + }); + writeJson(join(piDir, "models.json"), { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-responses", + models: [{ id: "gpt-5.4" }], + }, + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [{ id: "gpt-4o-mini" }, { id: "gpt-4o" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + "openai-codex": { + type: "oauth", + access: "oauth-access-token-fixture", + }, + acme: { type: "api_key", key: OTHER_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe("acme"); + expect(result.model).toBe("gpt-4o-mini"); + expect(result.apiKey).toBe(OTHER_KEY); + assertNoSecretLeak(result, OTHER_KEY, "oauth-access-token-fixture"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("reports unsupported-api when api-key providers only speak google-generative-ai", () => { + const piDir = makePiDir("unsupported"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + api: "google-generative-ai", + models: [{ id: "gemini-2.0-flash" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + google: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("unsupported-api"); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("skips unsupported APIs when a supported candidate exists", () => { + const piDir = makePiDir("skip-unsup"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + api: "google-generative-ai", + models: [{ id: "gemini-2.0-flash" }], + }, + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [{ id: "mini-model" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + google: { type: "api_key", key: FIXTURE_KEY }, + acme: { type: "api_key", key: OTHER_KEY }, + }); + + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe("acme"); + expect(result.model).toBe("mini-model"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Relocated home / PI_CODING_AGENT_DIR +// --------------------------------------------------------------------------- + +describe("resolvePiObserverConfig — relocated pi dir", () => { + it("honors PI_CODING_AGENT_DIR from env", () => { + const piDir = makePiDir("relocated"); + try { + writeJson(join(piDir, "settings.json"), {}); + writeJson(join(piDir, "models.json"), { + providers: { + acme: { + baseUrl: "https://api.acme.test/v1", + api: "openai-completions", + models: [{ id: "gpt-4o-mini" }], + }, + }, + }); + writeJson(join(piDir, "auth.json"), { + acme: { type: "api_key", key: FIXTURE_KEY }, + }); + + const result = resolvePiObserverConfig({ + env: { + HOME: "/tmp/should-not-be-used-for-pi", + PI_CODING_AGENT_DIR: piDir, + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.model).toBe("gpt-4o-mini"); + assertNoSecretLeak(result, FIXTURE_KEY); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); + + it("returns not-configured for an empty directory", () => { + const piDir = makePiDir("empty"); + try { + const result = resolvePiObserverConfig({ piDir }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("not-configured"); + expect(describePiObserverStatus(result)).toBe("unconfigured (not-configured)"); + } finally { + rmSync(piDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Explicit override detection + status redaction +// --------------------------------------------------------------------------- + +describe("hasExplicitObserverEnvOverride", () => { + it("is true when CODEMEM_OBSERVER_MODEL is set", () => { + expect(hasExplicitObserverEnvOverride({ CODEMEM_OBSERVER_MODEL: "gpt-4o-mini" })).toBe(true); + }); + + it("is true when CODEMEM_OBSERVER_PROVIDER is set", () => { + expect(hasExplicitObserverEnvOverride({ CODEMEM_OBSERVER_PROVIDER: "openai" })).toBe(true); + }); + + it("is false when neither is set", () => { + expect(hasExplicitObserverEnvOverride({})).toBe(false); + }); + + it("is env-only — file-style keys on the env object do not count", () => { + // Documents the rename: this helper does not inspect codemem config files. + expect( + hasExplicitObserverEnvOverride({ + observer_provider: "openai", + observer_model: "gpt-4o", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); + +describe("describePiObserverStatus", () => { + it("never echoes credential material", () => { + const ok: PiObserverResolveResult = { + ok: true, + provider: "acme", + model: "gpt-4o-mini", + baseUrl: "https://api.acme.test/v1", + apiKey: FIXTURE_KEY, + openAIUseResponses: false, + wireApi: "openai-completions", + }; + const status = describePiObserverStatus(ok); + expect(status).toBe("pi:acme/gpt-4o-mini via openai-completions (api-key)"); + expect(status).not.toContain(FIXTURE_KEY); + expect(status).not.toMatch(/sk-/); + + const all = collectStrings(ok); + // apiKey is on the object in memory (point of use) — that's expected; + // only the status string is user-facing. + expect(all).toContain(FIXTURE_KEY); + expect(describePiObserverStatus(undefined)).toContain("unconfigured"); + }); +}); diff --git a/packages/core/src/pi-observer-config.ts b/packages/core/src/pi-observer-config.ts new file mode 100644 index 000000000..e7d77c907 --- /dev/null +++ b/packages/core/src/pi-observer-config.ts @@ -0,0 +1,584 @@ +/** + * Derive codemem observer (extraction LLM) settings from pi agent configuration. + * + * Reads pi's settings.json, models.json / models-store.json, and auth.json + * (in memory only) and projects an API-key provider into the shape expected by + * ObserverConfig / the HTTP observer client. + * + * Design D8 / pi-agent-observer-config: + * - API-key credentials only (OAuth is unsupported in v1) + * - Wire APIs: openai-completions | openai-responses | anthropic-messages + * - Cheap-first model selection; never prefer interactive defaultModel + * - Credentials stay in the returned object only — never written or logged + * + * Callers MUST prefer explicit codemem `observer_*` config/env over this + * result (see {@link hasExplicitObserverEnvOverride}). + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { stripJsonComments, stripTrailingCommas } from "./observer-config.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type PiObserverResolveInput = { + /** Override for the pi agent dir (default: PI_CODING_AGENT_DIR or ~/.pi/agent). */ + piDir?: string; + /** Env view; defaults to process.env. Used for PI_CODING_AGENT_DIR + HOME. */ + env?: NodeJS.ProcessEnv; +}; + +export type PiObserverResolveOk = { + ok: true; + provider: string; + model: string; + baseUrl: string | null; + /** API key material — memory only; never persist or log. */ + apiKey: string | null; + openAIUseResponses: boolean; + /** Pi wire API id: openai-completions | openai-responses | anthropic-messages */ + wireApi: string; +}; + +export type PiObserverResolveReason = + | "not-configured" + | "oauth-only" + | "unsupported-api" + | "no-api-key-provider"; + +export type PiObserverResolveErr = { + ok: false; + reason: PiObserverResolveReason; + detail: string; +}; + +export type PiObserverResolveResult = PiObserverResolveOk | PiObserverResolveErr; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SUPPORTED_WIRE_APIS = new Set([ + "openai-completions", + "openai-responses", + "anthropic-messages", +]); + +/** + * Name-pattern cost tiers (lower = cheaper). Used when models-store has no + * numeric cost, and as a secondary signal alongside declared cost. + * + * Patterns match common small/fast model naming: mini, nano, haiku, flash, + * lite, small. Premium interactive names (opus, sonnet, pro, max, ultra, …) + * rank more expensive. + */ +const CHEAP_NAME_PATTERNS: Array<{ re: RegExp; tier: number }> = [ + { re: /\b(nano|haiku|micro)\b/i, tier: 0 }, + { re: /\b(mini|lite|flash|small|fast)\b/i, tier: 1 }, + { re: /\b(base|standard|instant)\b/i, tier: 2 }, + { re: /\b(sonnet|medium|plus)\b/i, tier: 5 }, + { re: /\b(opus|pro|max|ultra|large|premier|heavy)\b/i, tier: 8 }, +]; + +const DEFAULT_NAME_TIER = 4; + +// --------------------------------------------------------------------------- +// Path + JSON helpers +// --------------------------------------------------------------------------- + +function homeFromEnv(env: NodeJS.ProcessEnv): string { + const home = env.HOME?.trim() || env.USERPROFILE?.trim(); + return home || homedir(); +} + +/** Resolve the pi agent config directory. */ +export function resolvePiAgentDir(input: PiObserverResolveInput = {}): string { + if (input.piDir?.trim()) { + const raw = input.piDir.trim(); + if (raw.startsWith("~/")) { + return join(homeFromEnv(input.env ?? process.env), raw.slice(2)); + } + return raw; + } + const env = input.env ?? process.env; + const fromEnv = env.PI_CODING_AGENT_DIR?.trim(); + if (fromEnv) { + if (fromEnv.startsWith("~/")) { + return join(homeFromEnv(env), fromEnv.slice(2)); + } + return isAbsolute(fromEnv) ? fromEnv : join(homeFromEnv(env), fromEnv); + } + return join(homeFromEnv(env), ".pi", "agent"); +} + +function readJsonObject(path: string): Record | null { + if (!existsSync(path)) return null; + let text: string; + try { + text = readFileSync(path, "utf-8"); + } catch { + return null; + } + if (!text.trim()) return null; + + const tryParse = (raw: string): Record | null => { + try { + const parsed = JSON.parse(raw) as unknown; + return parsed != null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + }; + + const plain = tryParse(text); + if (plain) return plain; + return tryParse(stripTrailingCommas(stripJsonComments(text))); +} + +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +// --------------------------------------------------------------------------- +// Explicit observer_* env override detection (caller short-circuit) +// --------------------------------------------------------------------------- + +/** + * True when codemem already has an explicit observer provider/model via + * environment variables (`CODEMEM_OBSERVER_PROVIDER` / `CODEMEM_OBSERVER_MODEL`). + * + * Name is deliberately env-scoped: file-based `observer_provider`/`observer_model` + * in codemem config are checked by callers separately. Use this only for the + * env half of the "explicit config/env > pi-derived" precedence rule. + */ +export function hasExplicitObserverEnvOverride(env: NodeJS.ProcessEnv = process.env): boolean { + const provider = env.CODEMEM_OBSERVER_PROVIDER?.trim(); + const model = env.CODEMEM_OBSERVER_MODEL?.trim(); + return Boolean(provider || model); +} + +// --------------------------------------------------------------------------- +// Auth (in-memory only) +// --------------------------------------------------------------------------- + +type PiAuthEntry = { kind: "api_key"; key: string } | { kind: "oauth" } | { kind: "other" }; + +function loadPiAuth(piDir: string): { + byProvider: Map; + sawOAuth: boolean; + sawApiKey: boolean; +} { + const byProvider = new Map(); + let sawOAuth = false; + let sawApiKey = false; + const raw = readJsonObject(join(piDir, "auth.json")); + if (!raw) return { byProvider, sawOAuth, sawApiKey }; + + for (const [provider, value] of Object.entries(raw)) { + const entry = asRecord(value); + if (!entry) continue; + const type = asString(entry.type)?.toLowerCase(); + if (type === "api_key" || type === "api-key") { + const key = asString(entry.key); + if (key) { + byProvider.set(provider, { kind: "api_key", key }); + sawApiKey = true; + } + } else if (type === "oauth") { + byProvider.set(provider, { kind: "oauth" }); + sawOAuth = true; + } else if (type) { + byProvider.set(provider, { kind: "other" }); + } + } + return { byProvider, sawOAuth, sawApiKey }; +} + +// --------------------------------------------------------------------------- +// Models catalog (models.json + models-store.json) +// --------------------------------------------------------------------------- + +type PiModelCandidate = { + provider: string; + modelId: string; + baseUrl: string | null; + wireApi: string; + apiKey: string | null; + /** Declared input+output cost when known; null → fall back to name tier. */ + declaredCost: number | null; + nameTier: number; + /** True when listed in settings.enabledModels. */ + enabled: boolean; + /** True when this is settings.defaultModel. */ + isDefault: boolean; +}; + +function nameCostTier(modelId: string): number { + for (const { re, tier } of CHEAP_NAME_PATTERNS) { + if (re.test(modelId)) return tier; + } + return DEFAULT_NAME_TIER; +} + +function declaredCostOf(model: Record): number | null { + const cost = asRecord(model.cost); + if (!cost) return null; + const input = typeof cost.input === "number" ? cost.input : null; + const output = typeof cost.output === "number" ? cost.output : null; + if (input == null && output == null) return null; + // Weight input more heavily (extraction is prompt-heavy). + return (input ?? 0) * 2 + (output ?? 0); +} + +function loadModelsJsonProviders(piDir: string): Map< + string, + { + baseUrl: string | null; + api: string | null; + apiKey: string | null; + models: Array>; + } +> { + const out = new Map< + string, + { + baseUrl: string | null; + api: string | null; + apiKey: string | null; + models: Array>; + } + >(); + const root = readJsonObject(join(piDir, "models.json")); + if (!root) return out; + const providers = asRecord(root.providers) ?? root; + for (const [name, value] of Object.entries(providers)) { + // Skip non-provider keys if the file used a bare object without `providers` + if (name === "providers" || name === "modelOverrides") continue; + const prov = asRecord(value); + if (!prov) continue; + const modelsRaw = prov.models; + const models = Array.isArray(modelsRaw) + ? modelsRaw.filter((m): m is Record => asRecord(m) != null) + : []; + out.set(name, { + baseUrl: asString(prov.baseUrl), + api: asString(prov.api), + apiKey: asString(prov.apiKey), + models, + }); + } + return out; +} + +function loadModelsStoreProviders( + piDir: string, +): Map> }> { + const out = new Map> }>(); + const root = readJsonObject(join(piDir, "models-store.json")); + if (!root) return out; + + // models-store.json is { : { models: [...] } } (no providers wrapper) + // but also accept a wrapped shape for fixtures. + const providers = asRecord(root.providers) ?? root; + for (const [name, value] of Object.entries(providers)) { + const prov = asRecord(value); + if (!prov) continue; + const modelsRaw = prov.models; + if (!Array.isArray(modelsRaw)) continue; + const models = modelsRaw.filter((m): m is Record => asRecord(m) != null); + out.set(name, { models }); + } + return out; +} + +function buildCandidates( + piDir: string, + auth: ReturnType, + settings: { + enabledModels: Set; + defaultModel: string | null; + }, +): { + candidates: PiModelCandidate[]; + sawSupportedApi: boolean; + sawUnsupportedOnly: boolean; +} { + const fromJson = loadModelsJsonProviders(piDir); + const fromStore = loadModelsStoreProviders(piDir); + const providerNames = new Set([...fromJson.keys(), ...fromStore.keys()]); + + const candidates: PiModelCandidate[] = []; + let sawSupportedApi = false; + let sawAnyModelApi = false; + let sawUnsupportedApi = false; + + for (const provider of providerNames) { + const jsonProv = fromJson.get(provider); + const storeProv = fromStore.get(provider); + const authEntry = auth.byProvider.get(provider); + + // Credential: auth.json api_key wins, else models.json apiKey. + // OAuth-only providers are skipped entirely. + let apiKey: string | null = null; + if (authEntry?.kind === "api_key") { + apiKey = authEntry.key; + } else if (jsonProv?.apiKey) { + apiKey = jsonProv.apiKey; + } else if (authEntry?.kind === "oauth") { + // Tracked for oauth-only diagnosis; skip models. + continue; + } else { + // No credential for this provider — skip. + continue; + } + + // Merge models: models.json first (user-defined), then store catalog ids not already present. + const seenIds = new Set(); + const mergedModels: Array<{ + model: Record; + providerBaseUrl: string | null; + providerApi: string | null; + }> = []; + + for (const model of jsonProv?.models ?? []) { + const id = asString(model.id); + if (!id || seenIds.has(id)) continue; + seenIds.add(id); + mergedModels.push({ + model, + providerBaseUrl: jsonProv?.baseUrl ?? null, + providerApi: jsonProv?.api ?? null, + }); + } + for (const model of storeProv?.models ?? []) { + const id = asString(model.id); + if (!id || seenIds.has(id)) continue; + seenIds.add(id); + mergedModels.push({ + model, + providerBaseUrl: jsonProv?.baseUrl ?? asString(model.baseUrl), + providerApi: jsonProv?.api ?? null, + }); + } + + // Provider with apiKey in models.json but empty models list: nothing to pick. + for (const { model, providerBaseUrl, providerApi } of mergedModels) { + const modelId = asString(model.id); + if (!modelId) continue; + const wireApi = asString(model.api) ?? providerApi; + if (!wireApi) continue; + sawAnyModelApi = true; + if (!SUPPORTED_WIRE_APIS.has(wireApi)) { + sawUnsupportedApi = true; + continue; + } + sawSupportedApi = true; + + const baseUrl = asString(model.baseUrl) ?? providerBaseUrl; + const ref = `${provider}/${modelId}`; + const enabled = settings.enabledModels.size === 0 ? true : settings.enabledModels.has(ref); + const isDefault = settings.defaultModel === ref || settings.defaultModel === modelId; + + candidates.push({ + provider, + modelId, + baseUrl, + wireApi, + apiKey, + declaredCost: declaredCostOf(model), + nameTier: nameCostTier(modelId), + enabled, + isDefault, + }); + } + } + + return { + candidates, + sawSupportedApi, + sawUnsupportedOnly: sawAnyModelApi && !sawSupportedApi && sawUnsupportedApi, + }; +} + +function compareCheapFirst(a: PiModelCandidate, b: PiModelCandidate): number { + // Prefer enabledModels membership when the set is in use. + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + + // Numeric cost when both known. + if (a.declaredCost != null && b.declaredCost != null && a.declaredCost !== b.declaredCost) { + return a.declaredCost - b.declaredCost; + } + // Prefer known cost over unknown. + if (a.declaredCost != null && b.declaredCost == null) return -1; + if (a.declaredCost == null && b.declaredCost != null) return 1; + + // Name-pattern tier. + if (a.nameTier !== b.nameTier) return a.nameTier - b.nameTier; + + // Never prefer the interactive default when another candidate exists: + // sort non-default first so cheap-equal ties avoid defaultModel. + if (a.isDefault !== b.isDefault) return a.isDefault ? 1 : -1; + + // Deterministic tie-break. + const prov = a.provider.localeCompare(b.provider); + if (prov !== 0) return prov; + return a.modelId.localeCompare(b.modelId); +} + +function toOk(c: PiModelCandidate): PiObserverResolveOk { + return { + ok: true, + provider: c.provider, + model: c.modelId, + baseUrl: c.baseUrl, + apiKey: c.apiKey, + openAIUseResponses: c.wireApi === "openai-responses", + wireApi: c.wireApi, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolve observer provider/model/credential from a pi agent installation. + * + * Credentials are returned only on the in-memory result object. This function + * never writes files and never logs secret values. + */ +export function resolvePiObserverConfig( + input: PiObserverResolveInput = {}, +): PiObserverResolveResult { + const piDir = resolvePiAgentDir(input); + const settingsRaw = readJsonObject(join(piDir, "settings.json")) ?? {}; + const enabledModels = new Set(); + const enabledRaw = settingsRaw.enabledModels; + if (Array.isArray(enabledRaw)) { + for (const entry of enabledRaw) { + const s = asString(entry); + if (s) enabledModels.add(s); + } + } + const defaultModel = asString(settingsRaw.defaultModel); + + const auth = loadPiAuth(piDir); + const hasModelsJson = existsSync(join(piDir, "models.json")); + const hasModelsStore = existsSync(join(piDir, "models-store.json")); + const hasSettings = existsSync(join(piDir, "settings.json")); + const hasAuth = existsSync(join(piDir, "auth.json")); + + if (!hasSettings && !hasModelsJson && !hasModelsStore && !hasAuth) { + return { + ok: false, + reason: "not-configured", + detail: `No pi agent configuration found at ${piDir}`, + }; + } + + const { candidates, sawSupportedApi, sawUnsupportedOnly } = buildCandidates(piDir, auth, { + enabledModels, + defaultModel, + }); + + if (candidates.length > 0) { + const sorted = [...candidates].sort(compareCheapFirst); + const pick = sorted[0]; + if (pick) return toOk(pick); + } + + // Diagnosis when nothing eligible. + if (auth.sawOAuth && !auth.sawApiKey) { + // models.json may still embed apiKey — already considered above. + // If we truly have no api-key path: + const jsonProvs = loadModelsJsonProviders(piDir); + let embeddedKey = false; + for (const p of jsonProvs.values()) { + if (p.apiKey) { + embeddedKey = true; + break; + } + } + if (!embeddedKey) { + return { + ok: false, + reason: "oauth-only", + detail: + "pi auth.json contains only OAuth providers; codemem v1 cannot refresh OAuth. Set observer_provider/observer_model explicitly with an API-key provider.", + }; + } + } + + if (sawUnsupportedOnly || (!sawSupportedApi && (hasModelsJson || hasModelsStore))) { + // Providers exist but none speak a supported wire API (and no eligible candidates). + if (!auth.sawApiKey) { + // fall through + } else { + return { + ok: false, + reason: "unsupported-api", + detail: + "Authenticated pi providers use unsupported wire APIs (need openai-completions, openai-responses, or anthropic-messages).", + }; + } + } + + if (!auth.sawApiKey) { + const jsonProvs = loadModelsJsonProviders(piDir); + let embeddedKey = false; + for (const p of jsonProvs.values()) { + if (p.apiKey) { + embeddedKey = true; + break; + } + } + if (!embeddedKey) { + return { + ok: false, + reason: auth.sawOAuth ? "oauth-only" : "no-api-key-provider", + detail: auth.sawOAuth + ? "pi auth.json contains only OAuth providers; codemem v1 cannot refresh OAuth. Set observer_provider/observer_model explicitly with an API-key provider." + : "No API-key authenticated pi provider found in auth.json or models.json.", + }; + } + } + + return { + ok: false, + reason: "not-configured", + detail: `No eligible API-key model found under ${piDir}`, + }; +} + +/** + * One-line redacted status for setup/status output. Never includes secrets. + */ +export function describePiObserverStatus(result: PiObserverResolveResult | undefined): string { + if (result == null) { + return "unconfigured (pi observer not resolved)"; + } + if (result.ok) { + return `pi:${result.provider}/${result.model} via ${result.wireApi} (api-key)`; + } + switch (result.reason) { + case "oauth-only": + return "unconfigured (oauth-only); set observer_provider/observer_model explicitly"; + case "unsupported-api": + return "unconfigured (unsupported-api); set observer_provider/observer_model explicitly"; + case "no-api-key-provider": + return "unconfigured (no-api-key-provider); set observer_provider/observer_model explicitly"; + default: + return "unconfigured (not-configured)"; + } +} diff --git a/packages/embeddings/package.json b/packages/embeddings/package.json index 129793ed5..13fc6c577 100644 --- a/packages/embeddings/package.json +++ b/packages/embeddings/package.json @@ -15,7 +15,7 @@ ], "scripts": { "clean": "rm -rf dist tsconfig.tsbuildinfo", - "build": "pnpm exec vite build && pnpm exec tsc --build --force", + "build": "pnpm exec vite build && rm -f tsconfig.tsbuildinfo && pnpm exec tsc --build && test -f dist/index.d.ts", "typecheck": "pnpm exec tsc --noEmit", "test": "pnpm exec vitest run", "test:packed-runtime": "node ./scripts/packed-runtime-smoke.mjs" diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 91ae381dc..be802482b 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -28,7 +28,7 @@ ], "scripts": { "clean": "rm -rf dist tsconfig.tsbuildinfo", - "build": "pnpm exec vite build && pnpm exec tsc --build --force", + "build": "pnpm exec vite build && rm -f tsconfig.tsbuildinfo && pnpm exec tsc --build && test -f dist/index.d.ts && test -f dist/stdio.d.ts && test -f dist/http.d.ts", "typecheck": "pnpm exec tsc --noEmit" }, "dependencies": { diff --git a/packages/mcp-server/src/schemas.test.ts b/packages/mcp-server/src/schemas.test.ts index 41d779ee6..535669e81 100644 --- a/packages/mcp-server/src/schemas.test.ts +++ b/packages/mcp-server/src/schemas.test.ts @@ -1,6 +1,12 @@ -import { REMEMBER_MEMORY_KINDS } from "@codemem/core"; +import { + MEMORY_FILTER_FIELD_TYPES, + MEMORY_FILTER_NAMES, + type MemoryFilterFieldType, + type MemoryFilterName, + REMEMBER_MEMORY_KINDS, +} from "@codemem/core"; import { describe, expect, it } from "vitest"; -import { memoryKindSchema } from "./schemas.js"; +import { filterNames, filterSchema, memoryKindSchema } from "./schemas.js"; describe("memoryKindSchema", () => { it("accepts every remember kind from the core catalog", () => { @@ -14,3 +20,35 @@ describe("memoryKindSchema", () => { expect(memoryKindSchema.safeParse("not-a-kind").success).toBe(false); }); }); + +describe("filterSchema parity with the shared core catalog", () => { + it("exposes exactly the shared filter names in both surfaces", () => { + expect(filterNames).toEqual([...MEMORY_FILTER_NAMES]); + expect(Object.keys(filterSchema).toSorted()).toEqual([...MEMORY_FILTER_NAMES]); + }); + + it("accepts and rejects values per the shared field types", () => { + const valid: Record = { + string: "x", + "string-array": ["x", "y"], + int: 2, + number: 1.5, + "boolean-or-string": true, + }; + const invalid: Record = { + string: [false, 3], + "string-array": ["x", [1], { x: 1 }], + int: [1.5, "2", true], + number: ["abc", false], + "boolean-or-string": [3], + }; + for (const [name, fieldType] of Object.entries(MEMORY_FILTER_FIELD_TYPES)) { + const field = filterSchema[name as MemoryFilterName]; + expect(field.safeParse(valid[fieldType]).success, name).toBe(true); + expect(field.safeParse(undefined).success, name).toBe(true); + for (const bad of invalid[fieldType]) { + expect(field.safeParse(bad).success, `${name}: ${JSON.stringify(bad)}`).toBe(false); + } + } + }); +}); diff --git a/packages/viewer-server/package.json b/packages/viewer-server/package.json index d3fbc51b6..15689f95d 100644 --- a/packages/viewer-server/package.json +++ b/packages/viewer-server/package.json @@ -16,7 +16,7 @@ ], "scripts": { "clean": "rm -rf dist tsconfig.tsbuildinfo", - "build": "pnpm exec vite build --ssr src/index.ts --outDir dist && pnpm exec tsc --build --force", + "build": "pnpm exec vite build --ssr src/index.ts --outDir dist && rm -f tsconfig.tsbuildinfo && pnpm exec tsc --build && test -f dist/index.d.ts", "typecheck": "pnpm exec tsc --noEmit" }, "dependencies": { diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 746d1bc1c..932926902 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -2475,6 +2475,15 @@ describe("viewer-server", () => { prompt: "targeted Codex event", }, }, + { + route: "/api/pi-hooks", + payload: { + piEvent: "session_start", + sessionId: "targeted-pi", + cwd: "/tmp/pi-target", + ts: "2026-04-01T12:00:00.000Z", + }, + }, ] as const; it.each(targetedIngestCases)( diff --git a/packages/viewer-server/src/index.ts b/packages/viewer-server/src/index.ts index 2c5dad5ef..bc677c819 100644 --- a/packages/viewer-server/src/index.ts +++ b/packages/viewer-server/src/index.ts @@ -28,6 +28,7 @@ import { configRoutes } from "./routes/config.js"; import { diagnosticsRoutes } from "./routes/diagnostics.js"; import { healthRoutes } from "./routes/health.js"; import { memoryRoutes } from "./routes/memory.js"; +import { memoryToolRoutes } from "./routes/memory-tools.js"; import { observerStatusRoutes } from "./routes/observer-status.js"; import { packTransportRoutes } from "./routes/pack.js"; import { rawEventsRoutes } from "./routes/raw-events.js"; @@ -179,6 +180,7 @@ export function createApp(opts?: AppOptions) { app.route("/", diagnosticsRoutes(storeFactory)); app.route("/", statsRoutes(storeFactory)); app.route("/", memoryRoutes(storeFactory)); + app.route("/", memoryToolRoutes(storeFactory)); app.route("/", packTransportRoutes(storeFactory)); app.route( "/", diff --git a/packages/viewer-server/src/routes/memory-tools.test.ts b/packages/viewer-server/src/routes/memory-tools.test.ts new file mode 100644 index 000000000..b9ff759f6 --- /dev/null +++ b/packages/viewer-server/src/routes/memory-tools.test.ts @@ -0,0 +1,863 @@ +/** + * Route tests for pi-hooks ingest + memory tool-support HTTP twins. + * + * Contracts mirror packages/mcp-server tool handlers against MemoryStore. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + initTestSchema, + insertTestSession, + MemoryStore, + type RawEventSweeper, +} from "@codemem/core"; +import Database from "better-sqlite3"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { createApp } from "../index.js"; + +// Keep route tests hermetic: no embedding model downloads on the hot path. +// Save/restore so sibling suites in a shared worker are unaffected. +let savedEmbeddingDisabled: string | undefined; +beforeAll(() => { + savedEmbeddingDisabled = process.env.CODEMEM_EMBEDDING_DISABLED; + process.env.CODEMEM_EMBEDDING_DISABLED = "1"; +}); +afterAll(() => { + if (savedEmbeddingDisabled === undefined) delete process.env.CODEMEM_EMBEDDING_DISABLED; + else process.env.CODEMEM_EMBEDDING_DISABLED = savedEmbeddingDisabled; +}); +function createTestStore(): { store: MemoryStore; cleanup: () => void } { + const tmpDir = mkdtempSync(join(tmpdir(), "codemem-memory-tools-test-")); + const dbPath = join(tmpDir, "test.sqlite"); + const rawDb = new Database(dbPath); + initTestSchema(rawDb); + rawDb + .prepare( + "INSERT INTO sync_device(device_id, public_key, fingerprint, created_at) VALUES (?, ?, ?, ?)", + ) + .run("test-device-001", "test-public-key", "test-fingerprint", new Date().toISOString()); + rawDb.close(); + const store = new MemoryStore(dbPath); + return { + store, + cleanup: () => { + store.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +function createTestApp(opts?: { sweeper?: Partial | null }) { + let store: MemoryStore | null = null; + let storeCleanup: (() => void) | null = null; + const staticDir = mkdtempSync(join(tmpdir(), "codemem-memory-tools-static-")); + writeFileSync(join(staticDir, "index.html"), "test"); + const previousStaticDir = process.env.CODEMEM_VIEWER_STATIC_DIR; + process.env.CODEMEM_VIEWER_STATIC_DIR = staticDir; + const storeFactory = () => { + if (!store) { + const created = createTestStore(); + store = created.store; + storeCleanup = created.cleanup; + } + return store; + }; + const app = createApp({ + storeFactory, + sweeper: (opts?.sweeper ?? null) as RawEventSweeper | null, + }); + return { + app, + getStore: () => store, + ensureStore: () => storeFactory(), + cleanup: () => { + storeCleanup?.(); + store = null; + storeCleanup = null; + if (previousStaticDir == null) delete process.env.CODEMEM_VIEWER_STATIC_DIR; + else process.env.CODEMEM_VIEWER_STATIC_DIR = previousStaticDir; + rmSync(staticDir, { recursive: true, force: true }); + }, + }; +} + +function jsonHeaders(): Record { + return { + "Content-Type": "application/json", + Origin: "http://127.0.0.1:38888", + }; +} + +function seedMemories(store: MemoryStore): { sessionId: number; ids: number[] } { + const sessionId = insertTestSession(store.db); + // Ensure project matches insertTestSession default so project filters work. + store.db.prepare("UPDATE sessions SET project = ? WHERE id = ?").run("test-project", sessionId); + const ids = [ + store.remember( + sessionId, + "discovery", + "Database migration guide", + "How to run migrations", + 0.9, + ), + store.remember(sessionId, "feature", "Auth system", "JWT tokens and refresh flow", 0.8), + store.remember(sessionId, "decision", "Use SQLite", "Pick sqlite for local store", 0.7), + store.remember(sessionId, "bugfix", "Fix race in cache", "Race on concurrent writes", 0.6), + ]; + return { sessionId, ids }; +} + +// --------------------------------------------------------------------------- +// 4.1 POST /api/pi-hooks +// --------------------------------------------------------------------------- + +describe("POST /api/pi-hooks", () => { + // hashed id format shared with @codemem/core (pi/1 algo) + const PI_EVENT_ID = /^pi_evt_[0-9a-f]{24}$/; + it("records a pi event with source=pi and nudges the sweeper with (stream, pi)", async () => { + const nudge = vi.fn(); + const { app, getStore, cleanup } = createTestApp({ + sweeper: { nudge } as Partial, + }); + try { + const payload = { + piEvent: "session_start", + sessionId: "pi-sess-route-1", + cwd: "/tmp/pi-proj", + ts: "2026-04-01T12:00:00.000Z", + }; + const res = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(payload), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ inserted: 1, skipped: 0 }); + + const store = getStore(); + if (!store) throw new Error("store missing"); + + const eventRow = store.db + .prepare( + "SELECT source, stream_id, event_id, event_type FROM raw_events WHERE stream_id = ?", + ) + .get("pi-sess-route-1") as { + source: string; + stream_id: string; + event_id: string; + event_type: string; + }; + expect(eventRow.source).toBe("pi"); + expect(eventRow.stream_id).toBe("pi-sess-route-1"); + expect(eventRow.event_id).toMatch(PI_EVENT_ID); + expect(eventRow.event_type).toBe("pi.hook"); + + const sessionRow = store.db + .prepare("SELECT source, stream_id FROM raw_event_sessions WHERE stream_id = ?") + .get("pi-sess-route-1") as { source: string; stream_id: string }; + expect(sessionRow.source).toBe("pi"); + + const opencodeCount = store.db + .prepare("SELECT COUNT(*) AS n FROM raw_events WHERE source = 'opencode'") + .get() as { n: number }; + expect(opencodeCount.n).toBe(0); + + expect(nudge).toHaveBeenCalledWith("pi-sess-route-1", "pi"); + } finally { + cleanup(); + } + }); + + it("dedupes identical pi events on retry", async () => { + const nudge = vi.fn(); + const { app, getStore, cleanup } = createTestApp({ + sweeper: { nudge } as Partial, + }); + try { + const payload = { + piEvent: "message_end", + sessionId: "pi-sess-dedupe", + entryId: "entry-42", + role: "user", + text: "hello from pi", + ts: "2026-04-01T12:01:00.000Z", + }; + const first = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(payload), + }); + expect(await first.json()).toEqual({ inserted: 1, skipped: 0 }); + + const second = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(payload), + }); + expect(await second.json()).toEqual({ inserted: 0, skipped: 1 }); + + const store = getStore(); + if (!store) throw new Error("store missing"); + const count = store.db + .prepare("SELECT COUNT(*) AS n FROM raw_events WHERE source = 'pi' AND stream_id = ?") + .get("pi-sess-dedupe") as { n: number }; + expect(count.n).toBe(1); + expect(nudge).toHaveBeenCalledTimes(2); + expect(nudge).toHaveBeenNthCalledWith(1, "pi-sess-dedupe", "pi"); + expect(nudge).toHaveBeenNthCalledWith(2, "pi-sess-dedupe", "pi"); + } finally { + cleanup(); + } + }); + + it("skips unsupported / flush-only pi events without writing rows", async () => { + const nudge = vi.fn(); + const { app, getStore, ensureStore, cleanup } = createTestApp({ + sweeper: { nudge } as Partial, + }); + try { + ensureStore(); + const res = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + piEvent: "session_before_compact", + sessionId: "pi-sess-compact", + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ inserted: 0, skipped: 1 }); + expect(nudge).not.toHaveBeenCalled(); + + const store = getStore(); + if (!store) throw new Error("store missing"); + const count = store.db.prepare("SELECT COUNT(*) AS n FROM raw_events").get() as { + n: number; + }; + expect(count.n).toBe(0); + } finally { + cleanup(); + } + }); + + it("does not duplicate when the same envelope is posted to /api/raw-events", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + const payload = { + piEvent: "message_end", + sessionId: "pi-sess-alias", + entryId: "entry-alias", + role: "user", + text: "alias parity", + ts: "2026-04-01T12:02:00.000Z", + }; + const first = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(payload), + }); + expect(await first.json()).toEqual({ inserted: 1, skipped: 0 }); + + const envelope = await import("@codemem/core").then((mod) => + mod.buildRawEventEnvelopeFromPiEvent(payload), + ); + expect(envelope).not.toBeNull(); + const second = await app.request("/api/raw-events", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(envelope), + }); + const secondBody = (await second.json()) as { inserted: number; skipped: number }; + expect(secondBody.inserted).toBe(0); + expect(secondBody.skipped).toBe(1); + + const store = getStore(); + if (!store) throw new Error("store missing"); + const count = store.db + .prepare("SELECT COUNT(*) AS n FROM raw_events WHERE source = 'pi'") + .get() as { n: number }; + expect(count.n).toBe(1); + const opencode = store.db + .prepare("SELECT COUNT(*) AS n FROM raw_events WHERE source = 'opencode'") + .get() as { n: number }; + expect(opencode.n).toBe(0); + } finally { + cleanup(); + } + }); + + it("strips db_path and identity_target before persisting a targeted pi event", async () => { + const { app, getStore, ensureStore, cleanup } = createTestApp(); + try { + ensureStore(); + const store = getStore(); + if (!store) throw new Error("store missing"); + const profile = (await (await app.request("/api/prompt-pack-profile")).json()) as { + identity_target: Record; + }; + const res = await app.request("/api/pi-hooks", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + piEvent: "session_start", + sessionId: "pi-sess-targeted", + cwd: "/tmp/pi-proj", + ts: "2026-04-01T12:03:00.000Z", + db_path: store.dbPath, + identity_target: profile.identity_target, + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ inserted: 1, skipped: 0 }); + + const row = store.db + .prepare("SELECT payload_json FROM raw_events WHERE stream_id = ?") + .get("pi-sess-targeted") as { payload_json: string }; + expect(row.payload_json).not.toContain("db_path"); + expect(row.payload_json).not.toContain("identity_target"); + } finally { + cleanup(); + } + }); +}); +// --------------------------------------------------------------------------- +// 4.2 / 4.3 Memory tool routes vs MCP twins +// --------------------------------------------------------------------------- + +describe("memory tool routes", () => { + describe("POST /api/memories/remember (memory_remember)", () => { + it("creates a memory and returns { id }", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/remember", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + kind: "decision", + title: "Adopt HTTP tool routes", + body: "Close the viewer gap so pi can call tools over HTTP.", + confidence: 0.85, + project: "codemem", + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: number }; + expect(typeof body.id).toBe("number"); + expect(body.id).toBeGreaterThan(0); + + const store = getStore(); + if (!store) throw new Error("store missing"); + const item = store.get(body.id); + expect(item?.title).toBe("Adopt HTTP tool routes"); + expect(item?.kind).toBe("decision"); + expect(Number(item?.active)).toBe(1); + + const session = store.db + .prepare("SELECT project, tool_version FROM sessions WHERE id = ?") + .get(item?.session_id) as { project: string; tool_version: string }; + expect(session.project).toBe("codemem"); + expect(session.tool_version).toBe("viewer-api"); + } finally { + cleanup(); + } + }); + + it("rejects invalid kind", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/remember", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + kind: "not-a-kind", + title: "x", + body: "y", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/kind must be one of/); + } finally { + cleanup(); + } + }); + + it("rejects an oversized remember body", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/remember", { + method: "POST", + headers: { ...jsonHeaders(), "content-length": "9999999" }, + body: JSON.stringify({ kind: "decision", title: "x", body: "y" }), + }); + expect(res.status).toBe(413); + } finally { + cleanup(); + } + }); + }); + + describe("GET /api/memories/timeline (memory_timeline)", () => { + it("rejects a non-string kind filter", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request( + `/api/memories/timeline?filters=${encodeURIComponent(JSON.stringify({ kind: { x: 1 } }))}`, + { headers: jsonHeaders() }, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/kind must be a string/); + } finally { + cleanup(); + } + }); + + it("rejects malformed scalar and array filter values with 400 (MCP contract parity)", async () => { + const { app, cleanup } = createTestApp(); + try { + const malformed: Array> = [ + { include_scope_ids: false }, + { widen_shared_min_personal_results: "abc" }, + { include_visibility: ["private", 3] }, + { personal_first: 3 }, + ]; + for (const filters of malformed) { + const res = await app.request( + `/api/memories/timeline?filters=${encodeURIComponent(JSON.stringify(filters))}`, + { headers: jsonHeaders() }, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/has an invalid type/); + } + } finally { + cleanup(); + } + }); + + it("accepts boolean union filters like the MCP schema", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + seedMemories(store); + const filters = encodeURIComponent(JSON.stringify({ personal_first: true })); + const res = await app.request( + `/api/memories/timeline?query=Database&depth_before=5&depth_after=5&filters=${filters}`, + { headers: jsonHeaders() }, + ); + expect(res.status).toBe(200); + } finally { + cleanup(); + } + }); + + it("returns a chronological window around an anchor id", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + const { ids } = seedMemories(store); + const anchor = ids[2]; + + const res = await app.request( + `/api/memories/timeline?memory_id=${anchor}&depth_before=2&depth_after=2`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ id: number }> }; + expect(Array.isArray(body.items)).toBe(true); + expect(body.items.some((item) => item.id === anchor)).toBe(true); + expect(body.items.length).toBeGreaterThanOrEqual(1); + } finally { + cleanup(); + } + }); + + it("anchors via query string like the MCP tool", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + seedMemories(store); + const res = await app.request( + "/api/memories/timeline?query=Database&depth_before=1&depth_after=1", + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ title: string }> }; + expect(body.items.length).toBeGreaterThan(0); + expect(body.items.some((item) => /Database|migration/i.test(item.title))).toBe(true); + } finally { + cleanup(); + } + }); + + it("honors JSON filters.include_visibility (MCP filter surface parity)", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET project = ? WHERE id = ?") + .run("test-project", sessionId); + const sharedId = store.remember( + sessionId, + "discovery", + "Timeline shared visibility row", + "shared body for timeline filter", + 0.9, + undefined, + { visibility: "shared" }, + ); + const privateId = store.remember( + sessionId, + "discovery", + "Timeline private visibility row", + "private body for timeline filter", + 0.9, + undefined, + { visibility: "private" }, + ); + + const filters = encodeURIComponent(JSON.stringify({ include_visibility: ["private"] })); + const res = await app.request( + `/api/memories/timeline?query=Timeline&depth_before=5&depth_after=5&filters=${filters}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ id: number; title: string }> }; + const ids = body.items.map((item) => item.id); + expect(ids).toContain(privateId); + expect(ids).not.toContain(sharedId); + } finally { + cleanup(); + } + }); + + it("rejects malformed filters JSON with 400", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/timeline?query=x&filters=not-json"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "filters must be valid JSON" }); + } finally { + cleanup(); + } + }); + }); + + describe("POST /api/memories/expand (memory_expand)", () => { + it("returns anchors, timeline, missing_ids, errors, metadata", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + const { ids } = seedMemories(store); + const res = await app.request("/api/memories/expand", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + ids: [ids[0], ids[1], 999999], + depth_before: 1, + depth_after: 1, + include_observations: true, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + anchors: Array<{ id: number }>; + timeline: Array<{ id: number }>; + observations: Array<{ id: number }>; + missing_ids: number[]; + errors: Array<{ code: string }>; + metadata: { + requested_ids_count: number; + returned_anchor_count: number; + include_observations: boolean; + }; + }; + expect(body.anchors.map((a) => a.id).sort()).toEqual([ids[0], ids[1]].sort()); + expect(body.timeline.length).toBeGreaterThanOrEqual(2); + expect(body.observations.length).toBeGreaterThan(0); + expect(body.missing_ids).toContain(999999); + expect(body.errors.some((e) => e.code === "NOT_FOUND")).toBe(true); + expect(body.metadata.requested_ids_count).toBe(3); + expect(body.metadata.returned_anchor_count).toBe(2); + expect(body.metadata.include_observations).toBe(true); + } finally { + cleanup(); + } + }); + + it("rejects a non-string include_scope_ids filter with 400", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/expand", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + ids: [1], + include_scope_ids: { x: 1 }, + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/include_scope_ids/); + } finally { + cleanup(); + } + }); + }); + + describe("GET /api/memories/schema (memory_schema)", () => { + it("returns kinds, kind_descriptions, fields, and filters", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/schema"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + kinds: string[]; + kind_descriptions: Record; + fields: Record; + filters: string[]; + }; + expect(body.kinds).toEqual( + expect.arrayContaining([ + "discovery", + "change", + "feature", + "bugfix", + "refactor", + "decision", + "exploration", + ]), + ); + expect(body.kind_descriptions.decision).toMatch(/design/i); + expect(body.fields.title).toBe("short text"); + expect(body.fields.body).toBe("long text"); + expect(body.filters).toEqual(expect.arrayContaining(["kind", "project", "scope_id"])); + // Sorted like MCP Object.keys(...).toSorted() + expect(body.filters).toEqual([...body.filters].toSorted()); + } finally { + cleanup(); + } + }); + }); + + describe("GET /api/memories/search_index (memory_search_index)", () => { + it("returns compact index entries without body text", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + seedMemories(store); + const res = await app.request("/api/memories/search_index?query=Database&limit=5"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array>; + }; + expect(body.items.length).toBeGreaterThan(0); + const first = body.items[0]; + expect(first).toEqual( + expect.objectContaining({ + id: expect.any(Number), + kind: expect.any(String), + title: expect.any(String), + score: expect.any(Number), + created_at: expect.any(String), + session_id: expect.any(Number), + metadata: expect.any(Object), + }), + ); + // Compact index: no body / body_text field (MCP parity) + expect(first).not.toHaveProperty("body"); + expect(first).not.toHaveProperty("body_text"); + } finally { + cleanup(); + } + }); + + it("requires query", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/search_index"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "query required" }); + } finally { + cleanup(); + } + }); + + it("honors JSON filters.include_visibility so private rows can be selected", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET project = ? WHERE id = ?") + .run("test-project", sessionId); + // Distinct titles so FTS/search ranking is unambiguous. + const sharedId = store.remember( + sessionId, + "bugfix", + "IndexAlpha shared row", + "shared body IndexAlpha", + 0.9, + undefined, + { visibility: "shared" }, + ); + const privateId = store.remember( + sessionId, + "bugfix", + "IndexAlpha private row", + "private body IndexAlpha", + 0.9, + undefined, + { visibility: "private" }, + ); + + // Without filters both (or at least shared) should be findable. + const unfiltered = await app.request( + "/api/memories/search_index?query=IndexAlpha&limit=10", + ); + expect(unfiltered.status).toBe(200); + const unfilteredBody = (await unfiltered.json()) as { + items: Array<{ id: number }>; + }; + const unfilteredIds = unfilteredBody.items.map((i) => i.id); + expect(unfilteredIds).toContain(sharedId); + + const filters = encodeURIComponent(JSON.stringify({ include_visibility: ["private"] })); + const res = await app.request( + `/api/memories/search_index?query=IndexAlpha&limit=10&filters=${filters}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ id: number; title: string }> }; + const ids = body.items.map((item) => item.id); + expect(ids).toContain(privateId); + expect(ids).not.toContain(sharedId); + } finally { + cleanup(); + } + }); + + it("honors kind inside JSON filters (not only top-level kind)", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + seedMemories(store); + const filters = encodeURIComponent(JSON.stringify({ kind: "feature" })); + const res = await app.request( + `/api/memories/search_index?query=Auth&limit=10&filters=${filters}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ kind: string; title: string }> }; + expect(body.items.length).toBeGreaterThan(0); + for (const item of body.items) { + expect(item.kind).toBe("feature"); + } + } finally { + cleanup(); + } + }); + }); + + describe("POST /api/memories/explain (memory_explain)", () => { + it("returns scored explanation payload for a query", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + seedMemories(store); + const res = await app.request("/api/memories/explain", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ query: "database", limit: 5 }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array<{ id: number }>; + errors: unknown[]; + }; + expect(Array.isArray(body.items)).toBe(true); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.items.length).toBeGreaterThan(0); + } finally { + cleanup(); + } + }); + + it("explains specific ids", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + const { ids } = seedMemories(store); + const res = await app.request("/api/memories/explain", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ ids: [ids[0], ids[1]], limit: 10 }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array<{ id: number }> }; + const returned = body.items.map((i) => i.id); + expect(returned).toEqual(expect.arrayContaining([ids[0], ids[1]])); + } finally { + cleanup(); + } + }); + }); + + describe("POST /api/memories/distill_candidates (memory_distill_candidates)", () => { + it("returns a distill report shape (judge off for determinism)", async () => { + const { app, ensureStore, cleanup } = createTestApp(); + try { + const store = ensureStore(); + // Seed recurring-ish content so mining has something to cluster. + const sessionId = insertTestSession(store.db); + for (let i = 0; i < 4; i++) { + store.remember( + sessionId, + "discovery", + `Prefer explicit source attribution ${i}`, + "Always pass source pi explicitly; never rely on opencode defaults.", + 0.8, + ); + } + const res = await app.request("/api/memories/distill_candidates", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + limit: 5, + min_recurrence: 2, + judge: false, + all_projects: true, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + candidates: unknown[]; + metadata: Record; + }; + expect(Array.isArray(body.candidates)).toBe(true); + expect(body.metadata).toEqual(expect.any(Object)); + } finally { + cleanup(); + } + }); + + it("rejects project combined with all_projects", async () => { + const { app, cleanup } = createTestApp(); + try { + const res = await app.request("/api/memories/distill_candidates", { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + all_projects: true, + project: "codemem", + judge: false, + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/project cannot be combined with all_projects/); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/packages/viewer-server/src/routes/memory-tools.ts b/packages/viewer-server/src/routes/memory-tools.ts new file mode 100644 index 000000000..ed6395b79 --- /dev/null +++ b/packages/viewer-server/src/routes/memory-tools.ts @@ -0,0 +1,720 @@ +/** + * Memory tool-support routes — HTTP twins of MCP tools that the viewer + * previously lacked (remember, timeline, expand, schema, search_index, + * explain, distill_candidates). Used by thin clients (pi extension, CLI) + * that prefer HTTP over opening the store in-process. + * + * Behavioral contracts mirror packages/mcp-server/src/tools/* against the + * same @codemem/core MemoryStore APIs. No dependency on @codemem/mcp. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { + DistillContextDocument, + MemoryFilters, + MemoryItemResponse, + MemoryResult, + MemoryStore, +} from "@codemem/core"; +import { + buildDistillReport, + dedupeOrderedIds, + judgeDistillReport, + MEMORY_FILTER_FIELD_TYPES, + MEMORY_FILTER_NAMES, + MEMORY_KIND_DESCRIPTIONS as MEMORY_KINDS, + memoryFilterValueMatchesType, + ObserverClient, + parseStrictInteger, + projectMatchesFilter, + REMEMBER_MEMORY_KINDS, + resolveProject, + resolveProjectRoot, + storeVectors, + toJson, +} from "@codemem/core"; +import { Hono } from "hono"; +import { parseJsonObjectBody, queryInt } from "../helpers.js"; + +type StoreFactory = () => MemoryStore; + +const ALLOWED_REMEMBER_KINDS = new Set(REMEMBER_MEMORY_KINDS); + +const MEMORY_TOOLS_MAX_BODY_BYTES = 1_048_576; + +const SCHEMA_FIELDS = { + title: "short text", + body: "long text", + subtitle: "short text", + facts: "list", + narrative: "long text", + concepts: "list", + files_read: "list", + files_modified: "list", + prompt_number: "int", +}; + +function cleanProject(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function resolveWriteProject(input: { + project?: string | null; + envProject?: string | null; +}): string | null { + return cleanProject(input.project) ?? cleanProject(input.envProject) ?? null; +} + +/** + * Build MemoryFilters from a raw args object (query or body). + * + * Project scoping matches existing viewer routes (pack/memory/forget): only an + * explicit `project` arg applies. Unlike MCP tools, the viewer does not inject + * cwd/CODEMEM_PROJECT as an implicit default — the server process cwd is not a + * reliable client project signal. Callers (pi extension, CLI) pass project when + * they want scope. + */ +type FilterParse = { ok: true; filters: MemoryFilters | undefined } | { ok: false; error: string }; + +function buildFilters( + raw: Record, + defaultProject: string | null = null, +): FilterParse { + const filters: MemoryFilters = {}; + let hasAny = false; + + if (raw.project != null && typeof raw.project !== "string") { + return { ok: false, error: "project must be a string" }; + } + const explicitProject = typeof raw.project === "string" ? cleanProject(raw.project) : undefined; + // Only fall back to defaultProject when the caller omitted `project` entirely + // (expand uses defaultProject=null for blank-string clear; see expand route). + const resolvedProject = + explicitProject !== undefined + ? explicitProject || undefined + : cleanProject(defaultProject) || undefined; + if (resolvedProject) { + filters.project = resolvedProject; + hasAny = true; + } + + for (const [key, fieldType] of Object.entries(MEMORY_FILTER_FIELD_TYPES)) { + if (key === "project") continue; + const val = raw[key]; + if (val === undefined || val === null) continue; + if (key === "kind" && typeof val !== "string") { + return { ok: false, error: "kind must be a string" }; + } + if (!memoryFilterValueMatchesType(val, fieldType)) { + return { ok: false, error: `${key} has an invalid type` }; + } + (filters as Record)[key] = val; + hasAny = true; + } + + return { ok: true, filters: hasAny ? filters : undefined }; +} + +/** + * Parse MemoryFilters from a GET query string. + * + * Full filter surface (arrays / booleans / numbers matching MCP filterSchema) + * is accepted via a single JSON-encoded `filters` query param so GET stays + * ergonomic without multi-value keys. Top-level `project` and `kind` remain + * as convenience aliases for existing callers and override the same keys in + * the JSON object when both are present. + * + * Example: + * /api/memories/search_index?query=foo&filters={"include_visibility":["private"]} + */ +function parseGetFilters(queryGetter: (name: string) => string | undefined): FilterParse { + const filterRaw: Record = {}; + + const filtersParam = queryGetter("filters"); + if (filtersParam != null && filtersParam.trim() !== "") { + let parsed: unknown; + try { + parsed = JSON.parse(filtersParam); + } catch { + return { ok: false, error: "filters must be valid JSON" }; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: "filters must be a JSON object" }; + } + Object.assign(filterRaw, parsed as Record); + } + + // Top-level convenience aliases (backward compatible with project+kind only). + const project = queryGetter("project"); + const kind = queryGetter("kind"); + if (project != null) filterRaw.project = project; + if (kind != null) filterRaw.kind = kind; + + return buildFilters(filterRaw); +} + +function getMemoryForAccess( + store: MemoryStore, + memoryId: number, + filters?: MemoryFilters, +): MemoryItemResponse | null { + const rows = store.timeline(null, memoryId, 0, 0, filters ?? null); + return rows.find((row) => row.id === memoryId) ?? null; +} + +function getManyForAccess( + store: MemoryStore, + ids: number[], + filters?: MemoryFilters, +): MemoryItemResponse[] { + if (ids.length === 0) return []; + const results: MemoryItemResponse[] = []; + for (const id of ids) { + const item = getMemoryForAccess(store, id, filters); + if (item) results.push(item); + } + return results; +} + +function clampInt(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function parseOptionalInt(value: unknown): number | null { + if (value == null) return null; + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string") return parseStrictInteger(value); + return null; +} + +function parseOptionalBoolean(value: unknown): boolean | "invalid" { + if (value == null) return false; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + } + return "invalid"; +} +function parseJsonBody( + body: unknown, +): { ok: true; value: Record } | { ok: false; error: string } { + if (body == null || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "payload must be an object" }; + } + return { ok: true, value: body as Record }; +} + +function rememberMemory( + store: MemoryStore, + input: { + kind: string; + title: string; + body: string; + confidence: number; + project?: string | null; + }, +): { memId: number; title: string; body: string } { + return store.db.transaction(() => { + const now = new Date().toISOString(); + const user = process.env.USER ?? "unknown"; + const cwd = process.cwd(); + const project = resolveWriteProject({ + project: input.project, + envProject: process.env.CODEMEM_PROJECT, + }); + + const sessionInfo = store.db + .prepare( + `INSERT INTO sessions(started_at, ended_at, cwd, project, user, tool_version, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(now, now, cwd, project, user, "viewer-api", toJson({ viewer: true })); + const sessionId = Number(sessionInfo.lastInsertRowid); + + const memId = store.remember(sessionId, input.kind, input.title, input.body, input.confidence); + if (!getMemoryForAccess(store, memId)) { + throw new Error("unauthorized_scope"); + } + + store.db + .prepare("UPDATE sessions SET ended_at = ?, metadata_json = ? WHERE id = ?") + .run(new Date().toISOString(), toJson({ viewer: true }), sessionId); + + return { memId, title: input.title, body: input.body }; + })(); +} + +function readContextFile( + path: string, + displayPath: string, + scope: DistillContextDocument["scope"], +): DistillContextDocument | null { + if (!existsSync(path)) return null; + const text = readFileSync(path, "utf8"); + return text.trim() ? { path: displayPath, text, scope } : null; +} + +function loadDefaultContextDocuments( + includeProjectContext: boolean, + cwd = process.cwd(), +): DistillContextDocument[] { + const projectRoot = resolveProjectRoot(cwd) ?? cwd; + const documents = [ + includeProjectContext + ? readContextFile(join(projectRoot, "AGENTS.md"), "AGENTS.md", "project") + : null, + readContextFile( + join(homedir(), ".config", "opencode", "AGENTS.md"), + "~/.config/opencode/AGENTS.md", + "user", + ), + ]; + return documents.filter((document): document is DistillContextDocument => document != null); +} + +function shouldIncludeProjectContext( + args: { all_projects?: boolean; project?: unknown }, + defaultProject: string | null, +): boolean { + if (args.all_projects) return false; + const currentProject = resolveProject(process.cwd()); + if (!currentProject) return false; + const explicitProject = typeof args.project === "string" ? args.project.trim() : ""; + const targetProject = explicitProject + ? resolveProject(process.cwd(), explicitProject) + : defaultProject; + if (!targetProject) return false; + return projectMatchesFilter(targetProject, currentProject); +} + +function buildDistillFilters( + args: { all_projects?: boolean } & Record, + defaultProject: string | null, +): FilterParse { + if (args.all_projects && typeof args.project === "string" && args.project.trim()) { + return { ok: false, error: "project cannot be combined with all_projects" }; + } + return buildFilters(args, args.all_projects ? null : defaultProject); +} + +function mapSearchIndexItem(m: MemoryResult) { + return { + id: m.id, + kind: m.kind, + title: m.title, + score: m.score, + created_at: m.created_at, + session_id: m.session_id, + metadata: m.metadata, + }; +} + +function expandMemories( + store: MemoryStore, + args: { + ids: unknown[]; + depth_before: number; + depth_after: number; + include_observations: boolean; + filters?: MemoryFilters; + }, +) { + const resolvedProject = args.filters?.project ?? null; + const { ordered: orderedIds, invalid: invalidIds } = dedupeOrderedIds(args.ids); + const errors: Array> = []; + + if (invalidIds.length > 0) { + errors.push({ + code: "INVALID_ARGUMENT", + field: "ids", + message: "some ids are not valid integers", + ids: invalidIds, + }); + } + + const missingNotFound: number[] = []; + const missingProjectMismatch: number[] = []; + const missingFilterMismatch: number[] = []; + const anchors: MemoryItemResponse[] = []; + const timelineItems: MemoryItemResponse[] = []; + const timelineSeen = new Set(); + const sessionProjects = new Map(); + + for (const memoryId of orderedIds) { + const item = store.get(memoryId); + if (!item?.active) { + missingNotFound.push(memoryId); + continue; + } + + const sessionId = item.session_id; + if (resolvedProject && sessionId > 0) { + if (!sessionProjects.has(sessionId)) { + const row = store.db + .prepare("SELECT project FROM sessions WHERE id = ? LIMIT 1") + .get(sessionId) as { project: string | null } | undefined; + sessionProjects.set(sessionId, typeof row?.project === "string" ? row.project : null); + } + if (!projectMatchesFilter(resolvedProject, sessionProjects.get(sessionId) ?? null)) { + missingProjectMismatch.push(memoryId); + continue; + } + } else if (resolvedProject && sessionId <= 0) { + missingProjectMismatch.push(memoryId); + continue; + } + + const expanded = store.timeline( + null, + memoryId, + args.depth_before, + args.depth_after, + args.filters, + ); + const anchor = expanded.find((expandedItem) => expandedItem.id === memoryId); + if (!anchor) { + missingFilterMismatch.push(memoryId); + continue; + } + + anchors.push(anchor); + for (const expandedItem of expanded) { + const expandedId = expandedItem.id; + if (expandedId <= 0 || timelineSeen.has(expandedId)) continue; + timelineSeen.add(expandedId); + timelineItems.push(expandedItem); + } + } + + if (missingNotFound.length > 0) { + errors.push({ + code: "NOT_FOUND", + field: "ids", + message: "some requested ids were not found", + ids: missingNotFound, + }); + } + if (missingProjectMismatch.length > 0) { + errors.push({ + code: "PROJECT_MISMATCH", + field: "project", + message: "some requested ids are outside the requested project scope", + ids: missingProjectMismatch, + }); + } + if (missingFilterMismatch.length > 0) { + errors.push({ + code: "FILTER_MISMATCH", + field: "filters", + message: "some requested ids are outside the requested filters", + ids: missingFilterMismatch, + }); + } + + let observations: MemoryItemResponse[] = []; + if (args.include_observations) { + const observationSeen = new Set(); + const observationIds: number[] = []; + for (const item of [...anchors, ...timelineItems]) { + if (item.id > 0 && !observationSeen.has(item.id)) { + observationSeen.add(item.id); + observationIds.push(item.id); + } + } + observations = getManyForAccess(store, observationIds, args.filters); + } + + return { + anchors, + timeline: timelineItems, + observations, + missing_ids: orderedIds.filter( + (memoryId: number) => + missingNotFound.includes(memoryId) || + missingProjectMismatch.includes(memoryId) || + missingFilterMismatch.includes(memoryId), + ), + errors, + metadata: { + project: resolvedProject, + requested_ids_count: orderedIds.length, + returned_anchor_count: anchors.length, + timeline_count: timelineItems.length, + include_observations: args.include_observations, + }, + }; +} + +export function memoryToolRoutes(getStore: StoreFactory) { + const app = new Hono(); + + // POST /api/memories/remember — twin of memory_remember + app.post("/api/memories/remember", async (c) => { + const store = getStore(); + const body = await parseJsonObjectBody(c, MEMORY_TOOLS_MAX_BODY_BYTES); + if (body instanceof Response) return body; + const parsed = parseJsonBody(body); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const args = parsed.value; + + const kind = typeof args.kind === "string" ? args.kind.trim().toLowerCase() : ""; + if (!kind || !ALLOWED_REMEMBER_KINDS.has(kind)) { + return c.json( + { + error: `kind must be one of: ${[...ALLOWED_REMEMBER_KINDS].join(", ")}`, + }, + 400, + ); + } + const title = typeof args.title === "string" ? args.title : ""; + const bodyText = typeof args.body === "string" ? args.body : ""; + if (!title.trim()) return c.json({ error: "title is required" }, 400); + if (!bodyText.trim()) return c.json({ error: "body is required" }, 400); + + let confidence = 0.5; + if (args.confidence != null) { + if (typeof args.confidence !== "number" || Number.isNaN(args.confidence)) { + return c.json({ error: "confidence must be a number" }, 400); + } + confidence = Math.min(1, Math.max(0, args.confidence)); + } + const project = typeof args.project === "string" ? args.project : undefined; + + try { + const result = rememberMemory(store, { + kind, + title, + body: bodyText, + confidence, + project, + }); + try { + await storeVectors(store.db, result.memId, result.title, result.body); + } catch { + // Memory writes should succeed even if embeddings are unavailable. + } + return c.json({ id: result.memId }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("Invalid memory kind")) return c.json({ error: msg }, 400); + if (msg === "unauthorized_scope") return c.json({ error: msg }, 403); + return c.json({ error: msg }, 400); + } + }); + + // GET /api/memories/timeline — twin of memory_timeline + // Full filter surface via JSON `filters` query param (MCP filterSchema parity). + app.get("/api/memories/timeline", (c) => { + const store = getStore(); + const query = c.req.query("query") || undefined; + const memoryIdRaw = c.req.query("memory_id"); + const memoryId = + memoryIdRaw != null && memoryIdRaw !== "" ? parseStrictInteger(memoryIdRaw) : null; + if (memoryIdRaw != null && memoryIdRaw !== "" && memoryId == null) { + return c.json({ error: "memory_id must be int" }, 400); + } + const depthBefore = clampInt(queryInt(c.req.query("depth_before"), 3), 0, 100); + const depthAfter = clampInt(queryInt(c.req.query("depth_after"), 3), 0, 100); + + const parsedFilters = parseGetFilters((name) => c.req.query(name)); + if (!parsedFilters.ok) return c.json({ error: parsedFilters.error }, 400); + + const items = store.timeline( + query ?? null, + memoryId, + depthBefore, + depthAfter, + parsedFilters.filters, + ); + return c.json({ items }); + }); + + // POST /api/memories/expand — twin of memory_expand + app.post("/api/memories/expand", async (c) => { + const store = getStore(); + const body = await parseJsonObjectBody(c, MEMORY_TOOLS_MAX_BODY_BYTES); + if (body instanceof Response) return body; + const parsed = parseJsonBody(body); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const args = parsed.value; + + if (!Array.isArray(args.ids)) { + return c.json({ error: "ids must be an array" }, 400); + } + if (args.ids.length > 200) { + return c.json({ error: "ids must contain at most 200 entries" }, 400); + } + + const depthBeforeRaw = parseOptionalInt(args.depth_before); + const depthAfterRaw = parseOptionalInt(args.depth_after); + const depthBefore = clampInt(depthBeforeRaw ?? 3, 0, 100); + const depthAfter = clampInt(depthAfterRaw ?? 3, 0, 100); + const includeObservations = parseOptionalBoolean(args.include_observations); + if (includeObservations === "invalid") { + return c.json({ error: "include_observations must be a boolean" }, 400); + } + // Explicit blank project clears scoping (MCP expand parity). Viewer routes + // do not inject a cwd default project; only an explicit non-blank project scopes. + const parsedFilters = buildFilters(args, null); + if (!parsedFilters.ok) return c.json({ error: parsedFilters.error }, 400); + + const value = expandMemories(store, { + ids: args.ids, + depth_before: depthBefore, + depth_after: depthAfter, + include_observations: includeObservations === true, + filters: parsedFilters.filters, + }); + return c.json(value); + }); + + // GET /api/memories/schema — twin of memory_schema + app.get("/api/memories/schema", (c) => { + return c.json({ + kinds: Object.keys(MEMORY_KINDS), + kind_descriptions: MEMORY_KINDS, + fields: SCHEMA_FIELDS, + filters: MEMORY_FILTER_NAMES, + }); + }); + + // GET /api/memories/search_index — twin of memory_search_index + // Full filter surface via JSON `filters` query param (MCP filterSchema parity). + app.get("/api/memories/search_index", (c) => { + const store = getStore(); + const query = c.req.query("query") ?? ""; + if (!query.trim()) { + return c.json({ error: "query required" }, 400); + } + const limit = clampInt(queryInt(c.req.query("limit"), 8), 1, 50); + const parsedFilters = parseGetFilters((name) => c.req.query(name)); + if (!parsedFilters.ok) return c.json({ error: parsedFilters.error }, 400); + const items = store.search(query, limit, parsedFilters.filters).map(mapSearchIndexItem); + return c.json({ items }); + }); + + // POST /api/memories/explain — twin of memory_explain + app.post("/api/memories/explain", async (c) => { + const store = getStore(); + const body = await parseJsonObjectBody(c, MEMORY_TOOLS_MAX_BODY_BYTES); + if (body instanceof Response) return body; + const parsed = parseJsonBody(body); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const args = parsed.value; + const query = typeof args.query === "string" ? args.query : null; + let ids: number[] | null = null; + if (args.ids != null) { + if (!Array.isArray(args.ids)) { + return c.json({ error: "ids must be an array" }, 400); + } + if (args.ids.length > 200) { + return c.json({ error: "ids must contain at most 200 entries" }, 400); + } + const { ordered, invalid } = dedupeOrderedIds(args.ids); + if (invalid.length > 0) { + return c.json({ error: "some ids are not valid integers", ids: invalid }, 400); + } + ids = ordered; + } + const limit = clampInt(parseOptionalInt(args.limit) ?? 10, 1, 50); + const includePackContext = parseOptionalBoolean(args.include_pack_context); + if (includePackContext === "invalid") { + return c.json({ error: "include_pack_context must be a boolean" }, 400); + } + const parsedFilters = buildFilters(args); + if (!parsedFilters.ok) return c.json({ error: parsedFilters.error }, 400); + + const result = store.explain(query, ids, limit, parsedFilters.filters, { + includePackContext: includePackContext === true, + }); + return c.json(result); + }); + + // POST /api/memories/distill_candidates — twin of memory_distill_candidates + app.post("/api/memories/distill_candidates", async (c) => { + const store = getStore(); + const body = await parseJsonObjectBody(c, MEMORY_TOOLS_MAX_BODY_BYTES); + if (body instanceof Response) return body; + const parsed = parseJsonBody(body); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const args = parsed.value; + const limit = clampInt(parseOptionalInt(args.limit) ?? 10, 1, 50); + const minRecurrence = clampInt(parseOptionalInt(args.min_recurrence) ?? 2, 1, 50); + const allProjects = parseOptionalBoolean(args.all_projects); + if (allProjects === "invalid") { + return c.json({ error: "all_projects must be a boolean" }, 400); + } + const includeDocumented = parseOptionalBoolean(args.include_documented); + if (includeDocumented === "invalid") { + return c.json({ error: "include_documented must be a boolean" }, 400); + } + const maxEvidenceItems = clampInt(parseOptionalInt(args.max_evidence_items) ?? 5, 1, 20); + const judgeParsed = args.judge === undefined ? true : parseOptionalBoolean(args.judge); + if (judgeParsed === "invalid") { + return c.json({ error: "judge must be a boolean" }, 400); + } + const judge = judgeParsed === true; + try { + // Prefer explicit project / CODEMEM_PROJECT for context docs; no cwd default. + const resolvedDefaultProject = + cleanProject(typeof args.project === "string" ? args.project : null) ?? + cleanProject(process.env.CODEMEM_PROJECT); + const filterArgs = { ...args, all_projects: allProjects }; + const parsedFilters = buildDistillFilters(filterArgs, resolvedDefaultProject); + if (!parsedFilters.ok) return c.json({ error: parsedFilters.error }, 400); + const kinds = typeof args.kind === "string" && args.kind.trim() ? [args.kind] : undefined; + const fetchLimit = judge ? Math.min(limit * 3, limit + 20) : limit; + + let result = await buildDistillReport(store, { + candidate: { + includeDocumented, + maxEvidenceItems, + }, + contextDocuments: loadDefaultContextDocuments( + shouldIncludeProjectContext( + { all_projects: allProjects, project: args.project }, + resolvedDefaultProject, + ), + ), + corpus: { filters: parsedFilters.filters ?? null, kinds }, + limit: fetchLimit, + minRecurrence, + }); + + if (judge) { + try { + const client = new ObserverClient(); + result = await judgeDistillReport(result, async (system, user) => { + const response = await client.observe(system, user); + return response.raw; + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + result = { + ...result, + metadata: { ...result.metadata, judged: false, judge_error: message }, + }; + } + if (result.candidates.length > limit) { + result = { + ...result, + candidates: result.candidates.slice(0, limit), + metadata: { ...result.metadata, candidate_count: limit }, + }; + } + } + + return c.json(result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: msg }, 400); + } + }); + + return app; +} diff --git a/packages/viewer-server/src/routes/raw-events.ts b/packages/viewer-server/src/routes/raw-events.ts index b80016217..10432ba5f 100644 --- a/packages/viewer-server/src/routes/raw-events.ts +++ b/packages/viewer-server/src/routes/raw-events.ts @@ -1,6 +1,6 @@ /** * Raw events routes — GET & POST /api/raw-events, GET /api/raw-events/status, - * POST /api/claude-hooks, POST /api/codex-hooks. + * POST /api/claude-hooks, POST /api/codex-hooks, POST /api/pi-hooks. */ import { homedir } from "node:os"; @@ -9,6 +9,7 @@ import type { HookTranscriptOutcome, MemoryStore, RawEventSweeper } from "@codem import { buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, + buildRawEventEnvelopeFromPiEvent, ingestRawEvents, RawEventIngestValidationError, schema, @@ -336,5 +337,29 @@ export function rawEventsRoutes(getStore: StoreFactory, sweeper?: RawEventSweepe } }); + // POST /api/pi-hooks — ingest pi extension events (compat alias) + app.post("/api/pi-hooks", async (c) => { + const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES); + if (result instanceof Response) return result; + const payload = result; + + try { + const store = getStore(); + const target = validateViewerTarget(store, payload, { requirePairedTargets: true }); + if (!target.ok) return c.json(target.body, target.status); + const envelope = buildRawEventEnvelopeFromPiEvent(untargetedPayload(payload)); + if (envelope === null) { + return c.json({ inserted: 0, skipped: 1 }); + } + const ingestResult = await ingestNormalizedEnvelope(store, sweeper, { + ...envelope, + source: "pi", + }); + return c.json({ inserted: ingestResult.inserted, skipped: ingestResult.skipped }); + } catch (err) { + return boundedIngestErrorResponse(c, err); + } + }); + return app; }