Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion src/daemon/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) ---
Expand Down
9 changes: 7 additions & 2 deletions src/daemon/rpc-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
AddPatternOptions,
AddLinkOptions,
EntryFilter,
Zone,
} from '../core/types.js';
import {
EntryNotFoundError,
Expand Down Expand Up @@ -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 (
Expand Down
54 changes: 54 additions & 0 deletions src/daemon/store-selection.ts
Original file line number Diff line number Diff line change
@@ -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 `<brainPath>/.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' };
}
2 changes: 1 addition & 1 deletion src/extension/hooks/context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/extension/hooks/kb-command.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/extension/hooks/session.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
8 changes: 6 additions & 2 deletions src/extension/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/extension/scorer.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/tools/kb-add.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/tools/kb-list.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/tools/kb-load.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/tools/kb-search.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/tools/kb-verify.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
11 changes: 11 additions & 0 deletions tests/daemon/rpc-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
100 changes: 100 additions & 0 deletions tests/daemon/store-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
});