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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions e2e/memory-offline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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
test.describe('offline memory CRUD @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('write -> search -> read -> edit -> list -> delete, no network', async () => {
const m = createCliMachine();
try {
const add = await m.exec([
'vault',
'add',
'main',
'--local',
'--path',
`${m.configDir}/main`,
]);
expect(add.code, add.stderr).toBe(0);

const write = await m.exec([
'memory',
'write',
'@main/notes/q.md',
'--body',
'the quokka is a marsupial',
]);
expect(write.code, write.stderr).toBe(0);

const search = await m.exec(['memory', 'search', 'quokka', '--json']);
expect(search.code, search.stderr).toBe(0);
expect(JSON.parse(search.stdout).results.map((r: { path: string }) => r.path)).toEqual([
'notes/q.md',
]);

const read = await m.exec(['memory', 'read', '@main/notes/q.md']);
expect(read.stdout).toContain('marsupial');

const edit = await m.exec([
'memory',
'edit',
'@main/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');

const list = await m.exec(['memory', 'list', '--json']);
expect(JSON.parse(list.stdout).entries.map((e: { path: string }) => e.path)).toEqual([
'notes/q.md',
]);

const del = await m.exec(['memory', 'delete', '@main/notes/q.md']);
expect(del.code, del.stderr).toBe(0);
expect(JSON.parse((await m.exec(['memory', 'list', '--json'])).stdout).entries).toHaveLength(
0
);
} finally {
m.cleanup();
}
});

test('a duplicate str_replace target reports the canonical error', async () => {
const m = createCliMachine();
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']);
expect(res.code).not.toBe(0);
expect(res.stderr + res.stdout).toContain('Multiple occurrences');
} finally {
m.cleanup();
}
});
});
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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';
import { registerStatus } from './commands/status.js';
import { registerUpdate } from './commands/update.js';
Expand Down Expand Up @@ -54,6 +55,7 @@ program.name('agentage').description('The agentage CLI').version(VERSION);
registerSetup(program);
registerStatus(program);
registerVault(program);
registerMemory(program);
registerUpdate(program);

program.parseAsync().catch((err: unknown) => {
Expand Down
104 changes: 104 additions & 0 deletions src/commands/memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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 }] })),
read: vi.fn(async () => ({
vault: 'v',
path: 'a.md',
title: 'A',
frontmatter: {},
body: 'hi',
tags: [],
updated: 'now',
size: 2,
truncated: false,
})),
write: vi.fn(async () => ({ vault: 'v', path: 'a.md', bytesWritten: 5 })),
edit: vi.fn(async () => ({ vault: 'v', path: 'a.md', bytesWritten: 3 })),
list: vi.fn(async () => ({
vault: 'v',
folder: '',
entries: [{ path: 'a.md', updated: 'now' }],
})),
delete: vi.fn(async () => ({ vault: 'v', path: 'a.md', trashedTo: '.trash/a.md' })),
});

let logs: string[];
beforeEach(() => {
logs = [];
vi.spyOn(console, 'log').mockImplementation((m: unknown) => void logs.push(String(m)));
vi.spyOn(console, 'error').mockImplementation((m: unknown) => void logs.push(String(m)));
});
afterEach(() => vi.restoreAllMocks());

describe('memory command wiring', () => {
it('search maps --limit and prints hits', async () => {
const c = client();
await runSearch('quokka', { limit: '5' }, c);
expect(c.search).toHaveBeenCalledWith('quokka', { vault: undefined, limit: 5 });
expect(logs.join()).toContain('a.md');
});

it('search --json emits JSON', async () => {
const c = client();
await runSearch('q', { json: true }, c);
expect(JSON.parse(logs[0] ?? '{}')).toMatchObject({ vault: 'v' });
});

it('write passes an inline --body and parsed --frontmatter', async () => {
const c = client();
await runWrite('a.md', { body: 'hello', frontmatter: '{"title":"T"}' }, c);
expect(c.write).toHaveBeenCalledWith('a.md', 'hello', {
vault: undefined,
frontmatter: { title: 'T' },
});
});

it('edit maps --old/--new to a str_replace op', async () => {
const c = client();
await runEdit('a.md', { old: 'x', new: 'y' }, c);
expect(c.edit).toHaveBeenCalledWith(
'a.md',
{ oldStr: 'x', newStr: 'y', body: undefined, mode: 'replace' },
{ vault: undefined }
);
});

it('edit --append selects append mode', async () => {
const c = client();
await runEdit('a.md', { body: 'more', append: true }, c);
expect(c.edit).toHaveBeenCalledWith(
'a.md',
{ oldStr: undefined, newStr: undefined, body: 'more', mode: 'append' },
{ vault: undefined }
);
});

it('read warns when the output was truncated', 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');
});

it('list and delete call through to the client', 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');
});
});
173 changes: 173 additions & 0 deletions src/commands/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import chalk from 'chalk';
import { type Command } from 'commander';
import { createDirectClient, type MemoryClient } from '../lib/memory-client.js';
import { loadVaultsConfig } from '../lib/vaults.js';

const defaultClient = (): MemoryClient => createDirectClient(loadVaultsConfig().config);

const readStdin = async (): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf-8');
};

const emit = (json: boolean, data: unknown, human: () => void): void => {
if (json) console.log(JSON.stringify(data, null, 2));
else human();
};

interface CommonOpts {
vault?: string;
json?: boolean;
}

export const runSearch = async (
query: string,
opts: CommonOpts & { limit?: string },
client: MemoryClient = defaultClient()
): Promise<void> => {
const out = await client.search(query, {
vault: opts.vault,
limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
});
emit(opts.json ?? false, out, () => {
if (out.results.length === 0) console.log('No matches.');
for (const h of out.results) console.log(`${h.path}\n ${chalk.gray(h.snippet)}`);
});
};

export const runRead = async (
ref: string,
opts: CommonOpts,
client: MemoryClient = defaultClient()
): Promise<void> => {
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)'));
});
};

export const runWrite = async (
ref: string,
opts: CommonOpts & { body?: string; frontmatter?: string },
client: MemoryClient = defaultClient()
): Promise<void> => {
const body = opts.body !== undefined && opts.body !== '-' ? opts.body : await readStdin();
const frontmatter = opts.frontmatter
? (JSON.parse(opts.frontmatter) as Record<string, unknown>)
: 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)`))
);
};

export const runEdit = async (
ref: string,
opts: CommonOpts & { old?: string; new?: string; body?: string; append?: boolean },
client: MemoryClient = defaultClient()
): Promise<void> => {
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}`)));
};

export const runList = async (
folder: string | undefined,
opts: CommonOpts,
client: MemoryClient = defaultClient()
): Promise<void> => {
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);
});
};

export const runDelete = async (
ref: string,
opts: CommonOpts,
client: MemoryClient = defaultClient()
): Promise<void> => {
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})`)
);
};

const guard = (fn: () => Promise<void>): Promise<void> =>
fn().catch((err: unknown) => {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exitCode = 1;
});

export const registerMemory = (program: Command): void => {
const memory = program.command('memory').description('Read + write memory, offline');

memory
.command('search <query...>')
.description('Full-text search a vault (FTS5)')
.option('--vault <name>', 'target vault')
.option('--limit <n>', 'max hits (default 20)')
.option('--json', 'machine-readable output')
.action((query: string[], opts: CommonOpts & { limit?: string }) =>
guard(() => runSearch(query.join(' '), opts))
);

memory
.command('read <ref>')
.description('Print a document (@<vault>/<path> or <path> --vault)')
.option('--vault <name>', 'target vault')
.option('--json', 'machine-readable output')
.action((ref: string, opts: CommonOpts) => guard(() => runRead(ref, opts)));

memory
.command('write <ref>')
.description('Create or overwrite a document (body from --body or stdin)')
.option('--vault <name>', 'target vault')
.option('--body <text>', 'document body (omit or "-" to read stdin)')
.option('--frontmatter <json>', 'frontmatter as a JSON object')
.option('--json', 'machine-readable output')
.action((ref: string, opts: CommonOpts & { body?: string; frontmatter?: string }) =>
guard(() => runWrite(ref, opts))
);

memory
.command('edit <ref>')
.description('Edit a document: str_replace (--old/--new) or --body (--append)')
.option('--vault <name>', 'target vault')
.option('--old <str>', 'exact, unique substring to replace')
.option('--new <str>', 'replacement (omit to delete the match)')
.option('--body <text>', 'replace the whole body')
.option('--append', 'append --body instead of replacing')
.option('--json', 'machine-readable output')
.action(
(
ref: string,
opts: CommonOpts & { old?: string; new?: string; body?: string; append?: boolean }
) => guard(() => runEdit(ref, opts))
);

memory
.command('list [folder]')
.description('List documents in a vault (optionally under a folder)')
.option('--vault <name>', 'target vault')
.option('--json', 'machine-readable output')
.action((folder: string | undefined, opts: CommonOpts) => guard(() => runList(folder, opts)));

memory
.command('delete <ref>')
.description('Soft-delete a document (moved to .trash/, recoverable)')
.option('--vault <name>', 'target vault')
.option('--json', 'machine-readable output')
.action((ref: string, opts: CommonOpts) => guard(() => runDelete(ref, opts)));
};
Loading