From 6439601d1e16807c931a05cbf23c31d8d3672f02 Mon Sep 17 00:00:00 2001 From: vilosource Date: Sat, 16 May 2026 07:17:01 +0300 Subject: [PATCH] feat(daemon): extension switchover to daemon-aware store selection (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD red→green. The extension now selects its KnowledgeStore at startup: RpcKnowledgeStore when the daemon socket is bind-mounted in (container topology, contract §2.4), else the local MykbStore (host operator / no daemon). MYKB_STORE=local|rpc forces; else socket presence auto-detects. - store-selection.ts: selectKnowledgeStore(brainPath) → {store,mode}; socket = MYKB_SOCKET ?? /.mykbd.sock (mirrors daemon/main.ts). 5 unit tests (local default, socket-present, MYKB_SOCKET, both forces). - DIP: every extension hook + tool retyped from the concrete MykbStore to the KnowledgeStore interface (one-line alias import per file, 10 files) so RpcKnowledgeStore is LSP-substitutable with zero call-site change. index.ts swaps MykbStore.open → selectKnowledgeStore. - Contract-faithful fix surfaced by the retype: KnowledgeStore.search was missing the excludeZone arg the concrete impl + contract §5.3 (exclude_zone) already support — aligned the interface and threaded it through RpcKnowledgeStore.search and the dispatcher search verb (not cast away). +1 RPC parity test for exclude_zone over the wire. Full suite 645/645; zero regressions from the interface retype + extension swap (LSP evidence). Build + lint clean. --- src/core/types.ts | 2 +- src/daemon/dispatch.ts | 8 ++- src/daemon/rpc-store.ts | 9 ++- src/daemon/store-selection.ts | 54 +++++++++++++++ src/extension/hooks/context.ts | 2 +- src/extension/hooks/kb-command.ts | 2 +- src/extension/hooks/session.ts | 2 +- src/extension/index.ts | 8 ++- src/extension/scorer.ts | 2 +- src/tools/index.ts | 2 +- src/tools/kb-add.ts | 2 +- src/tools/kb-list.ts | 2 +- src/tools/kb-load.ts | 2 +- src/tools/kb-search.ts | 2 +- src/tools/kb-verify.ts | 2 +- tests/daemon/rpc-store.test.ts | 11 +++ tests/daemon/store-selection.test.ts | 100 +++++++++++++++++++++++++++ 17 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 src/daemon/store-selection.ts create mode 100644 tests/daemon/store-selection.test.ts diff --git a/src/core/types.ts b/src/core/types.ts index 27acb5f..3546371 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -163,7 +163,7 @@ export interface KnowledgeStore { promoteEntry(area: string, id: string): void; archiveEntry(area: string, id: string): void; loadArea(area: string, filter?: EntryFilter): KnowledgeEntry[]; - search(query: string): KnowledgeEntry[]; + search(query: string, excludeZone?: Zone): KnowledgeEntry[]; matchAreas(text: string): { area: string; score: number }[]; compact(area?: string): void; } diff --git a/src/daemon/dispatch.ts b/src/daemon/dispatch.ts index aa76970..1ba96fb 100644 --- a/src/daemon/dispatch.ts +++ b/src/daemon/dispatch.ts @@ -40,6 +40,7 @@ import { ArtifactNotFoundError, } from '../core/errors.js'; import { DaemonError } from './errors.js'; +import type { Zone } from '../core/types.js'; import type { Capability, ConnContext } from './capability.js'; const PROTOCOL = 1; @@ -361,7 +362,12 @@ export class Dispatcher { }); return { entry }; }), - search: A((p) => ({ entries: this.store().search(str(p, 'query')) })), + search: A((p) => ({ + entries: this.store().search( + str(p, 'query'), + optStr(p, 'exclude_zone') as Zone | undefined, + ), + })), match_areas: A((p) => ({ matches: this.store().matchAreas(str(p, 'text')) })), // --- workspace (§5.4) --- diff --git a/src/daemon/rpc-store.ts b/src/daemon/rpc-store.ts index 6b8f6c7..d40c2cb 100644 --- a/src/daemon/rpc-store.ts +++ b/src/daemon/rpc-store.ts @@ -20,6 +20,7 @@ import type { AddPatternOptions, AddLinkOptions, EntryFilter, + Zone, } from '../core/types.js'; import { EntryNotFoundError, @@ -117,8 +118,12 @@ export class RpcKnowledgeStore implements KnowledgeStore { loadArea(area: string, filter?: EntryFilter): KnowledgeEntry[] { return (this.call('load_area', { area, filter }) as { entries: KnowledgeEntry[] }).entries; } - search(query: string): KnowledgeEntry[] { - return (this.call('search', { query }) as { entries: KnowledgeEntry[] }).entries; + search(query: string, excludeZone?: Zone): KnowledgeEntry[] { + return ( + this.call('search', { query, exclude_zone: excludeZone }) as { + entries: KnowledgeEntry[]; + } + ).entries; } matchAreas(text: string): { area: string; score: number }[] { return ( diff --git a/src/daemon/store-selection.ts b/src/daemon/store-selection.ts new file mode 100644 index 0000000..80d98fc --- /dev/null +++ b/src/daemon/store-selection.ts @@ -0,0 +1,54 @@ +/** + * Store selection — `docs/v2-protocol-contract-DESIGN.md` §2.4 + §Dev-mode. + * + * Resolves which `KnowledgeStore` implementation a client should use: + * + * - `MYKB_STORE=local` → force the local `MykbStore` (bypass-by-design, + * trusted operator / tests — parent DESIGN §Dev-mode). + * - `MYKB_STORE=rpc` → force `RpcKnowledgeStore` (default socket path). + * - otherwise auto-detect: if the daemon socket is present, use it + * (§2.4 "daemon socket present → use it"); else local. + * + * Socket path = `MYKB_SOCKET` if set, else `/.mykbd.sock` + * (mirrors `daemon/main.ts`). Selection is a startup-time decision; the + * chosen `mode` is returned so the caller can log it. + * + * Both implementations satisfy the same `KnowledgeStore` interface (LSP), + * so the swap is invisible to extension hooks and CLI subcommands — the + * payoff of the sync-bridge decision. + */ + +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore } from '../core/types.js'; +import { RpcKnowledgeStore } from './rpc-store.js'; + +export type StoreMode = 'local' | 'rpc'; + +export interface SelectedStore { + store: KnowledgeStore; + mode: StoreMode; + socketPath?: string; +} + +function socketPathFor(brainPath: string): string { + return process.env.MYKB_SOCKET ?? path.join(brainPath, '.mykbd.sock'); +} + +export function selectKnowledgeStore(brainPath: string): SelectedStore { + const forced = process.env.MYKB_STORE; + const sock = socketPathFor(brainPath); + + if (forced === 'local') { + return { store: MykbStore.open(brainPath), mode: 'local' }; + } + if (forced === 'rpc') { + return { store: new RpcKnowledgeStore(sock), mode: 'rpc', socketPath: sock }; + } + // Auto-detect: presence of the socket is the signal (§2.4). + if (fs.existsSync(sock)) { + return { store: new RpcKnowledgeStore(sock), mode: 'rpc', socketPath: sock }; + } + return { store: MykbStore.open(brainPath), mode: 'local' }; +} diff --git a/src/extension/hooks/context.ts b/src/extension/hooks/context.ts index dbff346..1d69c55 100644 --- a/src/extension/hooks/context.ts +++ b/src/extension/hooks/context.ts @@ -1,4 +1,4 @@ -import type { MykbStore } from '../../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../../core/types.js'; import type { SessionState } from '../state.js'; import type { AreaMetadata } from '../../core/types.js'; import { readManifest } from '../../core/manifest.js'; diff --git a/src/extension/hooks/kb-command.ts b/src/extension/hooks/kb-command.ts index fdb192a..c26288d 100644 --- a/src/extension/hooks/kb-command.ts +++ b/src/extension/hooks/kb-command.ts @@ -1,5 +1,5 @@ import type { CommandHandler, ExtensionAPI } from '../pi-types.js'; -import type { MykbStore } from '../../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../../core/types.js'; import type { SessionState } from '../state.js'; import { type KnowledgeEntry, Zone } from '../../core/types.js'; import { renderMarkdown } from '../../core/render.js'; diff --git a/src/extension/hooks/session.ts b/src/extension/hooks/session.ts index 7841af8..87db30e 100644 --- a/src/extension/hooks/session.ts +++ b/src/extension/hooks/session.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI } from '../pi-types.js'; import type { BeforeAgentStartResult } from '../pi-types.js'; -import type { MykbStore } from '../../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../../core/types.js'; import type { SessionState } from '../state.js'; import { initBrain } from '../../core/init.js'; import { isDirtyShutdown, recoverDirtyShutdown } from '../../core/init.js'; diff --git a/src/extension/index.ts b/src/extension/index.ts index 92519f5..00b7302 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -1,8 +1,8 @@ import type { ExtensionAPI } from './pi-types.js'; import { resolveBrainPath, brainExists } from '../core/config.js'; import { initBrain } from '../core/init.js'; -import { MykbStore } from '../core/knowledge-store.js'; import { FileSystemWorkspaceStorage } from '../core/workspace.js'; +import { selectKnowledgeStore } from '../daemon/store-selection.js'; import { SessionState } from './state.js'; import { registerSessionHooks } from './hooks/session.js'; import { registerTools } from '../tools/index.js'; @@ -23,7 +23,11 @@ export default function (pi: ExtensionAPI): void { initBrain(brainPath); } - const store = MykbStore.open(brainPath); + // v2: route through the daemon when its socket is bind-mounted in + // (container topology, contract §2.4); fall back to the in-process + // local store otherwise (host operator / no daemon). Both satisfy the + // KnowledgeStore interface so every hook below is unchanged (LSP). + const { store } = selectKnowledgeStore(brainPath); // SessionState.create() with KB_SESSION_ID enables persistence across // separate Pi container invocations that share the same session id — // signals seeded by a prior turn's input/tool events become visible diff --git a/src/extension/scorer.ts b/src/extension/scorer.ts index 5c33826..38300a2 100644 --- a/src/extension/scorer.ts +++ b/src/extension/scorer.ts @@ -1,5 +1,5 @@ import { type AreaMetadata, type KnowledgeEntry, Zone } from '../core/types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import type { Signal } from './state.js'; export type ScoreResult = { diff --git a/src/tools/index.ts b/src/tools/index.ts index 8dfc67a..3cde7d2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import type { WorkspaceStorage } from '../core/types.js'; import { registerKbAdd } from './kb-add.js'; import { registerKbSearch } from './kb-search.js'; diff --git a/src/tools/kb-add.ts b/src/tools/kb-add.ts index 8608069..c649b2b 100644 --- a/src/tools/kb-add.ts +++ b/src/tools/kb-add.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ToolResult } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import type { EntryType } from '../core/types.js'; import { ProvenanceStatus, Zone } from '../core/types.js'; diff --git a/src/tools/kb-list.ts b/src/tools/kb-list.ts index 907c585..bb2b1f2 100644 --- a/src/tools/kb-list.ts +++ b/src/tools/kb-list.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ToolResult } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import { listAreas } from '../core/area.js'; import { renderAreaIndex } from '../core/render.js'; diff --git a/src/tools/kb-load.ts b/src/tools/kb-load.ts index 599dfaa..88f477f 100644 --- a/src/tools/kb-load.ts +++ b/src/tools/kb-load.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ToolResult } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import type { EntryFilter } from '../core/types.js'; import { Zone } from '../core/types.js'; import { renderMarkdown } from '../core/render.js'; diff --git a/src/tools/kb-search.ts b/src/tools/kb-search.ts index 2c7232b..cb69cfb 100644 --- a/src/tools/kb-search.ts +++ b/src/tools/kb-search.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ToolResult } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; import { Zone } from '../core/types.js'; import { renderMarkdown } from '../core/render.js'; diff --git a/src/tools/kb-verify.ts b/src/tools/kb-verify.ts index d26ad7a..2871d93 100644 --- a/src/tools/kb-verify.ts +++ b/src/tools/kb-verify.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ToolResult } from '../extension/pi-types.js'; -import type { MykbStore } from '../core/knowledge-store.js'; +import type { KnowledgeStore as MykbStore } from '../core/types.js'; type KbVerifyParams = { area: string; diff --git a/tests/daemon/rpc-store.test.ts b/tests/daemon/rpc-store.test.ts index 08f9633..7268690 100644 --- a/tests/daemon/rpc-store.test.ts +++ b/tests/daemon/rpc-store.test.ts @@ -9,6 +9,7 @@ import { createArea } from '../../src/core/area.js'; import { MykbStore } from '../../src/core/knowledge-store.js'; import { RpcKnowledgeStore } from '../../src/daemon/rpc-store.js'; import { EntryNotFoundError } from '../../src/core/errors.js'; +import { Zone } from '../../src/core/types.js'; // Phase 3 — RpcKnowledgeStore is the client-side Adapter behind the // EXISTING (synchronous) KnowledgeStore interface (decision: sync via a @@ -112,6 +113,16 @@ describe('RpcKnowledgeStore — synchronous adapter over the wire', () => { expect(() => store.updateEntry(a, 'no-such-id', { text: 'x' })).toThrow(EntryNotFoundError); }); + it('search honours the excludeZone arg over the wire (§5.3 exclude_zone)', () => { + const a = area('zone'); + const keep = store.addFact(a, 'visible active fact'); + const arch = store.addFact(a, 'old archived fact'); + store.archiveEntry(a, arch); + const ids = store.search('fact', Zone.Archive).map((e) => e.id); + expect(ids).toContain(keep); + expect(ids).not.toContain(arch); + }); + it('lifecycle verbs round-trip (verify/promote/archive/delete)', () => { const a = area('life'); const id = store.addFact(a, 'ephemeral'); diff --git a/tests/daemon/store-selection.test.ts b/tests/daemon/store-selection.test.ts new file mode 100644 index 0000000..09d8943 --- /dev/null +++ b/tests/daemon/store-selection.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { withTempBrain } from '../helpers.js'; +import { initBrain } from '../../src/core/init.js'; +import { MykbStore } from '../../src/core/knowledge-store.js'; +import { RpcKnowledgeStore } from '../../src/daemon/rpc-store.js'; +import { selectKnowledgeStore } from '../../src/daemon/store-selection.js'; + +// Phase 4 — store selection (contract §2.4 "daemon socket present → use +// it"; §Dev-mode env override MYKB_STORE=rpc|local). The extension picks +// RpcKnowledgeStore when the daemon socket is bind-mounted in, else the +// local MykbStore — both behind the same KnowledgeStore interface. + +const saved = { ...process.env }; +afterEach(() => { + process.env = { ...saved }; +}); + +describe('selectKnowledgeStore', () => { + it('returns the local MykbStore when no daemon socket is present', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + delete process.env.MYKB_SOCKET; + delete process.env.MYKB_STORE; + const { store, mode } = selectKnowledgeStore(bp); + try { + expect(mode).toBe('local'); + expect(store).toBeInstanceOf(MykbStore); + } finally { + (store as MykbStore).close?.(); + } + }); + }); + + it('returns RpcKnowledgeStore when a daemon socket file is present', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + const sock = path.join(bp, '.mykbd.sock'); + fs.writeFileSync(sock, ''); // presence is the signal (§2.4) + delete process.env.MYKB_STORE; + const { store, mode } = selectKnowledgeStore(bp); + try { + expect(mode).toBe('rpc'); + expect(store).toBeInstanceOf(RpcKnowledgeStore); + } finally { + (store as RpcKnowledgeStore).close(); + } + }); + }); + + it('honours MYKB_SOCKET as the explicit socket path', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sel-')); + const sock = path.join(dir, 'custom.sock'); + fs.writeFileSync(sock, ''); + process.env.MYKB_SOCKET = sock; + delete process.env.MYKB_STORE; + const { store, mode } = selectKnowledgeStore(bp); + try { + expect(mode).toBe('rpc'); + expect(store).toBeInstanceOf(RpcKnowledgeStore); + } finally { + (store as RpcKnowledgeStore).close(); + } + }); + }); + + it('MYKB_STORE=local forces local even if a socket is present', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + fs.writeFileSync(path.join(bp, '.mykbd.sock'), ''); + process.env.MYKB_STORE = 'local'; + const { store, mode } = selectKnowledgeStore(bp); + try { + expect(mode).toBe('local'); + expect(store).toBeInstanceOf(MykbStore); + } finally { + (store as MykbStore).close?.(); + } + }); + }); + + it('MYKB_STORE=rpc forces rpc using the default socket path', async () => { + await withTempBrain(async (bp) => { + initBrain(bp); + process.env.MYKB_STORE = 'rpc'; + delete process.env.MYKB_SOCKET; + const { store, mode } = selectKnowledgeStore(bp); + try { + expect(mode).toBe('rpc'); + expect(store).toBeInstanceOf(RpcKnowledgeStore); + } finally { + (store as RpcKnowledgeStore).close(); + } + }); + }); +});