diff --git a/CLAUDE.md b/CLAUDE.md index cf7968c..3fab793 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,10 @@ resurrect patterns from it. - `src/lib/` - origins (one FQDN -> service URLs), config (`~/.agentage`, 0600 auth.json), oauth (DCR + PKCE), callback-server (one-shot localhost), api (bearer + refresh-once), status-info +- `src/lib/memory-client.ts` - the memory-verb seam; DirectClient wraps `@agentage/memory-core` + (the one local engine: git-per-vault backends + federation router). No FTS5/SQLite. - `src/package-guard.test.ts` - CI guard: no daemon/agent-runtime remnants, runtime deps - stay exactly `chalk + commander + open` + stay exactly `@agentage/memory-core + chalk + commander + open` ## Auth model Fresh DCR public client per `setup` run (the redirect URI binds the ephemeral callback diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c1e951e..b7de347 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -41,13 +41,14 @@ export interface CliMachine { cleanup: () => void; } -export const createCliMachine = (): CliMachine => { +export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine => { const configDir = mkdtempSync(join(tmpdir(), 'agentage-cli-e2e-')); const env: NodeJS.ProcessEnv = { ...process.env, AGENTAGE_CONFIG_DIR: configDir, AGENTAGE_SITE_FQDN: TARGET_FQDN, NO_COLOR: '1', + ...extraEnv, }; const exec = (args: string[], timeoutMs = 30_000): Promise => diff --git a/e2e/m1-requirements.test.ts b/e2e/m1-requirements.test.ts index 60ffc03..22fdbe0 100644 --- a/e2e/m1-requirements.test.ts +++ b/e2e/m1-requirements.test.ts @@ -1,40 +1,24 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from '@playwright/test'; import { assertCliBuilt, createCliMachine } from './helpers.js'; -// Hardening tier: drives the built CLI against a temp config dir to cover every M1 -// acceptance criterion end to end. All offline except `update --check` (which the -// verb tolerates on any network state). @p0 +// Hardening tier: drives the built CLI against a temp config dir to cover the config + +// registry acceptance criteria end to end. All offline except `update --check`. @p0 const SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json'; -test.describe('M1 config + vault registry (offline) @p0', () => { +test.describe('config + vault registry (offline) @p0', () => { test.beforeAll(() => assertCliBuilt()); - test('add --git writes a valid git entry with the interval + $schema', async () => { + test('add --git writes a valid origin entry with the $schema link', async () => { const machine = createCliMachine(); try { - const path = join(machine.configDir, 'work'); - const res = await machine.exec([ - 'vault', - 'add', - 'work', - '--git', - 'git@github.com:me/w.git', - '--path', - path, - '--interval', - '10m', - ]); + const res = await machine.exec(['vault', 'add', 'work', '--git', 'git@github.com:me/w.git']); expect(res.code, res.stderr).toBe(0); const cfg = JSON.parse(readFileSync(join(machine.configDir, 'vaults.json'), 'utf-8')); expect(cfg.$schema).toBe(SCHEMA_URL); - expect(cfg.vaults[0]).toMatchObject({ - name: 'work', - type: 'git', - remote: 'git@github.com:me/w.git', - sync: { interval: '10m' }, - }); + expect(cfg.default).toBe('work'); + expect(cfg.vaults.work.origin[0].remote).toBe('git@github.com:me/w.git'); } finally { machine.cleanup(); } @@ -53,13 +37,12 @@ test.describe('M1 config + vault registry (offline) @p0', () => { test('a duplicate vault name is rejected', async () => { const machine = createCliMachine(); try { - await machine.exec(['vault', 'add', 'a', '--local', '--path', join(machine.configDir, 'a')]); + await machine.exec(['vault', 'add', 'a', '--local', join(machine.configDir, 'a')]); const res = await machine.exec([ 'vault', 'add', 'a', '--local', - '--path', join(machine.configDir, 'a2'), ]); expect(res.code).not.toBe(0); @@ -77,7 +60,6 @@ test.describe('M1 config + vault registry (offline) @p0', () => { 'add', 'bad name', '--local', - '--path', join(machine.configDir, 'x'), ]); expect(res.code).not.toBe(0); @@ -89,63 +71,37 @@ test.describe('M1 config + vault registry (offline) @p0', () => { test('an empty vaults.json still carries the $schema link', async () => { const machine = createCliMachine(); try { - await machine.exec(['vault', 'add', 'v', '--local', '--path', join(machine.configDir, 'v')]); + await machine.exec(['vault', 'add', 'v', '--local', join(machine.configDir, 'v')]); const rm = await machine.exec(['vault', 'remove', 'v']); expect(rm.code, rm.stderr).toBe(0); const cfg = JSON.parse(readFileSync(join(machine.configDir, 'vaults.json'), 'utf-8')); - expect(cfg).toMatchObject({ $schema: SCHEMA_URL, vaults: [] }); + expect(cfg.$schema).toBe(SCHEMA_URL); + expect(Object.keys(cfg.vaults)).toHaveLength(0); } finally { machine.cleanup(); } }); - test('remove keeps the markdown on disk and drops the index db', async () => { + test('remove keeps the markdown on disk', async () => { const machine = createCliMachine(); try { const vaultDir = join(machine.configDir, 'notes'); - await machine.exec(['vault', 'add', 'notes', '--local', '--path', vaultDir]); + await machine.exec(['vault', 'add', 'notes', '--local', vaultDir]); writeFileSync(join(vaultDir, 'note.md'), '# keep me'); - const indexDir = join(machine.configDir, 'index'); - mkdirSync(indexDir, { recursive: true }); - writeFileSync(join(indexDir, 'notes.db'), 'x'); - const rm = await machine.exec(['vault', 'remove', 'notes']); expect(rm.code, rm.stderr).toBe(0); expect(existsSync(join(vaultDir, 'note.md'))).toBe(true); - expect(existsSync(join(indexDir, 'notes.db'))).toBe(false); - } finally { - machine.cleanup(); - } - }); - - test('accepts a hand-written vaults.yaml, and JSON wins when both exist', async () => { - const machine = createCliMachine(); - try { - writeFileSync( - join(machine.configDir, 'vaults.yaml'), - 'version: 1\nvaults:\n - name: fromyaml\n type: local\n path: ~/y\n' - ); - const yamlList = await machine.exec(['vault', 'list', '--json']); - expect(yamlList.code, yamlList.stderr).toBe(0); - expect(JSON.parse(yamlList.stdout)[0]).toMatchObject({ name: 'fromyaml' }); - - writeFileSync( - join(machine.configDir, 'vaults.json'), - JSON.stringify({ version: 1, vaults: [{ name: 'fromjson', type: 'local', path: '~/j' }] }) - ); - const jsonList = await machine.exec(['vault', 'list', '--json']); - expect(JSON.parse(jsonList.stdout)[0]).toMatchObject({ name: 'fromjson' }); } finally { machine.cleanup(); } }); - test('a malformed config (unknown type) fails loudly', async () => { + test('a malformed config (entry with no path or origin) fails loudly', async () => { const machine = createCliMachine(); try { writeFileSync( join(machine.configDir, 'vaults.json'), - JSON.stringify({ version: 1, vaults: [{ name: 'x', type: 'ftp' }] }) + JSON.stringify({ version: 1, vaults: { x: {} } }) ); const res = await machine.exec(['vault', 'list']); expect(res.code).not.toBe(0); diff --git a/e2e/memory-offline.test.ts b/e2e/memory-offline.test.ts index 508c13b..46f1570 100644 --- a/e2e/memory-offline.test.ts +++ b/e2e/memory-offline.test.ts @@ -1,28 +1,41 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; import { expect, test } from '@playwright/test'; import { assertCliBuilt, createCliMachine } from './helpers.js'; -// The 6 memory verbs make zero network calls (DirectClient = fs + SQLite), so this is the -// full offline CRUD round trip against the built binary. @p0 +// The 6 memory verbs run entirely over @agentage/memory-core's local git backend (fs + git, +// zero network). Blackhole any egress via unroutable proxies to prove offline. @p0 +const BLACKHOLE = 'http://127.0.0.1:1'; +const OFFLINE = { + http_proxy: BLACKHOLE, + https_proxy: BLACKHOLE, + HTTP_PROXY: BLACKHOLE, + HTTPS_PROXY: BLACKHOLE, +}; + +interface TreeEntry { + type: 'file' | 'folder'; + path: string; + entries?: TreeEntry[]; +} + +const treeFiles = (entries: TreeEntry[]): string[] => + entries.flatMap((e) => (e.type === 'file' ? [e.path] : e.entries ? treeFiles(e.entries) : [])); + test.describe('offline memory CRUD @p0', () => { test.beforeAll(() => assertCliBuilt()); test('write -> search -> read -> edit -> list -> delete, no network', async () => { - const m = createCliMachine(); + const m = createCliMachine(OFFLINE); try { - const add = await m.exec([ - 'vault', - 'add', - 'main', - '--local', - '--path', - `${m.configDir}/main`, - ]); + const vaultDir = join(m.configDir, 'main'); + const add = await m.exec(['vault', 'add', 'main', '--local', vaultDir]); expect(add.code, add.stderr).toBe(0); const write = await m.exec([ 'memory', 'write', - '@main/notes/q.md', + 'notes/q.md', '--body', 'the quokka is a marsupial', ]); @@ -34,42 +47,44 @@ test.describe('offline memory CRUD @p0', () => { 'notes/q.md', ]); - const read = await m.exec(['memory', 'read', '@main/notes/q.md']); + const read = await m.exec(['memory', 'read', 'notes/q.md']); expect(read.stdout).toContain('marsupial'); const edit = await m.exec([ 'memory', 'edit', - '@main/notes/q.md', + 'notes/q.md', '--old', 'quokka', '--new', 'wombat', ]); expect(edit.code, edit.stderr).toBe(0); - expect((await m.exec(['memory', 'read', '@main/notes/q.md'])).stdout).toContain('wombat'); + expect((await m.exec(['memory', 'read', 'notes/q.md'])).stdout).toContain('wombat'); const list = await m.exec(['memory', 'list', '--json']); - expect(JSON.parse(list.stdout).entries.map((e: { path: string }) => e.path)).toEqual([ - 'notes/q.md', - ]); + expect(treeFiles(JSON.parse(list.stdout).entries)).toEqual(['notes/q.md']); - const del = await m.exec(['memory', 'delete', '@main/notes/q.md']); + const del = await m.exec(['memory', 'delete', 'notes/q.md']); expect(del.code, del.stderr).toBe(0); - expect(JSON.parse((await m.exec(['memory', 'list', '--json'])).stdout).entries).toHaveLength( - 0 - ); + const after = await m.exec(['memory', 'list', '--json']); + expect(treeFiles(JSON.parse(after.stdout).entries)).toHaveLength(0); + + // git-backed store: the vault folder is a repo carrying a commit per mutation + // (write + edit + delete = 3). + const log = execFileSync('git', ['-C', vaultDir, 'log', '--oneline'], { encoding: 'utf-8' }); + expect(log.trim().split('\n').length).toBeGreaterThanOrEqual(3); } finally { m.cleanup(); } }); test('a duplicate str_replace target reports the canonical error', async () => { - const m = createCliMachine(); + const m = createCliMachine(OFFLINE); try { - await m.exec(['vault', 'add', 'main', '--local', '--path', `${m.configDir}/main`]); - await m.exec(['memory', 'write', '@main/d.md', '--body', 'x x']); - const res = await m.exec(['memory', 'edit', '@main/d.md', '--old', 'x', '--new', 'y']); + await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]); + await m.exec(['memory', 'write', 'd.md', '--body', 'x x']); + const res = await m.exec(['memory', 'edit', 'd.md', '--old', 'x', '--new', 'y']); expect(res.code).not.toBe(0); expect(res.stderr + res.stdout).toContain('Multiple occurrences'); } finally { diff --git a/e2e/vault-offline.test.ts b/e2e/vault-offline.test.ts index 79e40f8..2ecec52 100644 --- a/e2e/vault-offline.test.ts +++ b/e2e/vault-offline.test.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { expect, test } from '@playwright/test'; import { assertCliBuilt, createCliMachine } from './helpers.js'; -// The offline registry: add/list/remove work with no network and no auth. @p0 +// The offline registry on the unified vaults.json: add/list/remove work with no network. @p0 test.describe('offline vault registry @p0', () => { test.beforeAll(() => assertCliBuilt()); @@ -10,31 +10,31 @@ test.describe('offline vault registry @p0', () => { const machine = createCliMachine(); try { const path = join(machine.configDir, 'scratch'); - const added = await machine.exec(['vault', 'add', 'scratch', '--local', '--path', path]); + const added = await machine.exec(['vault', 'add', 'scratch', '--local', path]); expect(added.code, added.stderr).toBe(0); const listed = await machine.exec(['vault', 'list', '--json']); expect(listed.code, listed.stderr).toBe(0); - const vaults = JSON.parse(listed.stdout) as Array<{ name: string; type: string }>; - expect(vaults).toHaveLength(1); - expect(vaults[0]).toMatchObject({ name: 'scratch', type: 'local' }); + const vaults = JSON.parse(listed.stdout) as Record; + expect(Object.keys(vaults)).toEqual(['scratch']); + expect(vaults.scratch!.path).toBe(path); const removed = await machine.exec(['vault', 'remove', 'scratch']); expect(removed.code, removed.stderr).toBe(0); const after = await machine.exec(['vault', 'list', '--json']); - expect(JSON.parse(after.stdout)).toHaveLength(0); + expect(Object.keys(JSON.parse(after.stdout))).toHaveLength(0); } finally { machine.cleanup(); } }); - test('vault add without --local/--git fails clearly (needs provisioning)', async () => { + test('vault add without --local/--git fails clearly', async () => { const machine = createCliMachine(); try { const res = await machine.exec(['vault', 'add', 'acct']); expect(res.code).not.toBe(0); - expect(res.stderr + res.stdout).toContain('provisioning'); + expect(res.stderr + res.stdout).toMatch(/--local|--git/); } finally { machine.cleanup(); } diff --git a/package-lock.json b/package-lock.json index 226dcb5..3e3b179 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,10 @@ "version": "0.0.3", "license": "MIT", "dependencies": { + "@agentage/memory-core": "^0.1.1", "chalk": "latest", "commander": "latest", - "open": "latest", - "yaml": "^2.9.0", - "zod": "^4.4.3" + "open": "latest" }, "bin": { "agentage": "dist/cli.js" @@ -37,6 +36,20 @@ "npm": ">=10.0.0" } }, + "node_modules/@agentage/memory-core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@agentage/memory-core/-/memory-core-0.1.1.tgz", + "integrity": "sha512-e4NMCWIjKoZcwDaemLqSmd02/VgDBEVrRz2/0t9T94O3ERlZhhUjOn0+Ya193YLyjfquRNdVMkXpKHV2k7SE6w==", + "license": "MIT", + "dependencies": { + "yaml": "^2.7.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.110.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", diff --git a/package.json b/package.json index a779197..98b98ab 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,10 @@ "prepublishOnly": "npm run verify" }, "dependencies": { + "@agentage/memory-core": "^0.1.1", "chalk": "latest", "commander": "latest", - "open": "latest", - "yaml": "^2.9.0", - "zod": "^4.4.3" + "open": "latest" }, "devDependencies": { "@anthropic-ai/sdk": "0.110.0", diff --git a/src/cli.ts b/src/cli.ts index d458b17..27c8e4f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; import { Command } from 'commander'; import { registerMemory } from './commands/memory.js'; import { registerSetup } from './commands/setup.js'; @@ -10,44 +8,6 @@ import { registerUpdate } from './commands/update.js'; import { registerVault } from './commands/vault.js'; import { VERSION } from './utils/version.js'; -// Silence Node's "SQLite is an experimental feature" warning: it prints on every index -// command and the user cannot act on it. -const hushSqliteWarning = (): void => { - const emit = process.emitWarning.bind(process); - process.emitWarning = ((warning: string | Error, ...rest: unknown[]): void => { - const message = typeof warning === 'string' ? warning : warning.message; - if (message.includes('SQLite is an experimental feature')) return; - (emit as (w: string | Error, ...r: unknown[]) => void)(warning, ...rest); - }) as typeof process.emitWarning; -}; - -// The vault index uses node:sqlite, which is behind --experimental-sqlite on Node < 23.4. -// For index-touching commands, re-exec once with the flag when the module can't load, so -// the CLI works across the whole Node >= 22 range. Non-index commands never pay for this. -// (Command modules load node:sqlite lazily, so importing them above stays flag-free.) -const bootstrapSqlite = (): void => { - if (!process.argv.slice(2).some((a) => a === 'memory' || a === 'reindex')) return; - hushSqliteWarning(); // install before requiring node:sqlite - the warning fires at load time - const req = createRequire(import.meta.url); - const loadable = ((): boolean => { - try { - req('node:sqlite'); - return true; - } catch { - return false; - } - })(); - if (loadable) return; - if (process.execArgv.includes('--experimental-sqlite')) return; - const result = spawnSync( - process.execPath, - ['--experimental-sqlite', process.argv[1] as string, ...process.argv.slice(2)], - { stdio: 'inherit' } - ); - process.exit(result.status ?? 1); -}; -bootstrapSqlite(); - const program = new Command(); program.name('agentage').description('The agentage CLI').version(VERSION); diff --git a/src/commands/memory.test.ts b/src/commands/memory.test.ts index 9bcbf7d..05e9c74 100644 --- a/src/commands/memory.test.ts +++ b/src/commands/memory.test.ts @@ -3,26 +3,34 @@ import type { MemoryClient } from '../lib/memory-client.js'; import { runDelete, runEdit, runList, runRead, runSearch, runWrite } from './memory.js'; const client = (): MemoryClient => ({ - search: vi.fn(async () => ({ vault: 'v', results: [{ path: 'a.md', snippet: 's', score: -1 }] })), + search: vi.fn(async () => ({ + results: [{ path: 'a.md', title: 'A', snippet: 's', score: 2, updated: 'now' }], + })), read: vi.fn(async () => ({ - vault: 'v', path: 'a.md', title: 'A', frontmatter: {}, body: 'hi', tags: [], updated: 'now', - size: 2, - truncated: false, + deleted: false, })), - write: vi.fn(async () => ({ vault: 'v', path: 'a.md', bytesWritten: 5 })), - edit: vi.fn(async () => ({ vault: 'v', path: 'a.md', bytesWritten: 3 })), + write: vi.fn(async () => ({ path: 'a.md', rev: 'sha', updated: 'now' })), + edit: vi.fn(async () => ({ path: 'a.md', rev: 'sha', updated: 'now' })), list: vi.fn(async () => ({ - vault: 'v', folder: '', - entries: [{ path: 'a.md', updated: 'now' }], + entries: [ + { + type: 'folder' as const, + path: 'notes', + files: 1, + entries: [{ type: 'file' as const, path: 'notes/a.md', title: 'a', updated: 'now' }], + }, + ], + truncated: false, + files: 1, })), - delete: vi.fn(async () => ({ vault: 'v', path: 'a.md', trashedTo: '.trash/a.md' })), + delete: vi.fn(async () => ({ path: 'a.md', deleted: true })), }); let logs: string[]; @@ -41,10 +49,19 @@ describe('memory command wiring', () => { expect(logs.join()).toContain('a.md'); }); - it('search --json emits JSON', async () => { + it('search --json emits the SearchResult', async () => { const c = client(); await runSearch('q', { json: true }, c); - expect(JSON.parse(logs[0] ?? '{}')).toMatchObject({ vault: 'v' }); + expect(JSON.parse(logs[0] ?? '{}').results[0].path).toBe('a.md'); + }); + + it('read prints the body; --json emits the MemoryView', async () => { + const c = client(); + await runRead('a.md', {}, c); + expect(logs.join()).toContain('hi'); + logs.length = 0; + await runRead('a.md', { json: true }, c); + expect(JSON.parse(logs[0] ?? '{}')).toMatchObject({ path: 'a.md', deleted: false }); }); it('write passes an inline --body and parsed --frontmatter', async () => { @@ -54,6 +71,7 @@ describe('memory command wiring', () => { vault: undefined, frontmatter: { title: 'T' }, }); + expect(logs.join()).toContain('Wrote a.md'); }); it('edit maps --old/--new to a str_replace op', async () => { @@ -61,7 +79,7 @@ describe('memory command wiring', () => { await runEdit('a.md', { old: 'x', new: 'y' }, c); expect(c.edit).toHaveBeenCalledWith( 'a.md', - { oldStr: 'x', newStr: 'y', body: undefined, mode: 'replace' }, + { mode: 'str_replace', old_str: 'x', new_str: 'y' }, { vault: undefined } ); }); @@ -71,34 +89,25 @@ describe('memory command wiring', () => { await runEdit('a.md', { body: 'more', append: true }, c); expect(c.edit).toHaveBeenCalledWith( 'a.md', - { oldStr: undefined, newStr: undefined, body: 'more', mode: 'append' }, + { mode: 'append', body: 'more' }, { vault: undefined } ); }); - it('read warns when the output was truncated', async () => { + it('list flattens the tree to file paths; --json emits the tree', async () => { const c = client(); - c.read = vi.fn(async () => ({ - vault: 'v', - path: 'a.md', - title: 'A', - frontmatter: {}, - body: 'big', - tags: [], - updated: 'now', - size: 3, - truncated: true, - })); - await runRead('a.md', {}, c); - expect(logs.join()).toContain('truncated'); + await runList('notes', { vault: 'v' }, c); + expect(c.list).toHaveBeenCalledWith('notes', { vault: 'v' }); + expect(logs.join()).toContain('notes/a.md'); + logs.length = 0; + await runList(undefined, { json: true }, c); + expect(JSON.parse(logs[0] ?? '{}').entries[0].type).toBe('folder'); }); - it('list and delete call through to the client', async () => { + it('delete calls through and reports git recovery', async () => { const c = client(); - await runList('notes', { vault: 'v' }, c); - expect(c.list).toHaveBeenCalledWith('notes', { vault: 'v' }); await runDelete('a.md', {}, c); expect(c.delete).toHaveBeenCalledWith('a.md', { vault: undefined }); - expect(logs.join()).toContain('.trash/a.md'); + expect(logs.join()).toContain('git history'); }); }); diff --git a/src/commands/memory.ts b/src/commands/memory.ts index 653a20a..c767486 100644 --- a/src/commands/memory.ts +++ b/src/commands/memory.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { type Command } from 'commander'; +import { type TreeEntry } from '@agentage/memory-core'; import { createDirectClient, type MemoryClient } from '../lib/memory-client.js'; import { loadVaultsConfig } from '../lib/vaults.js'; @@ -16,6 +17,10 @@ const emit = (json: boolean, data: unknown, human: () => void): void => { else human(); }; +// Flatten a folder-tree to the file paths it contains, in tree order. +const treeFiles = (entries: TreeEntry[]): string[] => + entries.flatMap((e) => (e.type === 'file' ? [e.path] : e.entries ? treeFiles(e.entries) : [])); + interface CommonOpts { vault?: string; json?: boolean; @@ -42,10 +47,7 @@ export const runRead = async ( client: MemoryClient = defaultClient() ): Promise => { const doc = await client.read(ref, { vault: opts.vault }); - emit(opts.json ?? false, doc, () => { - console.log(doc.body); - if (doc.truncated) console.error(chalk.yellow('(output truncated)')); - }); + emit(opts.json ?? false, doc, () => console.log(doc.body)); }; export const runWrite = async ( @@ -58,9 +60,7 @@ export const runWrite = async ( ? (JSON.parse(opts.frontmatter) as Record) : undefined; const out = await client.write(ref, body, { vault: opts.vault, frontmatter }); - emit(opts.json ?? false, out, () => - console.log(chalk.green(`Wrote @${out.vault}/${out.path} (${out.bytesWritten} bytes)`)) - ); + emit(opts.json ?? false, out, () => console.log(chalk.green(`Wrote ${out.path}`))); }; export const runEdit = async ( @@ -68,17 +68,12 @@ export const runEdit = async ( opts: CommonOpts & { old?: string; new?: string; body?: string; append?: boolean }, client: MemoryClient = defaultClient() ): Promise => { - const out = await client.edit( - ref, - { - oldStr: opts.old, - newStr: opts.new, - body: opts.body, - mode: opts.append ? 'append' : 'replace', - }, - { vault: opts.vault } - ); - emit(opts.json ?? false, out, () => console.log(chalk.green(`Edited @${out.vault}/${out.path}`))); + const op = + opts.old !== undefined + ? { mode: 'str_replace' as const, old_str: opts.old, new_str: opts.new ?? '' } + : { mode: opts.append ? ('append' as const) : ('replace' as const), body: opts.body }; + const out = await client.edit(ref, op, { vault: opts.vault }); + emit(opts.json ?? false, out, () => console.log(chalk.green(`Edited ${out.path}`))); }; export const runList = async ( @@ -88,8 +83,9 @@ export const runList = async ( ): Promise => { const out = await client.list(folder, { vault: opts.vault }); emit(opts.json ?? false, out, () => { - if (out.entries.length === 0) console.log('No documents.'); - for (const e of out.entries) console.log(e.path); + const files = treeFiles(out.entries); + if (files.length === 0) console.log('No documents.'); + for (const p of files) console.log(p); }); }; @@ -100,7 +96,7 @@ export const runDelete = async ( ): Promise => { const out = await client.delete(ref, { vault: opts.vault }); emit(opts.json ?? false, out, () => - console.log(`Deleted @${out.vault}/${out.path} (recoverable in ${out.trashedTo})`) + console.log(`Deleted ${out.path} (recoverable from git history)`) ); }; @@ -115,7 +111,7 @@ export const registerMemory = (program: Command): void => { memory .command('search ') - .description('Full-text search a vault (FTS5)') + .description('Search a vault (git grep)') .option('--vault ', 'target vault') .option('--limit ', 'max hits (default 20)') .option('--json', 'machine-readable output') @@ -166,7 +162,7 @@ export const registerMemory = (program: Command): void => { memory .command('delete ') - .description('Soft-delete a document (moved to .trash/, recoverable)') + .description('Delete a document (recoverable from git history)') .option('--vault ', 'target vault') .option('--json', 'machine-readable output') .action((ref: string, opts: CommonOpts) => guard(() => runDelete(ref, opts))); diff --git a/src/commands/reindex.test.ts b/src/commands/reindex.test.ts deleted file mode 100644 index 1b65e7f..0000000 --- a/src/commands/reindex.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { VaultsConfig } from '../lib/vaults.schema.js'; -import { runReindex, type ReindexDeps } from './reindex.js'; - -const cfg = (names: string[]): VaultsConfig => - VaultsConfig.parse({ - version: 1, - vaults: names.map((n) => ({ name: n, type: 'local', path: `~/vaults/${n}` })), - }); - -const makeDeps = (config: VaultsConfig) => { - const logs: string[] = []; - const reindex = vi.fn(async () => ({ added: 1, modified: 0, removed: 0, unchanged: 2 })); - const deps: ReindexDeps = { - load: () => ({ config, source: null }), - reindex, - log: (m) => logs.push(m), - }; - return { deps, logs, reindex }; -}; - -describe('runReindex', () => { - it('reindexes every vault when no name is given', async () => { - const h = makeDeps(cfg(['a', 'b'])); - await runReindex(undefined, h.deps); - expect(h.reindex).toHaveBeenCalledTimes(2); - expect(h.logs.join()).toContain("Reindexed 'a'"); - }); - - it('reindexes only the named vault, passing its path', async () => { - const h = makeDeps(cfg(['a', 'b'])); - await runReindex('b', h.deps); - expect(h.reindex).toHaveBeenCalledTimes(1); - expect(h.reindex).toHaveBeenCalledWith('b', '~/vaults/b'); - }); - - it('throws when the named vault is unknown', async () => { - const h = makeDeps(cfg(['a'])); - await expect(runReindex('nope', h.deps)).rejects.toThrow(/not found/); - }); - - it('reports when there are no vaults', async () => { - const h = makeDeps(cfg([])); - await runReindex(undefined, h.deps); - expect(h.logs.join()).toContain('No vaults'); - expect(h.reindex).not.toHaveBeenCalled(); - }); -}); diff --git a/src/commands/reindex.ts b/src/commands/reindex.ts deleted file mode 100644 index 8ce568b..0000000 --- a/src/commands/reindex.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { reindexNamedVault, type ReindexStats } from '../lib/vault-scan.js'; -import { loadVaultsConfig, type LoadedVaults } from '../lib/vaults.js'; - -export interface ReindexDeps { - load: () => LoadedVaults; - reindex: (name: string, vaultPath: string) => Promise; - log: (msg: string) => void; -} - -const defaultDeps: ReindexDeps = { - load: loadVaultsConfig, - reindex: reindexNamedVault, - log: (msg) => console.log(msg), -}; - -// Rebuild one vault's index (or every vault's when name is omitted) from its markdown. -export const runReindex = async ( - name: string | undefined, - deps: ReindexDeps = defaultDeps -): Promise => { - const { vaults } = deps.load().config; - const targets = name ? vaults.filter((v) => v.name === name) : vaults; - if (name && targets.length === 0) throw new Error(`vault '${name}' not found`); - if (targets.length === 0) { - deps.log('No vaults to reindex.'); - return; - } - for (const v of targets) { - const s = await deps.reindex(v.name, v.path); - deps.log(`Reindexed '${v.name}': +${s.added} ~${s.modified} -${s.removed} (=${s.unchanged})`); - } -}; diff --git a/src/commands/vault.test.ts b/src/commands/vault.test.ts index 97d3955..f5f3cf6 100644 --- a/src/commands/vault.test.ts +++ b/src/commands/vault.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { VaultsConfig } from '../lib/vaults.schema.js'; +import type { VaultsConfig } from '@agentage/memory-core'; import { runVaultAdd, runVaultList, runVaultRemove, type VaultDeps } from './vault.js'; -const makeDeps = (initial: VaultsConfig = VaultsConfig.parse({ version: 1 })) => { +const makeDeps = (initial: VaultsConfig = { version: 1, vaults: {} }) => { let config = initial; const logs: string[] = []; const ensured: string[] = []; - const removedIndex: string[] = []; const deps: VaultDeps = { load: () => ({ config, source: null }), save: (c) => { @@ -14,47 +13,38 @@ const makeDeps = (initial: VaultsConfig = VaultsConfig.parse({ version: 1 })) => return '/tmp/vaults.json'; }, ensureDir: (p) => ensured.push(p), - removeIndex: (n) => removedIndex.push(n), log: (m) => logs.push(m), }; - return { deps, logs, ensured, removedIndex, get: () => config }; + return { deps, logs, ensured, get: () => config }; }; describe('vault add', () => { - it('registers a --local vault, creates its dir, saves', () => { + it('registers a --local vault, creates its dir, sets the default', () => { const h = makeDeps(); - runVaultAdd('scratch', { local: true, path: '/tmp/scratch' }, h.deps); - expect(h.get().vaults).toMatchObject([ - { name: 'scratch', type: 'local', path: '/tmp/scratch' }, - ]); + runVaultAdd('scratch', { local: '/tmp/scratch' }, h.deps); + expect(h.get().vaults?.scratch).toEqual({ path: '/tmp/scratch', mcp: ['local'] }); + expect(h.get().default).toBe('scratch'); expect(h.ensured).toEqual(['/tmp/scratch']); expect(h.logs.join()).toContain("Added vault 'scratch'"); }); - it('registers a --git vault with a remote + interval', () => { + it('defaults the --local path to ~/vaults/ when no value is given', () => { const h = makeDeps(); - runVaultAdd( - 'work', - { git: 'git@github.com:me/w.git', interval: '10m', path: '/tmp/w' }, - h.deps - ); - expect(h.get().vaults[0]).toMatchObject({ - type: 'git', - remote: 'git@github.com:me/w.git', - sync: { interval: '10m' }, - }); + runVaultAdd('notes', { local: true }, h.deps); + expect(h.get().vaults?.notes).toMatchObject({ path: '~/vaults/notes' }); + expect(h.ensured).toEqual(['~/vaults/notes']); }); - it('defaults the path to ~/vaults/', () => { + it('registers a --git vault as an origin (no dir created)', () => { const h = makeDeps(); - runVaultAdd('notes', { local: true }, h.deps); - expect(h.ensured).toEqual(['~/vaults/notes']); - expect(h.get().vaults[0]).toMatchObject({ path: '~/vaults/notes' }); + runVaultAdd('work', { git: 'git@github.com:me/w.git' }, h.deps); + expect(h.get().vaults?.work.origin?.[0]?.remote).toBe('git@github.com:me/w.git'); + expect(h.ensured).toEqual([]); }); - it('rejects the couchdb default (no flag) as needing provisioning', () => { + it('rejects add with no flag', () => { const h = makeDeps(); - expect(() => runVaultAdd('acct', {}, h.deps)).toThrow(/provisioning/); + expect(() => runVaultAdd('acct', {}, h.deps)).toThrow(/--local .* --git/); }); it('rejects --local and --git together', () => { @@ -64,20 +54,21 @@ describe('vault add', () => { it('rejects a duplicate name', () => { const h = makeDeps(); - runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps); - expect(() => runVaultAdd('a', { local: true, path: '/tmp/a2' }, h.deps)).toThrow( - /already exists/ - ); + runVaultAdd('a', { local: '/tmp/a' }, h.deps); + expect(() => runVaultAdd('a', { local: '/tmp/a2' }, h.deps)).toThrow(/already exists/); }); }); describe('vault remove', () => { - it('removes the vault, drops its index, keeps files', () => { + it('removes the vault and reassigns the default', () => { const h = makeDeps(); - runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps); + runVaultAdd('a', { local: '/tmp/a' }, h.deps); + runVaultAdd('b', { local: '/tmp/b' }, h.deps); + h.logs.length = 0; runVaultRemove('a', h.deps); - expect(h.get().vaults).toHaveLength(0); - expect(h.removedIndex).toEqual(['a']); + expect(Object.keys(h.get().vaults ?? {})).toEqual(['b']); + expect(h.get().default).toBe('b'); + expect(h.logs.join()).toContain("Default vault is now 'b'"); }); it('throws when the vault is absent', () => { @@ -93,20 +84,21 @@ describe('vault list', () => { expect(h.logs.join()).toContain('No vaults registered'); }); - it('prints one line per vault', () => { + it('prints one line per vault and marks the default', () => { const h = makeDeps(); - runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps); - runVaultAdd('b', { git: 'g@h:x.git', path: '/tmp/b' }, h.deps); + runVaultAdd('a', { local: '/tmp/a' }, h.deps); + runVaultAdd('b', { git: 'g@h:x.git' }, h.deps); h.logs.length = 0; runVaultList({}, h.deps); expect(h.logs).toHaveLength(2); + expect(h.logs[0]).toContain('(default)'); }); - it('emits JSON with --json', () => { + it('emits the vaults map with --json', () => { const h = makeDeps(); - runVaultAdd('a', { local: true, path: '/tmp/a' }, h.deps); + runVaultAdd('a', { local: '/tmp/a' }, h.deps); h.logs.length = 0; runVaultList({ json: true }, h.deps); - expect(JSON.parse(h.logs[0] ?? '[]')).toHaveLength(1); + expect(Object.keys(JSON.parse(h.logs[0] ?? '{}'))).toEqual(['a']); }); }); diff --git a/src/commands/vault.ts b/src/commands/vault.ts index fb49418..c74c704 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -1,21 +1,13 @@ import chalk from 'chalk'; import { type Command } from 'commander'; -import { - addVault, - ensureVaultDir, - formatVaultLine, - removeIndexDb, - removeVault, -} from '../lib/vault-registry.js'; +import { type VaultEntry, type VaultsConfig } from '@agentage/memory-core'; +import { addVault, ensureVaultDir, formatVaultLine, removeVault } from '../lib/vault-registry.js'; import { loadVaultsConfig, saveVaultsConfig, type LoadedVaults } from '../lib/vaults.js'; -import { type VaultsConfig } from '../lib/vaults.schema.js'; -import { runReindex } from './reindex.js'; export interface VaultDeps { load: () => LoadedVaults; save: (config: VaultsConfig) => string; ensureDir: (path: string) => void; - removeIndex: (name: string) => void; log: (msg: string) => void; } @@ -23,33 +15,24 @@ const defaultDeps: VaultDeps = { load: loadVaultsConfig, save: saveVaultsConfig, ensureDir: ensureVaultDir, - removeIndex: removeIndexDb, log: (msg) => console.log(msg), }; export interface VaultAddOptions { - local?: boolean; + // `--local [path]`: true when the flag is present without a value. + local?: string | boolean; git?: string; - path?: string; - interval?: string; } -// Builds the loosely-typed entry; addVault runs it through the schema (fills sync defaults). -const buildEntry = (name: string, opts: VaultAddOptions): object => { - if (opts.local && opts.git) throw new Error('choose one of --local or --git, not both'); - const path = opts.path ?? `~/vaults/${name}`; - if (opts.git) - return { - name, - path, - type: 'git', - remote: opts.git, - ...(opts.interval ? { sync: { interval: opts.interval } } : {}), - }; - if (opts.local) return { name, path, type: 'local' }; - throw new Error( - 'account (couchdb) vaults need provisioning - coming with the sync channel. For now use --local or --git .' - ); +const buildEntry = (name: string, opts: VaultAddOptions): VaultEntry => { + const hasLocal = opts.local !== undefined; + if (hasLocal && opts.git) throw new Error('choose one of --local or --git, not both'); + if (opts.git) return { origin: [{ remote: opts.git }], mcp: ['local'] }; + if (hasLocal) { + const path = typeof opts.local === 'string' ? opts.local : `~/vaults/${name}`; + return { path, mcp: ['local'] }; + } + throw new Error('a vault needs --local [path] (a local folder) or --git '); }; export const runVaultAdd = ( @@ -58,30 +41,39 @@ export const runVaultAdd = ( deps: VaultDeps = defaultDeps ): void => { const entry = buildEntry(name, opts); - const { config, vault } = addVault(deps.load().config, entry); - deps.ensureDir(vault.path); + const config = addVault(deps.load().config, name, entry); + if (entry.path) deps.ensureDir(entry.path); deps.save(config); - deps.log(chalk.green(`Added vault '${vault.name}' (${vault.type}) -> ${vault.path}`)); + const kind = entry.path ? 'local' : 'remote'; + const where = entry.path ?? entry.origin?.[0]?.remote ?? ''; + deps.log(chalk.green(`Added vault '${name}' (${kind}) -> ${where}`)); }; export const runVaultRemove = (name: string, deps: VaultDeps = defaultDeps): void => { - const config = removeVault(deps.load().config, name); - deps.save(config); - deps.removeIndex(name); + const { config } = deps.load(); + const wasDefault = config.default === name; + const next = removeVault(config, name); + deps.save(next); deps.log(`Removed vault '${name}' (files left on disk).`); + if (wasDefault && next.default) deps.log(`Default vault is now '${next.default}'.`); }; export const runVaultList = (opts: { json?: boolean }, deps: VaultDeps = defaultDeps): void => { - const { vaults } = deps.load().config; + const { config } = deps.load(); + const vaults = config.vaults ?? {}; + const names = Object.keys(vaults); if (opts.json) { deps.log(JSON.stringify(vaults, null, 2)); return; } - if (vaults.length === 0) { + if (names.length === 0) { deps.log('No vaults registered. Add one with `agentage vault add --local`.'); return; } - for (const v of vaults) deps.log(formatVaultLine(v)); + for (const name of names) { + const suffix = name === config.default ? ' (default)' : ''; + deps.log(formatVaultLine(name, vaults[name]!) + suffix); + } }; const guard = (fn: () => void): void => { @@ -93,15 +85,6 @@ const guard = (fn: () => void): void => { } }; -const guardAsync = async (fn: () => Promise): Promise => { - try { - await fn(); - } catch (err) { - console.error(chalk.red(err instanceof Error ? err.message : String(err))); - process.exitCode = 1; - } -}; - export const registerVault = (program: Command): void => { const vault = program.command('vault').description('Manage local memory vaults'); @@ -114,19 +97,12 @@ export const registerVault = (program: Command): void => { vault .command('add ') .description('Register a new vault (files stay on disk)') - .option('--local', 'a local folder that never syncs') - .option('--git ', 'sync to an external git remote') - .option('--path ', 'markdown directory (default ~/vaults/)') - .option('--interval ', 'git auto-sync interval (default 5m)') + .option('--local [path]', 'a local folder (default ~/vaults/)') + .option('--git ', 'a vault synced to an external git remote') .action((name: string, opts: VaultAddOptions) => guard(() => runVaultAdd(name, opts))); vault .command('remove ') - .description('Unregister a vault and drop its index (files stay)') + .description('Unregister a vault (files stay on disk)') .action((name: string) => guard(() => runVaultRemove(name))); - - vault - .command('reindex [name]') - .description('Rebuild a vault index from its markdown (all vaults if omitted)') - .action((name: string | undefined) => guardAsync(() => runReindex(name))); }; diff --git a/src/lib/memory-client.test.ts b/src/lib/memory-client.test.ts index 31dfafb..f8fd487 100644 --- a/src/lib/memory-client.test.ts +++ b/src/lib/memory-client.test.ts @@ -2,34 +2,22 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { VaultsConfig } from '@agentage/memory-core'; import { createDirectClient } from './memory-client.js'; -import { VaultsConfig, type VaultType } from './vaults.schema.js'; -// Each test gets its own config dir (for the index) + vault dir(s). -const configFor = (vaults: { name: string; path: string; type?: VaultType }[]): VaultsConfig => - VaultsConfig.parse({ - version: 1, - vaults: vaults.map((v) => ({ - name: v.name, - path: v.path, - ...(v.type === 'couchdb' - ? { type: 'couchdb', server: 'agentage' } - : v.type === 'git' - ? { type: 'git', remote: 'git@x:y.git' } - : { type: 'local' }), - })), - }); +// Each test gets its own vault dir(s); memory-core git-inits them in place (autoInit). +const configFor = (vaults: { name: string; path: string }[], def?: string): VaultsConfig => ({ + version: 1, + ...(def ? { default: def } : vaults.length === 1 ? { default: vaults[0]!.name } : {}), + vaults: Object.fromEntries( + vaults.map((v) => [v.name, { path: v.path, mcp: ['local'] as const }]) + ), +}); describe('DirectClient', () => { let root: string; - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'agentage-mc-')); - process.env['AGENTAGE_CONFIG_DIR'] = join(root, 'cfg'); - }); - afterEach(() => { - delete process.env['AGENTAGE_CONFIG_DIR']; - rmSync(root, { recursive: true, force: true }); - }); + beforeEach(() => (root = mkdtempSync(join(tmpdir(), 'agentage-mc-')))); + afterEach(() => rmSync(root, { recursive: true, force: true })); const single = () => { const vault = join(root, 'v'); @@ -38,46 +26,43 @@ describe('DirectClient', () => { it('write -> read round-trips', async () => { const { client } = single(); - await client.write('a.md', 'hello memory'); - const doc = await client.read('a.md'); - expect(doc.body).toBe('hello memory'); - expect(doc.vault).toBe('main'); + const receipt = await client.write('a.md', 'hello memory'); + expect(receipt.path).toBe('a.md'); + expect(receipt.rev).toMatch(/^[0-9a-f]{7,}$/); + expect((await client.read('a.md')).body).toBe('hello memory'); }); - it('search finds a written doc after its index refresh', async () => { + it('search finds a written doc via git grep', async () => { const { client } = single(); await client.write('notes/x.md', 'the quokka is a marsupial'); const out = await client.search('quokka'); expect(out.results.map((r) => r.path)).toEqual(['notes/x.md']); + expect(out.results[0]!.snippet).toContain('quokka'); }); - it('list reflects writes and deletes; delete is soft', async () => { + it('list returns a folder tree; delete removes and read then fails', async () => { const { client } = single(); await client.write('a.md', 'one'); - await client.write('b.md', 'two'); - expect((await client.list(undefined)).entries.map((e) => e.path).sort()).toEqual([ - 'a.md', - 'b.md', - ]); + await client.write('sub/b.md', 'two'); + const tree = await client.list(undefined); + expect(tree.files).toBe(2); await client.delete('a.md'); - expect((await client.list(undefined)).entries.map((e) => e.path)).toEqual(['b.md']); + expect((await client.list(undefined)).files).toBe(1); await expect(client.read('a.md')).rejects.toThrow(/not found/); }); - it('edits via str_replace through the client', async () => { + it('edits via str_replace', async () => { const { client } = single(); await client.write('a.md', 'alpha beta'); - await client.edit('a.md', { oldStr: 'beta', newStr: 'BETA' }); + await client.edit('a.md', { mode: 'str_replace', old_str: 'beta', new_str: 'BETA' }); expect((await client.read('a.md')).body).toBe('alpha BETA'); }); - it('routes by @vault/ prefix and by --vault', async () => { - const work = join(root, 'work'); - const notes = join(root, 'notes'); + it('routes by @vault prefix and by --vault', async () => { const client = createDirectClient( configFor([ - { name: 'work', path: work }, - { name: 'notes', path: notes }, + { name: 'work', path: join(root, 'work') }, + { name: 'notes', path: join(root, 'notes') }, ]) ); await client.write('@work/a.md', 'in work'); @@ -94,22 +79,16 @@ describe('DirectClient', () => { ]) ); await expect(client.read('x.md')).rejects.toThrow(/multiple vaults/); - await expect(client.read('x.md', { vault: 'nope' })).rejects.toThrow(/unknown vault/); + await expect(client.read('@nope/x.md')).rejects.toThrow(/Unknown vault/); }); - it('enforces the 8 MB cap on account-synced (couchdb) vaults only', async () => { - const client = createDirectClient( - configFor([{ name: 'cloud', path: join(root, 'c'), type: 'couchdb' }]) - ); - const tooBig = 'x'.repeat(8 * 1024 * 1024 + 1); - await expect(client.write('big.md', tooBig)).rejects.toThrow(/8 MB/); + it('errors when no local vault is registered', async () => { + const client = createDirectClient({ version: 1, vaults: {} }); + await expect(client.read('x.md')).rejects.toThrow(/no local vaults/); }); - it('bounds read output with a truncation flag', async () => { + it('delete of a missing doc reports not found', async () => { const { client } = single(); - await client.write('big.md', 'y'.repeat(1_000_050)); - const doc = await client.read('big.md'); - expect(doc.truncated).toBe(true); - expect(doc.body.length).toBe(1_000_000); + await expect(client.delete('ghost.md')).rejects.toThrow(/not found/); }); }); diff --git a/src/lib/memory-client.ts b/src/lib/memory-client.ts index 6eaff52..35a139b 100644 --- a/src/lib/memory-client.ts +++ b/src/lib/memory-client.ts @@ -1,171 +1,152 @@ -import { expandHome, indexDbPath } from './vault-registry.js'; -import { openIndex, type Hit, type VaultIndex } from './vault-index.js'; -import { reconcileIndex } from './vault-scan.js'; import { - deleteDoc, - editDoc, - readDoc, - writeDoc, - type DocView, - type WriteReceipt, -} from './vault-store.js'; -import { type EditOp } from './memory-edit.js'; -import { type VaultsConfig, type VaultType } from './vaults.schema.js'; - -// The one seam every index-touching verb goes through (daemon-index-ownership V10). This is -// the DirectClient (in-process) impl; the DaemonClient lands with the daemon (M2.5). Verbs -// never touch SQLite or the store directly - they call these six methods. - -const MAX_SYNCED_DOC = 8 * 1024 * 1024; // ADR-011 WI-4: 8 MB per-doc cap on account vaults -const READ_MAX = 1_000_000; // bounded read output -const SEARCH_LIMIT_MAX = 100; // bounded search page - -export interface SearchOutput { - vault: string; - results: Hit[]; -} -export interface ListOutput { - vault: string; - folder: string; - entries: { path: string; updated: string }[]; -} -export interface DeleteOutput { - vault: string; + createRegistry, + createRouter, + type EditInput, + type ListResult, + type MemoryView, + type Router, + type SearchResult, + type VaultsConfig, + type WriteResult, +} from '@agentage/memory-core'; + +// The one seam every memory verb goes through. This is the DirectClient (in-process) impl over +// @agentage/memory-core: per-vault git-backed local backends behind a federation router. Verbs +// delegate to the router; result shapes are memory-core's contract types at full fidelity. + +export interface DeleteResult { path: string; - trashedTo: string; + deleted: boolean; } -export type ReadOutput = DocView & { vault: string; truncated: boolean }; export interface VerbOptions { vault?: string; +} + +export interface SearchOptions extends VerbOptions { folder?: string; limit?: number; + tags?: string[]; + cursor?: string; +} + +export interface ListOptions extends VerbOptions { + depth?: 1 | 2; + tags?: string[]; } export interface MemoryClient { - search(query: string, opts?: VerbOptions): Promise; - read(ref: string, opts?: VerbOptions): Promise; + search(query: string, opts?: SearchOptions): Promise; + read(ref: string, opts?: VerbOptions): Promise; write( ref: string, body: string, opts?: VerbOptions & { frontmatter?: Record } - ): Promise; - edit( - ref: string, - op: Omit, - opts?: VerbOptions - ): Promise; - list(folder: string | undefined, opts?: VerbOptions): Promise; - delete(ref: string, opts?: VerbOptions): Promise; + ): Promise; + edit(ref: string, op: Omit, opts?: VerbOptions): Promise; + list(folder: string | undefined, opts?: ListOptions): Promise; + delete(ref: string, opts?: VerbOptions): Promise; } -interface Resolved { - name: string; - path: string; - type: VaultType; - relPath: string; +interface Context { + router: Router; + multi: boolean; + hasDefault: boolean; } -const parseRef = (ref: string): { vaultName?: string; relPath: string } => { - const m = /^@([A-Za-z0-9_-]{1,64})\/(.+)$/.exec(ref); - return m ? { vaultName: m[1]!, relPath: m[2]! } : { relPath: ref }; +// --vault becomes an @-prefix so the router resolves it; an explicit @/... passes through. +const scopeRef = (ref: string, vault?: string): string => + ref.startsWith('@') ? ref : vault ? `@${vault}/${ref}` : ref; + +const scopeFolder = (folder?: string, vault?: string): string | undefined => { + if (vault) return folder ? `@${vault}/${folder}` : `@${vault}`; + return folder || undefined; }; export const createDirectClient = (config: VaultsConfig): MemoryClient => { - const resolve = (ref: string, vaultOpt?: string): Resolved => { - const { vaultName, relPath } = parseRef(ref); - const want = vaultName ?? vaultOpt; - const vaults = config.vaults; - if (vaults.length === 0) throw new Error('no vaults registered - run `agentage vault add`'); - const vault = want - ? vaults.find((v) => v.name === want) - : vaults.length === 1 - ? vaults[0] - : undefined; - if (!vault) - throw new Error( - want ? `unknown vault: ${want}` : 'multiple vaults - use --vault or @/' - ); - return { name: vault.name, path: expandHome(vault.path), type: vault.type, relPath }; - }; - - // A folder-only ref (no doc path) resolves the vault without requiring a relPath. - const resolveVault = (vaultOpt?: string): Omit => { - const r = resolve('_', vaultOpt); - return { name: r.name, path: r.path, type: r.type }; - }; - - const refreshed = async ( - v: Omit, - use: (i: VaultIndex) => T - ): Promise => { - const index = openIndex(indexDbPath(v.name)); - try { - await reconcileIndex(index, v.path); - return use(index); - } finally { - index.close(); + let ctxP: Promise | undefined; + const ctx = (): Promise => { + if (!ctxP) { + ctxP = (async () => { + const registry = await createRegistry(config); + // Offline engine: only local (git working-copy) backends are usable here. + const local = registry.list().filter((h) => h.backend.capabilities().kind === 'local'); + if (local.length === 0) + throw new Error('no local vaults registered - run `agentage vault add --local`'); + const def = registry.default(); + const defaultHandle = def && local.some((h) => h.id === def.id) ? def : undefined; + return { + router: createRouter(local, defaultHandle), + multi: local.length > 1, + hasDefault: !!defaultHandle, + }; + })(); + ctxP.catch(() => (ctxP = undefined)); } + return ctxP; }; - const capForSync = (v: Resolved, body: string): void => { - if (v.type === 'couchdb' && Buffer.byteLength(body, 'utf-8') > MAX_SYNCED_DOC) - throw new Error(`document exceeds the 8 MB limit for account-synced vaults`); + // A single-target verb on a bare ref needs a vault when >1 is registered and none is default. + const requireTarget = (ref: string, c: Context): void => { + if (c.multi && !c.hasDefault && !ref.startsWith('@')) + throw new Error( + 'multiple vaults - use --vault , @/, or set "default" in vaults.json' + ); }; return { async search(query, opts = {}) { - const v = resolveVault(opts.vault); - const limit = Math.min(opts.limit ?? 20, SEARCH_LIMIT_MAX); - const results = await refreshed(v, (i) => i.search(query, { limit })); - return { vault: v.name, results }; + const { router } = await ctx(); + return router.search({ + query, + folder: scopeFolder(opts.folder, opts.vault), + tags: opts.tags, + limit: opts.limit, + cursor: opts.cursor, + }); }, async read(ref, opts = {}) { - const v = resolve(ref, opts.vault); - const doc = await readDoc(v.path, v.relPath); - const truncated = doc.body.length > READ_MAX; - return { - ...doc, - vault: v.name, - truncated, - body: truncated ? doc.body.slice(0, READ_MAX) : doc.body, - }; + const c = await ctx(); + const target = scopeRef(ref, opts.vault); + requireTarget(target, c); + const view = await c.router.read(target); + if (!view) throw new Error(`not found: ${ref}`); + return view; }, async write(ref, body, opts = {}) { - const v = resolve(ref, opts.vault); - capForSync(v, body); - const receipt = await writeDoc(v.path, v.relPath, body, opts.frontmatter); - await refreshed(v, () => undefined); - return { ...receipt, vault: v.name }; + const c = await ctx(); + const target = scopeRef(ref, opts.vault); + requireTarget(target, c); + return c.router.write({ path: target, body, frontmatter: opts.frontmatter }); }, async edit(ref, op, opts = {}) { - const v = resolve(ref, opts.vault); - const receipt = await editDoc(v.path, { ...op, path: v.relPath }); - await refreshed(v, () => undefined); - return { ...receipt, vault: v.name }; + const c = await ctx(); + const target = scopeRef(ref, opts.vault); + requireTarget(target, c); + const receipt = await c.router.edit({ ...op, path: target }); + if (!receipt) throw new Error(`not found: ${ref}`); + return receipt; }, async list(folder, opts = {}) { - const v = resolveVault(opts.vault); - const prefix = - folder && folder.length > 0 ? (folder.endsWith('/') ? folder : `${folder}/`) : undefined; - const entries = await refreshed(v, (i) => - i.list(prefix ? { prefix } : {}).map((e) => ({ - path: e.path, - updated: new Date(e.mtime).toISOString(), - })) - ); - return { vault: v.name, folder: folder ?? '', entries }; + const { router } = await ctx(); + return router.list({ + folder: scopeFolder(folder, opts.vault), + depth: opts.depth, + tags: opts.tags, + }); }, async delete(ref, opts = {}) { - const v = resolve(ref, opts.vault); - const { trashedTo } = await deleteDoc(v.path, v.relPath); - await refreshed(v, () => undefined); - return { vault: v.name, path: v.relPath, trashedTo }; + const c = await ctx(); + const target = scopeRef(ref, opts.vault); + requireTarget(target, c); + const deleted = await c.router.delete(target); + if (!deleted) throw new Error(`not found: ${ref}`); + return { path: target, deleted }; }, }; }; diff --git a/src/lib/memory-edit.test.ts b/src/lib/memory-edit.test.ts deleted file mode 100644 index cee2b6d..0000000 --- a/src/lib/memory-edit.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveEdit, strReplace } from './memory-edit.js'; - -describe('strReplace', () => { - it('replaces a unique occurrence', () => { - expect(strReplace('alpha beta gamma', 'f.md', 'beta', 'BETA')).toBe('alpha BETA gamma'); - }); - - it('deletes the match when new_str is empty', () => { - expect(strReplace('a b c', 'f.md', 'b ', '')).toBe('a c'); - }); - - it('throws the canonical error when old_str is absent', () => { - expect(() => strReplace('abc', 'f.md', 'xyz', 'q')).toThrow( - /No replacement was performed, old_str `xyz` did not appear verbatim in f\.md\./ - ); - }); - - it('throws the canonical error (with lines) on multiple occurrences', () => { - expect(() => strReplace('a\na', 'f.md', 'a', 'X')).toThrow( - /Multiple occurrences of old_str `a` in f\.md \(lines: 1, 2\)\. Please ensure it is unique\./ - ); - }); -}); - -describe('resolveEdit', () => { - it('applies a str_replace', () => { - expect(resolveEdit('a b', { path: 'f', oldStr: 'b', newStr: 'X' })).toBe('a X'); - }); - - it('appends with a newline guard', () => { - expect(resolveEdit('x', { path: 'f', body: 'more', mode: 'append' })).toBe('x\nmore'); - expect(resolveEdit('x\n', { path: 'f', body: 'more', mode: 'append' })).toBe('x\nmore'); - }); - - it('replaces the whole body', () => { - expect(resolveEdit('old', { path: 'f', body: 'new' })).toBe('new'); - }); - - it('rejects combining str_replace with a body', () => { - expect(() => resolveEdit('x', { path: 'f', oldStr: 'x', body: 'y' })).toThrow(/cannot combine/); - }); - - it('rejects an edit with neither --old nor --body', () => { - expect(() => resolveEdit('x', { path: 'f' })).toThrow(/either --old.*or --body/); - }); -}); diff --git a/src/lib/memory-edit.ts b/src/lib/memory-edit.ts deleted file mode 100644 index eb47d6f..0000000 --- a/src/lib/memory-edit.ts +++ /dev/null @@ -1,42 +0,0 @@ -// str_replace + edit-body resolution, mirroring @agentage/memory-core's local-ops verbatim -// (exact + unique match, canonical error strings) so the local CLI stays contract-faithful. - -const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; - -// Exact, unique substring replacement. new_str '' deletes the match. -export const strReplace = (body: string, path: string, oldStr: string, newStr: string): string => { - const starts: number[] = []; - for (let i = body.indexOf(oldStr); i !== -1; i = body.indexOf(oldStr, i + 1)) starts.push(i); - if (starts.length === 0) - throw new Error( - `No replacement was performed, old_str \`${oldStr}\` did not appear verbatim in ${path}.` - ); - if (starts.length > 1) - throw new Error( - `Multiple occurrences of old_str \`${oldStr}\` in ${path} (lines: ${starts - .map((s) => lineOf(body, s)) - .join(', ')}). Please ensure it is unique.` - ); - return body.slice(0, starts[0]) + newStr + body.slice(starts[0]! + oldStr.length); -}; - -export interface EditOp { - path: string; - oldStr?: string; - newStr?: string; - body?: string; - mode?: 'append' | 'replace'; -} - -// Resolve the new body for an edit. str_replace (old_str present) and a whole-body edit are -// mutually exclusive; a str_replace with no new_str deletes the matched text. -export const resolveEdit = (existing: string, op: EditOp): string => { - const hasStrReplace = op.oldStr !== undefined; - const hasBody = op.body !== undefined; - if (hasStrReplace && hasBody) - throw new Error('cannot combine a str_replace (--old/--new) with a --body edit'); - if (hasStrReplace) return strReplace(existing, op.path, op.oldStr!, op.newStr ?? ''); - if (!hasBody) throw new Error('edit needs either --old (str_replace) or --body'); - if (op.mode === 'append') return `${existing}${existing.endsWith('\n') ? '' : '\n'}${op.body!}`; - return op.body!; -}; diff --git a/src/lib/path-safety.ts b/src/lib/path-safety.ts deleted file mode 100644 index 8fba7aa..0000000 --- a/src/lib/path-safety.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isAbsolute, relative, resolve } from 'node:path'; - -// Resolve a vault-relative path to an absolute path, refusing anything that escapes the -// vault root (absolute inputs, `..` traversal). Ported from the v0.24 module. -export const safeJoin = (vaultPath: string, vaultRelPath: string): string => { - if (isAbsolute(vaultRelPath)) throw new Error('path must be vault-relative, not absolute'); - const full = resolve(vaultPath, vaultRelPath); - const rel = relative(vaultPath, full); - if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('path escapes the vault root'); - return full; -}; diff --git a/src/lib/vault-index.test.ts b/src/lib/vault-index.test.ts deleted file mode 100644 index e63fb21..0000000 --- a/src/lib/vault-index.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { openIndex, type FileChange } from './vault-index.js'; - -const change = (path: string, content: string): FileChange => ({ - path, - content, - sha256: `sha-${path}-${content.length}`, - size: content.length, - mtime: 1, -}); - -describe('vault index (node:sqlite FTS5)', () => { - it('reconciles adds and searches by content', () => { - const idx = openIndex(':memory:'); - idx.reconcile({ - added: [change('a.md', 'the quick brown fox'), change('b.md', 'lazy dog sleeps')], - modified: [], - removed: [], - }); - expect(idx.fileCount()).toBe(2); - const hits = idx.search('fox'); - expect(hits).toHaveLength(1); - expect(hits[0]?.path).toBe('a.md'); - expect(hits[0]?.snippet).toContain('fox'); - idx.close(); - }); - - it('stat returns metadata or null', () => { - const idx = openIndex(':memory:'); - idx.reconcile({ added: [change('a.md', 'hi')], modified: [], removed: [] }); - expect(idx.stat('a.md')).toMatchObject({ path: 'a.md', size: 2 }); - expect(idx.stat('missing.md')).toBeNull(); - idx.close(); - }); - - it('list respects prefix + path order', () => { - const idx = openIndex(':memory:'); - idx.reconcile({ - added: [change('notes/b.md', 'y'), change('notes/a.md', 'x'), change('other/c.md', 'z')], - modified: [], - removed: [], - }); - expect(idx.list({ prefix: 'notes/' }).map((e) => e.path)).toEqual(['notes/a.md', 'notes/b.md']); - expect(idx.list()).toHaveLength(3); - idx.close(); - }); - - it('modified replaces content; removed drops from search + files', () => { - const idx = openIndex(':memory:'); - idx.reconcile({ added: [change('a.md', 'apple')], modified: [], removed: [] }); - idx.reconcile({ added: [], modified: [change('a.md', 'banana')], removed: [] }); - expect(idx.search('apple')).toHaveLength(0); - expect(idx.search('banana')).toHaveLength(1); - idx.reconcile({ added: [], modified: [], removed: ['a.md'] }); - expect(idx.fileCount()).toBe(0); - expect(idx.search('banana')).toHaveLength(0); - idx.close(); - }); - - it('returns [] for an empty/whitespace query', () => { - const idx = openIndex(':memory:'); - expect(idx.search(' ')).toEqual([]); - idx.close(); - }); - - it('treats punctuation as literal, not FTS syntax', () => { - const idx = openIndex(':memory:'); - idx.reconcile({ added: [change('a.md', 'node:sqlite is great')], modified: [], removed: [] }); - expect(idx.search('node:sqlite')).toHaveLength(1); - idx.close(); - }); - - it('records indexedAt after reconcile', () => { - const idx = openIndex(':memory:', () => new Date('2026-07-06T00:00:00Z')); - expect(idx.indexedAt()).toBeNull(); - idx.reconcile({ added: [change('a.md', 'x')], modified: [], removed: [] }); - expect(idx.indexedAt()).toBe('2026-07-06T00:00:00.000Z'); - idx.close(); - }); -}); diff --git a/src/lib/vault-index.ts b/src/lib/vault-index.ts deleted file mode 100644 index 485dc1d..0000000 --- a/src/lib/vault-index.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname } from 'node:path'; - -// A per-vault SQLite FTS5 index (~/.agentage/index/.db). The index is a rebuildable -// cache: the markdown files are canonical, this is derived and can be dropped + rebuilt. -// Ported from the archived v0.24 module to node:sqlite (native sqlite addons are forbidden). - -const require = createRequire(import.meta.url); - -export interface Hit { - path: string; - score: number; - snippet: string; -} -export interface FileEntry { - path: string; - size: number; - mtime: number; - sha256: string; -} -export interface FileChange extends FileEntry { - content: string; -} -export interface DiskDiff { - added: FileChange[]; - modified: FileChange[]; - removed: string[]; -} -export interface SearchOptions { - limit?: number; - offset?: number; -} -export interface ListOptions { - prefix?: string; - limit?: number; - offset?: number; -} - -// Minimal shape of the node:sqlite surface we use (avoids a hard type dep on the module). -interface SqliteStatement { - get(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; - run(...params: unknown[]): unknown; -} -interface SqliteDb { - exec(sql: string): void; - prepare(sql: string): SqliteStatement; - close(): void; -} -interface SqliteModule { - DatabaseSync: new (path: string) => SqliteDb; -} - -// Lazy: importing node:sqlite at module top would throw on Node versions where it is still -// flagged, breaking every command. cli.ts re-execs index commands with the flag when needed. -export const loadSqlite = (): SqliteModule | null => { - try { - return require('node:sqlite') as SqliteModule; - } catch { - return null; - } -}; - -const requireSqlite = (): SqliteModule => { - const mod = loadSqlite(); - if (!mod) - throw new Error( - 'the vault index needs node:sqlite (Node >= 22.5 with --experimental-sqlite, or Node >= 23.4)' - ); - return mod; -}; - -const SCHEMA_VERSION = 1; -const SCHEMA = ` -PRAGMA journal_mode = WAL; - -CREATE TABLE IF NOT EXISTS files ( - path TEXT PRIMARY KEY, - size INTEGER NOT NULL, - mtime INTEGER NOT NULL, - sha256 TEXT NOT NULL -); - -CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5( - path UNINDEXED, - content, - tokenize='porter unicode61' -); - -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -`; - -// Quote each term so FTS5 treats punctuation as literal, not query syntax. -const sanitizeFtsQuery = (query: string): string | null => { - const tokens = query - .trim() - .split(/\s+/) - .filter((t) => t.length > 0) - .map((t) => `"${t.replace(/"/g, '""')}"`); - return tokens.length === 0 ? null : tokens.join(' '); -}; - -const transaction = (db: SqliteDb, fn: () => void): void => { - db.exec('BEGIN'); - try { - fn(); - db.exec('COMMIT'); - } catch (err) { - db.exec('ROLLBACK'); - throw err; - } -}; - -export interface VaultIndex { - search(query: string, opts?: SearchOptions): Hit[]; - stat(path: string): FileEntry | null; - list(opts?: ListOptions): FileEntry[]; - reconcile(diff: DiskDiff): void; - fileCount(): number; - indexedAt(): string | null; - close(): void; -} - -export const openIndex = (dbPath: string, now: () => Date = () => new Date()): VaultIndex => { - const { DatabaseSync } = requireSqlite(); - if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), { recursive: true }); - const db = new DatabaseSync(dbPath); - db.exec(SCHEMA); - if (!db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version')) - db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run( - 'schema_version', - String(SCHEMA_VERSION) - ); - - return { - search: (query, opts = {}) => { - const sanitized = sanitizeFtsQuery(query); - if (sanitized === null) return []; - return db - .prepare( - `SELECT path, snippet(files_fts, 1, '<<', '>>', '...', 32) AS snippet, bm25(files_fts) AS score - FROM files_fts WHERE files_fts MATCH ? ORDER BY score LIMIT ? OFFSET ?` - ) - .all(sanitized, opts.limit ?? 20, opts.offset ?? 0) as Hit[]; - }, - stat: (path) => - (db.prepare('SELECT path, size, mtime, sha256 FROM files WHERE path = ?').get(path) as - FileEntry | undefined) ?? null, - list: (opts = {}) => { - const limit = opts.limit ?? -1; - const offset = opts.offset ?? 0; - if (opts.prefix !== undefined) - return db - .prepare( - 'SELECT path, size, mtime, sha256 FROM files WHERE path LIKE ? ORDER BY path LIMIT ? OFFSET ?' - ) - .all(`${opts.prefix}%`, limit, offset) as FileEntry[]; - return db - .prepare('SELECT path, size, mtime, sha256 FROM files ORDER BY path LIMIT ? OFFSET ?') - .all(limit, offset) as FileEntry[]; - }, - reconcile: (diff) => { - const upsertFile = db.prepare( - 'INSERT OR REPLACE INTO files (path, size, mtime, sha256) VALUES (?, ?, ?, ?)' - ); - const deleteFts = db.prepare('DELETE FROM files_fts WHERE path = ?'); - const insertFts = db.prepare('INSERT INTO files_fts (path, content) VALUES (?, ?)'); - const deleteFile = db.prepare('DELETE FROM files WHERE path = ?'); - const setMeta = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)'); - transaction(db, () => { - for (const f of [...diff.added, ...diff.modified]) { - upsertFile.run(f.path, f.size, f.mtime, f.sha256); - deleteFts.run(f.path); - insertFts.run(f.path, f.content); - } - for (const path of diff.removed) { - deleteFile.run(path); - deleteFts.run(path); - } - setMeta.run('indexed_at', now().toISOString()); - }); - }, - fileCount: () => (db.prepare('SELECT COUNT(*) AS n FROM files').get() as { n: number }).n, - indexedAt: () => - ( - db.prepare('SELECT value FROM meta WHERE key = ?').get('indexed_at') as - { value: string } | undefined - )?.value ?? null, - close: () => db.close(), - }; -}; diff --git a/src/lib/vault-registry.test.ts b/src/lib/vault-registry.test.ts index 3416dac..992836b 100644 --- a/src/lib/vault-registry.test.ts +++ b/src/lib/vault-registry.test.ts @@ -1,59 +1,60 @@ -import { describe, expect, it } from 'vitest'; -import { - addVault, - expandHome, - formatVaultLine, - indexDbPath, - removeVault, -} from './vault-registry.js'; -import { VaultsConfig } from './vaults.schema.js'; +import { existsSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { VaultsConfig } from '@agentage/memory-core'; +import { addVault, ensureVaultDir, formatVaultLine, removeVault } from './vault-registry.js'; -const base = (): VaultsConfig => VaultsConfig.parse({ version: 1 }); +const base = (): VaultsConfig => ({ version: 1, vaults: {} }); describe('addVault', () => { - it('appends a local vault verbatim', () => { - const { config, vault } = addVault(base(), { - name: 'scratch', - type: 'local', - path: '~/vaults/scratch', - }); - expect(vault).toEqual({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); - expect(config.vaults).toHaveLength(1); + it('adds a local entry and makes the first vault the default', () => { + const config = addVault(base(), 'scratch', { path: '~/vaults/scratch', mcp: ['local'] }); + expect(config.vaults?.scratch).toEqual({ path: '~/vaults/scratch', mcp: ['local'] }); + expect(config.default).toBe('scratch'); }); - it('fills the git sync defaults', () => { - const { vault } = addVault(base(), { - name: 'work', - type: 'git', - path: '~/w', - remote: 'git@github.com:me/w.git', - }); - expect(vault).toMatchObject({ - type: 'git', - sync: { auto: true, interval: '5m', message: 'vault: auto-sync' }, - }); + it('keeps the existing default when adding a second vault', () => { + const one = addVault(base(), 'a', { path: '~/a' }); + const two = addVault(one, 'b', { path: '~/b' }); + expect(two.default).toBe('a'); + expect(Object.keys(two.vaults ?? {})).toEqual(['a', 'b']); + }); + + it('adds a git-origin entry', () => { + const config = addVault(base(), 'work', { origin: [{ remote: 'git@github.com:me/w.git' }] }); + expect(config.vaults?.work.origin?.[0]?.remote).toBe('git@github.com:me/w.git'); }); it('rejects a duplicate name', () => { - const { config } = addVault(base(), { name: 'a', type: 'local', path: '~/a' }); - expect(() => addVault(config, { name: 'a', type: 'local', path: '~/b' })).toThrow( - /already exists/ - ); + const config = addVault(base(), 'a', { path: '~/a' }); + expect(() => addVault(config, 'a', { path: '~/b' })).toThrow(/already exists/); }); - it('rejects an invalid name via the schema', () => { - expect(() => addVault(base(), { name: 'bad name', type: 'local', path: '~/x' })).toThrow(); + it('rejects an invalid name', () => { + expect(() => addVault(base(), 'bad name', { path: '~/x' })).toThrow(/invalid vault name/); }); - it('rejects a git vault with no remote', () => { - expect(() => addVault(base(), { name: 'g', type: 'git', path: '~/g' })).toThrow(); + it('rejects an entry with neither path nor origin', () => { + expect(() => addVault(base(), 'x', {})).toThrow(/origin and\/or a path/); }); }); describe('removeVault', () => { - it('removes an existing vault', () => { - const { config } = addVault(base(), { name: 'a', type: 'local', path: '~/a' }); - expect(removeVault(config, 'a').vaults).toHaveLength(0); + it('removes an entry and reassigns the default', () => { + let config = addVault(base(), 'a', { path: '~/a' }); + config = addVault(config, 'b', { path: '~/b' }); + const next = removeVault(config, 'a'); + expect(Object.keys(next.vaults ?? {})).toEqual(['b']); + expect(next.default).toBe('b'); + }); + + it('drops the default when the last vault is removed', () => { + const config = addVault(base(), 'a', { path: '~/a' }); + const next = removeVault(config, 'a'); + expect(next.vaults).toEqual({}); + expect(next.default).toBeUndefined(); }); it('throws when the vault is absent', () => { @@ -61,32 +62,26 @@ describe('removeVault', () => { }); }); -describe('paths + formatting', () => { - it('expands a leading ~/ against HOME', () => { - const prev = process.env['HOME']; - process.env['HOME'] = '/home/tester'; - expect(expandHome('~/vaults/x')).toBe('/home/tester/vaults/x'); - expect(expandHome('/abs/x')).toBe('/abs/x'); - process.env['HOME'] = prev; +describe('formatVaultLine', () => { + it('labels a local vault', () => { + expect(formatVaultLine('a', { path: '/tmp/a' })).toContain('local'); }); - it('places the index db under the config dir', () => { - const prev = process.env['AGENTAGE_CONFIG_DIR']; - process.env['AGENTAGE_CONFIG_DIR'] = '/tmp/cfg'; - expect(indexDbPath('work')).toBe('/tmp/cfg/index/work.db'); - process.env['AGENTAGE_CONFIG_DIR'] = prev; + it('labels a git-origin vault with its remote', () => { + const line = formatVaultLine('work', { origin: [{ remote: 'git@github.com:me/w.git' }] }); + expect(line).toContain('remote'); + expect(line).toContain('git@github.com:me/w.git'); }); +}); - it('formats a git line with its remote', () => { - const { vault } = addVault(base(), { - name: 'work', - type: 'git', - path: '~/w', - remote: 'git@github.com:me/w.git', - }); - const line = formatVaultLine(vault); - expect(line).toContain('work'); - expect(line).toContain('git'); - expect(line).toContain('git@github.com:me/w.git'); +describe('ensureVaultDir', () => { + let dir: string; + beforeEach(() => (dir = mkdtempSync(join(tmpdir(), 'agentage-reg-')))); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('creates the directory when missing', () => { + const target = join(dir, 'nested', 'vault'); + ensureVaultDir(target); + expect(existsSync(target)).toBe(true); }); }); diff --git a/src/lib/vault-registry.ts b/src/lib/vault-registry.ts index acded4b..b0ecaa3 100644 --- a/src/lib/vault-registry.ts +++ b/src/lib/vault-registry.ts @@ -1,47 +1,51 @@ -import { existsSync, mkdirSync, rmSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { getConfigDir } from './config.js'; -import { Vault, VaultsConfig } from './vaults.schema.js'; - -// Offline registry operations over an in-memory VaultsConfig: no network, no provisioning. -// The couchdb (account) path lives in a later slice - these cover local + git vaults. - -export const expandHome = (p: string): string => - p.startsWith('~/') ? join(process.env['HOME'] || homedir(), p.slice(2)) : p; - -export const indexDir = (): string => join(getConfigDir(), 'index'); -export const indexDbPath = (name: string): string => join(indexDir(), `${name}.db`); - -// The markdown directory is created on `vault add` if missing (~ is expanded). +import { existsSync, mkdirSync } from 'node:fs'; +import { + expandPath, + validateConfig, + type VaultEntry, + type VaultsConfig, +} from '@agentage/memory-core'; +import { isValidVaultName } from './vaults.schema.js'; + +// Offline registry operations over the unified vaults.json (object map keyed by name). No +// network, no provisioning: local (--local) and git-origin (--git) entries only. + +// A local vault's markdown directory is created on `vault add` (~ is expanded). export const ensureVaultDir = (path: string): void => { - const dir = expandHome(path); + const dir = expandPath(path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); }; -// The index is a rebuildable cache: dropping it on remove never touches the markdown. -export const removeIndexDb = (name: string): void => { - const db = indexDbPath(name); - if (existsSync(db)) rmSync(db, { force: true }); -}; - -export const formatVaultLine = (v: Vault): string => { - const extra = - v.type === 'git' ? ` <- ${v.remote}` : v.type === 'couchdb' ? ` (${v.server})` : ''; - return `${v.name.padEnd(16)} ${v.type.padEnd(8)} ${v.path}${extra}`; +export const formatVaultLine = (name: string, entry: VaultEntry): string => { + const kind = entry.path ? (entry.origin?.length ? 'git' : 'local') : 'remote'; + const where = entry.path ? expandPath(entry.path) : (entry.origin?.[0]?.remote ?? ''); + const remote = entry.path && entry.origin?.length ? ` <- ${entry.origin[0]!.remote}` : ''; + return `${name.padEnd(16)} ${kind.padEnd(8)} ${where}${remote}`; }; -export const addVault = ( - config: VaultsConfig, - entry: unknown -): { config: VaultsConfig; vault: Vault } => { - const vault = Vault.parse(entry); - if (config.vaults.some((v) => v.name === vault.name)) - throw new Error(`vault '${vault.name}' already exists`); - return { config: VaultsConfig.parse({ ...config, vaults: [...config.vaults, vault] }), vault }; +// Add an entry under `name`; the first vault added also becomes the `default`. +export const addVault = (config: VaultsConfig, name: string, entry: VaultEntry): VaultsConfig => { + if (!isValidVaultName(name)) throw new Error(`invalid vault name: ${JSON.stringify(name)}`); + const vaults = config.vaults ?? {}; + if (vaults[name]) throw new Error(`vault '${name}' already exists`); + return validateConfig({ + ...config, + default: config.default ?? name, + vaults: { ...vaults, [name]: entry }, + }); }; +// Remove an entry; if it was the `default`, reassign to a remaining vault or drop the field. export const removeVault = (config: VaultsConfig, name: string): VaultsConfig => { - if (!config.vaults.some((v) => v.name === name)) throw new Error(`vault '${name}' not found`); - return VaultsConfig.parse({ ...config, vaults: config.vaults.filter((v) => v.name !== name) }); + const vaults = config.vaults ?? {}; + if (!vaults[name]) throw new Error(`vault '${name}' not found`); + const rest = { ...vaults }; + delete rest[name]; + const next: VaultsConfig = { ...config, vaults: rest }; + if (config.default === name) { + const fallback = Object.keys(rest)[0]; + if (fallback) next.default = fallback; + else delete next.default; + } + return validateConfig(next); }; diff --git a/src/lib/vault-scan.test.ts b/src/lib/vault-scan.test.ts deleted file mode 100644 index 0867e2e..0000000 --- a/src/lib/vault-scan.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { openIndex } from './vault-index.js'; -import { reindexVault } from './vault-scan.js'; - -describe('reindexVault', () => { - let dir: string; - let vault: string; - let db: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'agentage-scan-')); - vault = join(dir, 'vault'); - mkdirSync(vault, { recursive: true }); - db = join(dir, 'index.db'); - }); - afterEach(() => rmSync(dir, { recursive: true, force: true })); - - const put = (rel: string, body: string): void => { - const full = join(vault, rel); - mkdirSync(dirname(full), { recursive: true }); - writeFileSync(full, body); - }; - const searchPaths = (query: string): string[] => { - const idx = openIndex(db); - const paths = idx.search(query).map((h) => h.path); - idx.close(); - return paths; - }; - - it('builds an index that search can query, walking subfolders', async () => { - put('a.md', 'alpha content'); - put('sub/b.md', 'beta content'); - const stats = await reindexVault(vault, db); - expect(stats.added).toBe(2); - expect(searchPaths('beta')).toEqual(['sub/b.md']); - }); - - it('drop the db + reindex = identical results (index is a pure cache)', async () => { - put('a.md', 'findme unique'); - await reindexVault(vault, db); - const first = searchPaths('findme'); - unlinkSync(db); - await reindexVault(vault, db); - expect(searchPaths('findme')).toEqual(first); - expect(first).toEqual(['a.md']); - }); - - it('detects modified + removed on a second pass', async () => { - put('a.md', 'one'); - put('b.md', 'two'); - await reindexVault(vault, db); - writeFileSync(join(vault, 'a.md'), 'one changed'); - unlinkSync(join(vault, 'b.md')); - const stats = await reindexVault(vault, db); - expect(stats.modified).toBe(1); - expect(stats.removed).toBe(1); - expect(searchPaths('two')).toHaveLength(0); - }); - - it('leaves unchanged files untouched on a re-run', async () => { - put('a.md', 'stable'); - await reindexVault(vault, db); - const stats = await reindexVault(vault, db); - expect(stats).toMatchObject({ added: 0, modified: 0, removed: 0, unchanged: 1 }); - }); - - it('skips dot-dirs like .obsidian and .git', async () => { - put('a.md', 'keep'); - mkdirSync(join(vault, '.obsidian'), { recursive: true }); - writeFileSync(join(vault, '.obsidian', 'x.md'), 'ignore me'); - const stats = await reindexVault(vault, db); - expect(stats.added).toBe(1); - }); -}); diff --git a/src/lib/vault-scan.ts b/src/lib/vault-scan.ts deleted file mode 100644 index 9663a8d..0000000 --- a/src/lib/vault-scan.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readdir, readFile, stat } from 'node:fs/promises'; -import { join, relative } from 'node:path'; -import { expandHome, indexDbPath } from './vault-registry.js'; -import { - openIndex, - type DiskDiff, - type FileChange, - type FileEntry, - type VaultIndex, -} from './vault-index.js'; - -// Reconcile a vault's markdown tree against its index: walk the folder, diff by mtime/size -// then sha256, and apply added/modified/removed. Dot-dirs and node_modules are skipped. - -export interface ReindexStats { - added: number; - modified: number; - removed: number; - unchanged: number; -} - -const isMarkdown = (name: string): boolean => /\.md$/i.test(name); -const sha256 = (buf: Buffer): string => createHash('sha256').update(buf).digest('hex'); - -interface WalkResult { - unchanged: number; - added: FileChange[]; - modified: FileChange[]; - seen: Set; -} - -const walkMarkdown = async (root: string, inIndex: Map): Promise => { - const added: FileChange[] = []; - const modified: FileChange[] = []; - const seen = new Set(); - let unchanged = 0; - const queue = [root]; - - while (queue.length > 0) { - const dir = queue.shift() as string; - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; - const full = join(dir, entry.name); - if (entry.isDirectory()) { - queue.push(full); - continue; - } - if (!entry.isFile() || !isMarkdown(entry.name)) continue; - - const relPath = relative(root, full); - seen.add(relPath); - const st = await stat(full); - const existing = inIndex.get(relPath); - if (existing && existing.mtime === st.mtimeMs && existing.size === st.size) { - unchanged++; - continue; - } - const buf = await readFile(full); - const change: FileChange = { - path: relPath, - content: buf.toString('utf-8'), - sha256: sha256(buf), - size: st.size, - mtime: st.mtimeMs, - }; - if (!existing) added.push(change); - else if (existing.sha256 !== change.sha256) modified.push(change); - else unchanged++; - } - } - return { unchanged, added, modified, seen }; -}; - -const buildDiff = (walk: WalkResult, inIndex: Map): DiskDiff => { - const removed = [...inIndex.keys()].filter((path) => !walk.seen.has(path)); - return { added: walk.added, modified: walk.modified, removed }; -}; - -// Reconcile an already-open index against the markdown tree at vaultPath (no open/close). -// Lets a caller keep the index open to query right after refreshing it. -export const reconcileIndex = async ( - index: VaultIndex, - vaultPath: string -): Promise => { - const inIndex = new Map(index.list().map((e) => [e.path, e])); - const walk = await walkMarkdown(expandHome(vaultPath), inIndex); - const diff = buildDiff(walk, inIndex); - index.reconcile(diff); - return { - added: diff.added.length, - modified: diff.modified.length, - removed: diff.removed.length, - unchanged: walk.unchanged, - }; -}; - -// Open the index at dbPath, reconcile it against the markdown tree at vaultPath, close. -export const reindexVault = async (vaultPath: string, dbPath: string): Promise => { - const index = openIndex(dbPath); - try { - return await reconcileIndex(index, vaultPath); - } finally { - index.close(); - } -}; - -// Resolve the index db path for a named vault (its markdown dir is `vaultPath`). -export const reindexNamedVault = (name: string, vaultPath: string): Promise => - reindexVault(vaultPath, indexDbPath(name)); diff --git a/src/lib/vault-store.test.ts b/src/lib/vault-store.test.ts deleted file mode 100644 index 4e8762e..0000000 --- a/src/lib/vault-store.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { deleteDoc, docExists, editDoc, readDoc, writeDoc } from './vault-store.js'; - -describe('vault store', () => { - let vault: string; - beforeEach(() => { - vault = mkdtempSync(join(tmpdir(), 'agentage-store-')); - }); - afterEach(() => rmSync(vault, { recursive: true, force: true })); - - it('round-trips a plain body', async () => { - await writeDoc(vault, 'a.md', 'hello world'); - const doc = await readDoc(vault, 'a.md'); - expect(doc.body).toBe('hello world'); - expect(doc.frontmatter).toEqual({}); - }); - - it('writes + parses frontmatter, deriving title + tags', async () => { - await writeDoc(vault, 'n.md', 'body text', { title: 'My Note', tags: ['x', 'y'] }); - const doc = await readDoc(vault, 'n.md'); - expect(doc.frontmatter).toMatchObject({ title: 'My Note', tags: ['x', 'y'] }); - expect(doc.body).toBe('body text'); - expect(doc.title).toBe('My Note'); - expect(doc.tags).toEqual(['x', 'y']); - }); - - it('falls back to the first heading, then the filename, for the title', async () => { - await writeDoc(vault, 'h.md', '# Heading Title\n\nbody'); - expect((await readDoc(vault, 'h.md')).title).toBe('Heading Title'); - await writeDoc(vault, 'plain.md', 'no heading'); - expect((await readDoc(vault, 'plain.md')).title).toBe('plain'); - }); - - it('edits via str_replace, preserving frontmatter', async () => { - await writeDoc(vault, 'a.md', 'one two three', { title: 'T' }); - await editDoc(vault, { path: 'a.md', oldStr: 'two', newStr: 'TWO' }); - const doc = await readDoc(vault, 'a.md'); - expect(doc.body).toBe('one TWO three'); - expect(doc.frontmatter).toMatchObject({ title: 'T' }); - }); - - it('soft-deletes into .trash/, keeping the file recoverable', async () => { - await writeDoc(vault, 'sub/a.md', 'keep me'); - const { trashedTo } = await deleteDoc(vault, 'sub/a.md'); - expect(trashedTo).toBe('.trash/sub/a.md'); - expect(docExists(vault, 'sub/a.md')).toBe(false); - expect(existsSync(join(vault, '.trash/sub/a.md'))).toBe(true); - }); - - it('refuses a path that escapes the vault root', async () => { - await expect(readDoc(vault, '../escape.md')).rejects.toThrow(/escapes the vault root/); - }); - - it('errors reading a missing file', async () => { - await expect(readDoc(vault, 'nope.md')).rejects.toThrow(/not found/); - }); -}); diff --git a/src/lib/vault-store.ts b/src/lib/vault-store.ts deleted file mode 100644 index 9b0fe29..0000000 --- a/src/lib/vault-store.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { existsSync } from 'node:fs'; -import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; -import { basename, dirname } from 'node:path'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { resolveEdit, type EditOp } from './memory-edit.js'; -import { safeJoin } from './path-safety.js'; - -// Markdown CRUD over a vault directory. The file is canonical; the SQLite index is derived. -// Frontmatter is a leading `---` YAML block (parsed/emitted with the `yaml` dep). - -export interface DocView { - path: string; - title: string; - frontmatter: Record; - body: string; - tags: string[]; - updated: string; - size: number; -} - -export interface WriteReceipt { - path: string; - bytesWritten: number; -} - -const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; - -const parseDoc = (raw: string): { frontmatter: Record; body: string } => { - const m = FRONTMATTER.exec(raw); - if (!m) return { frontmatter: {}, body: raw }; - let frontmatter: Record = {}; - try { - const parsed: unknown = parseYaml(m[1]!); - if (parsed && typeof parsed === 'object') frontmatter = parsed as Record; - } catch { - // a malformed frontmatter block is treated as body, not a hard error - return { frontmatter: {}, body: raw }; - } - return { frontmatter, body: raw.slice(m[0].length) }; -}; - -const composeDoc = (body: string, frontmatter?: Record): string => - frontmatter && Object.keys(frontmatter).length > 0 - ? `---\n${stringifyYaml(frontmatter)}---\n${body}` - : body; - -const titleOf = (relPath: string, fm: Record, body: string): string => { - if (typeof fm['title'] === 'string' && fm['title'].length > 0) return fm['title']; - const heading = /^#\s+(.+)$/m.exec(body); - return heading ? heading[1]!.trim() : basename(relPath).replace(/\.md$/i, ''); -}; - -const tagsOf = (fm: Record): string[] => - Array.isArray(fm['tags']) ? fm['tags'].filter((t): t is string => typeof t === 'string') : []; - -export const docExists = (vaultPath: string, relPath: string): boolean => - existsSync(safeJoin(vaultPath, relPath)); - -export const readDoc = async (vaultPath: string, relPath: string): Promise => { - const full = safeJoin(vaultPath, relPath); - if (!existsSync(full)) throw new Error(`not found: ${relPath}`); - const raw = await readFile(full, 'utf-8'); - const st = await stat(full); - const { frontmatter, body } = parseDoc(raw); - return { - path: relPath, - title: titleOf(relPath, frontmatter, body), - frontmatter, - body, - tags: tagsOf(frontmatter), - updated: new Date(st.mtimeMs).toISOString(), - size: st.size, - }; -}; - -export const writeDoc = async ( - vaultPath: string, - relPath: string, - body: string, - frontmatter?: Record -): Promise => { - const full = safeJoin(vaultPath, relPath); - const payload = composeDoc(body, frontmatter); - await mkdir(dirname(full), { recursive: true }); - await writeFile(full, payload, 'utf-8'); - return { path: relPath, bytesWritten: Buffer.byteLength(payload, 'utf-8') }; -}; - -export const editDoc = async (vaultPath: string, op: EditOp): Promise => { - const current = await readDoc(vaultPath, op.path); - const nextBody = resolveEdit(current.body, op); - return writeDoc(vaultPath, op.path, nextBody, current.frontmatter); -}; - -// Soft delete: move the file into the vault's `.trash/` (dot-prefixed, so the scanner skips -// it), preserving its relative path. Recoverable; the index drops it on the next reconcile. -export const deleteDoc = async ( - vaultPath: string, - relPath: string -): Promise<{ path: string; trashedTo: string }> => { - const full = safeJoin(vaultPath, relPath); - if (!existsSync(full)) throw new Error(`not found: ${relPath}`); - const trashRel = `.trash/${relPath}`; - const trashFull = safeJoin(vaultPath, trashRel); - await mkdir(dirname(trashFull), { recursive: true }); - await rename(full, trashFull); - return { path: relPath, trashedTo: trashRel }; -}; diff --git a/src/lib/vaults.schema.test.ts b/src/lib/vaults.schema.test.ts index 3be8491..8589853 100644 --- a/src/lib/vaults.schema.test.ts +++ b/src/lib/vaults.schema.test.ts @@ -1,90 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { Discover, Vault, VaultsConfig } from './vaults.schema.js'; +import { isValidVaultName, VAULTS_SCHEMA_URL } from './vaults.schema.js'; -describe('Vault schema', () => { - it('accepts a local vault (no sync block)', () => { - const v = Vault.parse({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); - expect(v).toEqual({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); +describe('vaults.schema', () => { + it('pins the public $schema url', () => { + expect(VAULTS_SCHEMA_URL).toBe('https://agentage.io/schemas/vaults.schema.json'); }); - it('defaults a couchdb vault to server agentage + continuous sync', () => { - const v = Vault.parse({ name: 'default', type: 'couchdb', path: '~/vaults/default' }); - expect(v).toMatchObject({ - type: 'couchdb', - server: 'agentage', - sync: { auto: true, mode: 'continuous', ignore: ['.obsidian/', 'data.json'] }, - }); + it('accepts allowlist-safe names', () => { + expect(isValidVaultName('scratch')).toBe(true); + expect(isValidVaultName('My_Vault-1')).toBe(true); }); - it('defaults a git vault sync block and requires a remote', () => { - const v = Vault.parse({ - name: 'work', - type: 'git', - path: '~/vaults/work', - remote: 'git@github.com:me/work.git', - }); - expect(v).toMatchObject({ sync: { interval: '5m', message: 'vault: auto-sync', auto: true } }); - expect(() => Vault.parse({ name: 'work', type: 'git', path: '~/w' })).toThrow(); - }); - - it('setting ignore replaces the defaults ([] = sync everything)', () => { - const v = Vault.parse({ - name: 'work', - type: 'couchdb', - path: '~/w', - sync: { ignore: [] }, - }); - expect(v).toMatchObject({ sync: { ignore: [] } }); - }); - - it('hard-fails an unknown type', () => { - expect(() => Vault.parse({ name: 'x', type: 'ftp', path: '~/x' })).toThrow(); - }); - - it('rejects a name that breaks the cloud-path allowlist', () => { - expect(() => Vault.parse({ name: 'has spaces', type: 'local', path: '~/x' })).toThrow(); - expect(() => Vault.parse({ name: 'a/b', type: 'local', path: '~/x' })).toThrow(); - }); - - it('rejects unknown keys (strict)', () => { - expect(() => Vault.parse({ name: 'x', type: 'local', path: '~/x', mcp: ['local'] })).toThrow(); - }); -}); - -describe('Discover schema', () => { - it('defaults type couchdb, autosync on, dot/underscore ignores', () => { - expect(Discover.parse({ path: '~/vaults' })).toEqual({ - path: '~/vaults', - type: 'couchdb', - autosync: true, - ignore: ['.*', '_*'], - }); - }); -}); - -describe('VaultsConfig schema', () => { - it('defaults empty discover + vaults arrays', () => { - expect(VaultsConfig.parse({ version: 1 })).toEqual({ version: 1, discover: [], vaults: [] }); - }); - - it('accepts and ignores $schema', () => { - const c = VaultsConfig.parse({ $schema: 'https://x/y.json', version: 1 }); - expect(c.version).toBe(1); - }); - - it('rejects a version other than 1', () => { - expect(() => VaultsConfig.parse({ version: 2 })).toThrow(); - }); - - it('rejects duplicate vault names', () => { - expect(() => - VaultsConfig.parse({ - version: 1, - vaults: [ - { name: 'dup', type: 'local', path: '~/a' }, - { name: 'dup', type: 'local', path: '~/b' }, - ], - }) - ).toThrow(/duplicate vault name/); + it('rejects names that break the cloud-path allowlist', () => { + expect(isValidVaultName('has spaces')).toBe(false); + expect(isValidVaultName('a/b')).toBe(false); + expect(isValidVaultName('')).toBe(false); + expect(isValidVaultName('x'.repeat(65))).toBe(false); }); }); diff --git a/src/lib/vaults.schema.ts b/src/lib/vaults.schema.ts index a6ea515..66b3ffd 100644 --- a/src/lib/vaults.schema.ts +++ b/src/lib/vaults.schema.ts @@ -1,77 +1,14 @@ -import { z } from 'zod'; +// The vaults.json format is owned by @agentage/memory-core (validateConfig / VaultsConfig). +// This module keeps only the two CLI-surface pieces memory-core does not: the editor-tooling +// $schema pointer written on create, and the vault-name allowlist enforced at `vault add`. -// The public JSON Schema location (served by the landing). Written into the file on -// create so editors get autocomplete + inline validation; accepted-and-ignored on load. -// A fixed production URL by design: editor tooling fetches it regardless of the CLI's -// target FQDN, so it is intentionally not derived from AGENTAGE_SITE_FQDN. +// The public JSON Schema location (served by the landing). Written into the file on create so +// editors get autocomplete + inline validation; accepted-and-ignored on load. A fixed +// production URL by design: editor tooling fetches it regardless of the CLI's target FQDN. export const VAULTS_SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json'; -// ADR-013 AA-4: every name stays a valid cloud path segment (`/.git`). -export const VaultName = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'invalid vault name'); +// ADR-013 AA-4: every name stays a valid cloud path segment (`/.git`). memory-core's +// schema accepts any string key, so the CLI guards the name at the add surface. +const VAULT_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/; -const Base = z.object({ name: VaultName, path: z.string() }).strict(); - -// Setting `ignore` REPLACES these defaults ([] = sync everything). -const SyncIgnore = z.array(z.string()).default(['.obsidian/', 'data.json']); -// false = the daemon skips the vault; manual `vault sync ` only. -const SyncAuto = z.boolean().default(true); - -export const Vault = z.discriminatedUnion('type', [ - Base.extend({ type: z.literal('local') }), - Base.extend({ - type: z.literal('git'), - remote: z.string().min(1), - sync: z - .object({ - auto: SyncAuto, - interval: z.string().default('5m'), - message: z.string().default('vault: auto-sync'), - ignore: SyncIgnore, - }) - .strict() - // prefault (not default) so zod 4 re-parses `{}` and applies the inner defaults - .prefault({}), - }), - Base.extend({ - type: z.literal('couchdb'), - // A raw CouchDB URL for self-hosted is a later extension. - server: z.literal('agentage').default('agentage'), - sync: z - .object({ - auto: SyncAuto, - mode: z.enum(['continuous', 'interval']).default('continuous'), - ignore: SyncIgnore, - }) - .strict() - // prefault (not default) so zod 4 re-parses `{}` and applies the inner defaults - .prefault({}), - }), -]); -export type Vault = z.infer; -export type VaultType = Vault['type']; - -// `git` cannot be a discovery type (it needs a remote) - use `vault add --git`. -export const Discover = z - .object({ - path: z.string(), - type: z.enum(['couchdb', 'local']).default('couchdb'), - // seeds discovered entries' sync.auto - autosync: z.boolean().default(true), - ignore: z.array(z.string()).default(['.*', '_*']), - }) - .strict(); -export type Discover = z.infer; - -export const VaultsConfig = z - .object({ - // editor-tooling pointer, written on create, otherwise ignored - $schema: z.string().optional(), - version: z.literal(1), - discover: z.array(Discover).default([]), - vaults: z.array(Vault).default([]), - }) - .strict() - .refine((c) => new Set(c.vaults.map((v) => v.name)).size === c.vaults.length, { - message: 'duplicate vault name', - }); -export type VaultsConfig = z.infer; +export const isValidVaultName = (name: string): boolean => VAULT_NAME_RE.test(name); diff --git a/src/lib/vaults.test.ts b/src/lib/vaults.test.ts index b559236..5f9fffe 100644 --- a/src/lib/vaults.test.ts +++ b/src/lib/vaults.test.ts @@ -2,16 +2,17 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { VaultsConfig } from '@agentage/memory-core'; import { ensureConfigDir } from './config.js'; -import type { VaultsConfig } from './vaults.schema.js'; import { ensureVaultsConfig, loadVaultsConfig, saveVaultsConfig, vaultsJsonPath, - vaultsYamlPath, } from './vaults.js'; +const SCHEMA = 'https://agentage.io/schemas/vaults.schema.json'; + const write = (path: string, text: string): void => { ensureConfigDir(); writeFileSync(path, text); @@ -31,96 +32,61 @@ describe('vaults config store', () => { }); it('returns an empty config when no file exists (zero-config)', () => { - const loaded = loadVaultsConfig(); - expect(loaded).toEqual({ - config: { version: 1, discover: [], vaults: [] }, - source: null, - }); + expect(loadVaultsConfig()).toEqual({ config: { version: 1, vaults: {} }, source: null }); }); - it('loads and validates the array shape from vaults.json', () => { - write( - vaultsJsonPath(), - JSON.stringify({ version: 1, vaults: [{ name: 'work', type: 'local', path: '~/w' }] }) - ); - const { config } = loadVaultsConfig(); - expect(config.vaults[0]).toMatchObject({ name: 'work', type: 'local' }); - }); - - it('loads YAML from vaults.yaml', () => { - write( - vaultsYamlPath(), - 'version: 1\nvaults:\n - name: work\n type: local\n path: ~/w\n' - ); - const { config, source } = loadVaultsConfig(); - expect(config.vaults[0]).toMatchObject({ name: 'work' }); - expect(source).toBe(vaultsYamlPath()); - }); - - it('prefers vaults.json when both files exist', () => { - write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [] })); - write(vaultsYamlPath(), 'version: 1\nvaults:\n - name: y\n type: local\n path: ~/y\n'); + it('loads and validates the unified object-map shape', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: { work: { path: '~/w' } } })); const { config, source } = loadVaultsConfig(); + expect(config.vaults?.work).toMatchObject({ path: '~/w' }); expect(source).toBe(vaultsJsonPath()); - expect(config.vaults).toHaveLength(0); }); - it('rejects a legacy object-map file (no migration)', () => { - write( - vaultsJsonPath(), - JSON.stringify({ vaults: { work: { origin: [{ remote: 'agentage' }] } } }) - ); - expect(() => loadVaultsConfig()).toThrow(); + it('rejects an entry with neither path nor origin', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: { x: {} } })); + expect(() => loadVaultsConfig()).toThrow(/origin and\/or a path/); }); - it('throws on an unknown vault type', () => { - write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [{ name: 'x', type: 'ftp' }] })); + it('rejects a version other than 1', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 2 })); expect(() => loadVaultsConfig()).toThrow(); }); - it('round-trips a save with $schema first, 0600, and a trailing newline', () => { + it('round-trips a save: $schema first, 0600, trailing newline, default preserved', () => { const config: VaultsConfig = { version: 1, - discover: [], - vaults: [{ name: 'work', type: 'local', path: '~/w' }], + default: 'work', + vaults: { work: { path: '~/w', mcp: ['local'] } }, }; const path = saveVaultsConfig(config); const raw = readFileSync(path, 'utf-8'); expect(raw.endsWith('\n')).toBe(true); - expect(Object.keys(JSON.parse(raw))[0]).toBe('$schema'); + const parsed = JSON.parse(raw); + expect(Object.keys(parsed)[0]).toBe('$schema'); + expect(parsed.$schema).toBe(SCHEMA); + expect(parsed.default).toBe('work'); expect(statSync(path).mode & 0o777).toBe(0o600); - const { config: reloaded } = loadVaultsConfig(); - expect(reloaded.vaults).toEqual(config.vaults); + expect(loadVaultsConfig().config.vaults).toEqual(config.vaults); }); it('always rewrites the canonical $schema url on save', () => { - const path = saveVaultsConfig({ - $schema: 'https://evil/x.json', - version: 1, - discover: [], - vaults: [], - } as VaultsConfig); - expect(JSON.parse(readFileSync(path, 'utf-8'))['$schema']).toBe( - 'https://agentage.io/schemas/vaults.schema.json' - ); + const path = saveVaultsConfig({ $schema: 'https://evil/x.json', version: 1, vaults: {} }); + expect(JSON.parse(readFileSync(path, 'utf-8'))['$schema']).toBe(SCHEMA); }); it('scaffolds an empty, $schema-linked vaults.json when none exists', () => { ensureVaultsConfig(); - const raw = JSON.parse(readFileSync(vaultsJsonPath(), 'utf-8')); - expect(raw).toEqual({ - $schema: 'https://agentage.io/schemas/vaults.schema.json', + expect(JSON.parse(readFileSync(vaultsJsonPath(), 'utf-8'))).toEqual({ + $schema: SCHEMA, version: 1, - discover: [], - vaults: [], + vaults: {}, }); }); it('leaves an existing vaults.json untouched when scaffolding', () => { - write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [] })); + const original = JSON.stringify({ version: 1, vaults: {} }); + write(vaultsJsonPath(), original); ensureVaultsConfig(); - expect(readFileSync(vaultsJsonPath(), 'utf-8')).toBe( - JSON.stringify({ version: 1, vaults: [] }) - ); + expect(readFileSync(vaultsJsonPath(), 'utf-8')).toBe(original); }); }); diff --git a/src/lib/vaults.ts b/src/lib/vaults.ts index 157a008..626a753 100644 --- a/src/lib/vaults.ts +++ b/src/lib/vaults.ts @@ -1,13 +1,12 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { parse as parseYaml } from 'yaml'; +import { validateConfig, type VaultsConfig } from '@agentage/memory-core'; import { ensureConfigDir, getConfigDir } from './config.js'; -import { VAULTS_SCHEMA_URL, VaultsConfig } from './vaults.schema.js'; +import { VAULTS_SCHEMA_URL } from './vaults.schema.js'; export const vaultsJsonPath = (): string => join(getConfigDir(), 'vaults.json'); -export const vaultsYamlPath = (): string => join(getConfigDir(), 'vaults.yaml'); -const EMPTY: VaultsConfig = { version: 1, discover: [], vaults: [] }; +const EMPTY: VaultsConfig = { version: 1, vaults: {} }; export interface LoadedVaults { config: VaultsConfig; @@ -15,41 +14,32 @@ export interface LoadedVaults { source: string | null; } -const readRaw = (): { text: string; path: string } | null => { - const jsonPath = vaultsJsonPath(); - if (existsSync(jsonPath)) return { text: readFileSync(jsonPath, 'utf-8'), path: jsonPath }; - const yamlPath = vaultsYamlPath(); - if (existsSync(yamlPath)) return { text: readFileSync(yamlPath, 'utf-8'), path: yamlPath }; - return null; -}; - -// No legacy migration: a file that is not the current array schema fails validation -// loudly (start fresh with the new schema / `vault add`). +// JSON only (one format everywhere; the standalone @agentage/server-memory reads the same file +// and parses only JSON). A missing file yields an empty config; a bad shape throws ConfigError. export const loadVaultsConfig = (): LoadedVaults => { - const raw = readRaw(); - if (!raw) return { config: EMPTY, source: null }; - const parsed: unknown = raw.path.endsWith('.yaml') ? parseYaml(raw.text) : JSON.parse(raw.text); - return { config: VaultsConfig.parse(parsed), source: raw.path }; + const path = vaultsJsonPath(); + if (!existsSync(path)) return { config: EMPTY, source: null }; + const raw: unknown = JSON.parse(readFileSync(path, 'utf-8')); + return { config: validateConfig(raw), source: path }; }; -// Scaffold an empty, $schema-linked vaults.json when none exists yet, so a fresh machine -// always has an editable file with editor autocomplete. Leaves any existing file untouched -// (it is validated on actual use, not here). Safe to call on every connect. +// Scaffold an empty, $schema-linked vaults.json when none exists yet, so a fresh machine always +// has an editable file with editor autocomplete. Leaves any existing file untouched. export const ensureVaultsConfig = (): void => { - if (readRaw()) return; + if (existsSync(vaultsJsonPath())) return; saveVaultsConfig(EMPTY); }; -// Atomic 0600 write of the canonical array shape, always to vaults.json (never .yaml). +// Atomic 0600 write. Re-injects the canonical $schema first and preserves every other field. export const saveVaultsConfig = (config: VaultsConfig): string => { ensureConfigDir(); const path = vaultsJsonPath(); - const out = { - $schema: VAULTS_SCHEMA_URL, - version: config.version, - discover: config.discover, - vaults: config.vaults, - }; + const out: Record = { $schema: VAULTS_SCHEMA_URL, version: config.version }; + if (config.vaultsDir !== undefined) out.vaultsDir = config.vaultsDir; + if (config.autodiscover !== undefined) out.autodiscover = config.autodiscover; + if (config.autoInit !== undefined) out.autoInit = config.autoInit; + if (config.default !== undefined) out.default = config.default; + out.vaults = config.vaults ?? {}; const tmp = `${path}.tmp`; writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); renameSync(tmp, path); diff --git a/src/package-guard.test.ts b/src/package-guard.test.ts index d1547ab..919ac20 100644 --- a/src/package-guard.test.ts +++ b/src/package-guard.test.ts @@ -40,14 +40,13 @@ describe('package guard (R6)', () => { dependencies: Record; bin: Record; }; - // zod + yaml join at M1 for vaults.json validation + YAML parsing (both are the exact - // runtime deps of the sibling @agentage/memory-core); the set grows again at M3. + // @agentage/memory-core is the one local engine at M2-C (decision V7/V11-C); it replaces + // the retired FTS5/SQLite stack and the direct zod/yaml deps. Still minimal: no daemon. expect(Object.keys(pkg.dependencies).sort()).toEqual([ + '@agentage/memory-core', 'chalk', 'commander', 'open', - 'yaml', - 'zod', ]); expect(pkg.bin['agentage']).toBe('./dist/cli.js'); }); diff --git a/vitest.config.ts b/vitest.config.ts index 8c4fb54..2352ebf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,10 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['src/**/*.test.ts'], - // node:sqlite sits behind --experimental-sqlite on Node < 23.4 (the vault index needs - // it); pass the flag to the workers. Harmless (accepted) on newer Node. (Vitest 4: - // execArgv is a top-level test option; the old poolOptions.forks form was removed.) - execArgv: ['--experimental-sqlite'], coverage: { provider: 'v8', include: ['src/**/*.ts'],