feat(memory): persistent memory layer with remember/recall CodeAct API
Add a memories table (survives index rebuilds) for agent-written conclusions with provenance (session, message range, project). The agent writes markdown files via Write tool (user-approved), then registers them via a --remember CodeAct script with remember(). Recall via memories() in --query scripts, filtered by project/session/time. Separates query (read-only, assertReadOnlySql) from remember (write) execution contexts in runtime.mjs.
This commit is contained in:
@@ -54,6 +54,13 @@ CREATE INDEX IF NOT EXISTS idx_sa_session ON subagents(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_session ON workflows(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_run ON workflow_agents(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
||||
message_start TEXT, message_end TEXT,
|
||||
path TEXT, summary TEXT, created_at TEXT);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
||||
`;
|
||||
|
||||
function openDb() {
|
||||
|
||||
+74
-3
@@ -24,8 +24,21 @@ function buildWhere(opts, aliases) {
|
||||
|
||||
const BASH_EXIT_PAT = 'Exit code %';
|
||||
|
||||
function assertReadOnlySql(sql) {
|
||||
const text = String(sql || '').trim();
|
||||
if (!/^(SELECT|WITH)\b/i.test(text)) {
|
||||
throw new Error('sql() only supports read-only SELECT/WITH queries');
|
||||
}
|
||||
if (/\b(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER|PRAGMA|VACUUM|ATTACH|DETACH)\b/i.test(text)) {
|
||||
throw new Error('sql() only supports read-only SELECT/WITH queries');
|
||||
}
|
||||
}
|
||||
|
||||
function createQueryApi(db) {
|
||||
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
||||
const q = (sql, ...p) => {
|
||||
assertReadOnlySql(sql);
|
||||
return db.prepare(sql).all(...p);
|
||||
};
|
||||
|
||||
const search = (text, opts = {}) => {
|
||||
const { limit = 20, sessionId, project, after, before, cwd } = opts;
|
||||
@@ -213,7 +226,65 @@ function createQueryApi(db) {
|
||||
};
|
||||
};
|
||||
|
||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw };
|
||||
const memories = (optsOrSid) => {
|
||||
const opts = normalizeOpts(optsOrSid);
|
||||
const { limit = 50, query } = opts;
|
||||
const needsJoin = opts.branch;
|
||||
const { where: baseWhere, params } = buildWhere(opts, {
|
||||
sessionId: 'mem.session_id',
|
||||
project: 'mem.project',
|
||||
timestamp: 'mem.created_at',
|
||||
branch: 's.git_branch',
|
||||
});
|
||||
const terms = String(query || '')
|
||||
.trim()
|
||||
.replace(/[-_]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
let where = baseWhere;
|
||||
for (const term of terms) {
|
||||
where += " AND lower(coalesce(mem.summary,'') || ' ' || coalesce(mem.path,'')) LIKE ?";
|
||||
params.push(`%${term.toLowerCase()}%`);
|
||||
}
|
||||
params.push(limit);
|
||||
const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : '';
|
||||
return db.prepare(`SELECT mem.* FROM memories mem ${join} WHERE ${where} ORDER BY mem.created_at DESC LIMIT ?`).all(...params);
|
||||
};
|
||||
|
||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories };
|
||||
}
|
||||
|
||||
export { createQueryApi };
|
||||
function createRememberApi(db) {
|
||||
const resolveMemoryPath = (memoryPath, sessionId) => {
|
||||
let base = null;
|
||||
if (sessionId) {
|
||||
base = db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId)?.project_path || null;
|
||||
}
|
||||
const resolved = path.isAbsolute(memoryPath)
|
||||
? path.normalize(memoryPath)
|
||||
: path.resolve(base || process.cwd(), memoryPath);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
throw new Error(`remember() memory file does not exist: ${resolved}`);
|
||||
}
|
||||
if (!stat.isFile()) throw new Error(`remember() memory path is not a file: ${resolved}`);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => {
|
||||
if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
|
||||
const normalizedPath = resolveMemoryPath(memoryPath, session_id);
|
||||
const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null;
|
||||
const created_at = new Date().toISOString();
|
||||
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, summary, created_at) VALUES (?,?,?,?,?,?,?,?)').run(
|
||||
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, summary, created_at);
|
||||
return { id, path: normalizedPath, project: proj, created_at };
|
||||
};
|
||||
|
||||
return { remember };
|
||||
}
|
||||
|
||||
export { createQueryApi, createRememberApi };
|
||||
|
||||
+20
-4
@@ -7,10 +7,9 @@ const vm = require('node:vm');
|
||||
|
||||
import { DB_PATH, openDb } from './db.mjs';
|
||||
import { buildIndex } from './indexer.mjs';
|
||||
import { createQueryApi } from './query.mjs';
|
||||
import { createQueryApi, createRememberApi } from './query.mjs';
|
||||
|
||||
function executeQuery(db, scriptContent) {
|
||||
const api = createQueryApi(db);
|
||||
function executeScript(api, scriptContent) {
|
||||
const sandbox = {
|
||||
...api, JSON, Math, Array, Object, Set, Map, Date, RegExp,
|
||||
parseInt, parseFloat, String, Number, Boolean, Error, Promise, console, setTimeout,
|
||||
@@ -19,6 +18,14 @@ function executeQuery(db, scriptContent) {
|
||||
return vm.runInNewContext(`(async()=>{${scriptContent}})()`, ctx, { timeout: 30000 });
|
||||
}
|
||||
|
||||
function executeQuery(db, scriptContent) {
|
||||
return executeScript(createQueryApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function executeRemember(db, scriptContent) {
|
||||
return executeScript(createRememberApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--build') {
|
||||
@@ -42,7 +49,16 @@ function main() {
|
||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n');
|
||||
if (args[0] === '--remember' && args[1]) {
|
||||
buildIndex();
|
||||
const db = openDb();
|
||||
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
||||
executeRemember(db, script)
|
||||
.then(r => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); db.close(); })
|
||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n node runtime.mjs --remember <file.js>\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user