Files
boocode/apps/server/src/services/tools/manage_memory.ts
indifferentketchup 381b97f78a feat(server): inference state-graph + supervisor, memory tools, MCP client, schema, routes
- Add state-graph.ts: typed state machine for inference lifecycle
- Add supervisor.ts: agent supervisor pattern for multi-agent coordination
- Add export-formatter.ts: structured export formatting
- Add manage_memory.ts: memory CRUD tool for agent persistence
- Add get_wiki_article.ts: codecontext wiki article retrieval
- Extend memory/index.ts: 3-tier memory (context/daily/core)
- Extend MCP client: mcp-config.ts env-var substitution
- Update schema.sql: agent_sessions, tasks, pending_changes extensions
- Update API types: MessageMetadata, ErrorReason, AgentSessionConfig
- Update routes: chats, messages, sessions — column renames and agent_session_id
- Update inference: error handler, payload builder, stream phase, turn orchestrator
2026-06-08 03:48:47 +00:00

161 lines
5.6 KiB
TypeScript

import { z } from 'zod';
import { existsSync } from 'node:fs';
import { writeFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import type { ToolDef } from '../tools/types.js';
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
import { writeEntry, readTopicFiles } from '../memory/store.js';
const ManageMemoryInput = z.object({
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
title: z.string().min(1).max(200).describe('Entry title (used as identifier for update/delete)'),
content: z.string().optional().describe('Memory content body (required for create/update)'),
tags: z.array(z.string()).optional().describe('Optional tags for search'),
action: z.enum(['create', 'update', 'delete']).describe('Action to perform'),
});
type InputT = z.infer<typeof ManageMemoryInput>;
function titleToFilename(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') + '.md'
);
}
/**
* Try to update the CoreTier SQLite database in addition to the file store.
* This is best-effort — CoreTier is optional (file store is primary).
*/
async function syncCoreTier(
_root: string,
_topic: string,
_title: string,
_content: string,
_tags: string[],
): Promise<void> {
// CoreTier SQLite backend is not available in this build — file store only.
}
export const manageMemoryTool: ToolDef<InputT> = {
name: 'manage_memory',
description:
'Create, update, or delete memory entries in .boocode/memory/ for cross-session recall. ' +
'Use to persist project conventions, user preferences, and architectural decisions. ' +
'Actions: create (write new entry), update (modify existing entry), delete (remove entry).',
inputSchema: ManageMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'manage_memory',
description: 'Manage memory entries — create, update, or delete',
parameters: {
type: 'object',
properties: {
topic: {
type: 'string',
enum: ['project', 'user', 'reference'],
description: 'Memory topic category',
},
title: { type: 'string', description: 'Entry title (identifier for update/delete)' },
content: {
type: 'string',
description: 'Memory content body (required for create/update)',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Optional tags for search',
},
action: {
type: 'string',
enum: ['create', 'update', 'delete'],
description: 'Action to perform',
},
},
required: ['topic', 'title', 'action'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const root = getMemoryRoot(projectRoot);
await ensureMemoryScaffold(root);
const filename = titleToFilename(input.title);
if (input.action === 'create') {
if (!input.content) {
return { error: 'Content is required for create action.' };
}
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" created in .boocode/memory/${input.topic}/`,
};
}
if (input.action === 'update') {
if (!input.content) {
return { error: 'Content is required for update action.' };
}
// Resolve target file path — try computed filename first, then heading match
let targetPath = join(root, input.topic, filename);
if (!existsSync(targetPath)) {
const files = await readTopicFiles(root, input.topic);
const matched = [...files.keys()].find((name) => {
const content = files.get(name);
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
});
if (matched) {
targetPath = join(root, input.topic, matched);
} else {
return {
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
};
}
}
const tagLine =
(input.tags ?? []).length > 0
? `> tags: ${(input.tags ?? []).join(', ')}\n\n`
: '\n';
const entry = `## ${input.topic}: ${input.title}\n${tagLine}${input.content}\n`;
await writeFile(targetPath, entry, 'utf8');
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" updated in .boocode/memory/${input.topic}/`,
};
}
if (input.action === 'delete') {
// Resolve target file path
let targetPath = join(root, input.topic, filename);
if (!existsSync(targetPath)) {
const files = await readTopicFiles(root, input.topic);
const matched = [...files.keys()].find((name) => {
const content = files.get(name);
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
});
if (matched) {
targetPath = join(root, input.topic, matched);
} else {
return {
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
};
}
}
await unlink(targetPath);
return {
result: `Memory entry "${input.title}" deleted from .boocode/memory/${input.topic}/`,
};
}
return { error: `Unknown action: ${input.action}` };
},
};