feat: evolve retrieval layer with content_type, memory FTS, forget(), and recap references
Extract schema DDL into scripts/schema.sql. Add content_type and is_meta columns to messages for transcript control-plane filtering. Introduce FTS5-backed memory recall with safe tokenization, memory soft-delete via forget() through the renamed --attune runtime, and anchors on memory records. Expand query helpers (includeMeta, thread opts, overview project-path awareness). Add per-card recap retrieval and writing references under references/recap/.
This commit is contained in:
+44
-55
@@ -8,69 +8,34 @@ const { DatabaseSync } = require('node:sqlite');
|
||||
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
||||
const DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite');
|
||||
const TEXT_LIMIT = 10000;
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
|
||||
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
|
||||
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
||||
timestamp TEXT, role TEXT, text TEXT, model TEXT,
|
||||
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
||||
input_tokens INTEGER, output_tokens INTEGER,
|
||||
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
name TEXT, input_json TEXT, file_path TEXT);
|
||||
CREATE TABLE IF NOT EXISTS tool_results (
|
||||
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
content TEXT, file_path TEXT, is_error INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS subagents (
|
||||
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
|
||||
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
|
||||
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER, total_tokens INTEGER, status TEXT, workflow_name TEXT);
|
||||
CREATE TABLE IF NOT EXISTS workflow_agents (
|
||||
agent_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT,
|
||||
agent_type TEXT, description TEXT,
|
||||
phase TEXT, label TEXT, model TEXT, state TEXT,
|
||||
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS index_state (
|
||||
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS summaries (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
|
||||
source TEXT, content TEXT);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_tc_session_name ON tool_calls(session_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tc_file ON tool_calls(file_path);
|
||||
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);
|
||||
`;
|
||||
const SCHEMA = fs.readFileSync(new URL('./schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
function openDb() {
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('PRAGMA synchronous=NORMAL');
|
||||
db.exec(SCHEMA);
|
||||
migrateDb(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureColumn(db, table, column, definition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
function migrateDb(db) {
|
||||
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
||||
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
||||
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
||||
}
|
||||
|
||||
function rebuildMemoryFts(db) {
|
||||
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||
}
|
||||
|
||||
function trunc(s) {
|
||||
return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s;
|
||||
}
|
||||
@@ -101,6 +66,30 @@ function extractText(content) {
|
||||
return parts.length ? trunc(parts.join('\n')) : null;
|
||||
}
|
||||
|
||||
function extractContentType(content) {
|
||||
if (typeof content === 'string') return 'text';
|
||||
if (!Array.isArray(content) || !content.length) return 'unknown';
|
||||
const types = new Set();
|
||||
let sawUnknown = false;
|
||||
for (const b of content) {
|
||||
if (!b || typeof b !== 'object') { sawUnknown = true; continue; }
|
||||
if (b.type === 'text') types.add('text');
|
||||
else if (b.type === 'thinking') types.add('thinking');
|
||||
else if (b.type === 'tool_use') types.add('tool_use');
|
||||
else if (b.type === 'tool_result') types.add('tool_result');
|
||||
else sawUnknown = true;
|
||||
}
|
||||
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
|
||||
}
|
||||
|
||||
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
|
||||
|
||||
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
|
||||
const msg = record?.message || {};
|
||||
if (record?.isMeta === true || msg.isMeta === true) return 1;
|
||||
return typeof text === 'string' && COMMAND_ENVELOPE_RE.test(text) ? 1 : 0;
|
||||
}
|
||||
|
||||
function filePath(name, input) {
|
||||
if (!input) return null;
|
||||
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
|
||||
@@ -129,4 +118,4 @@ function readLines(filePath, callback) {
|
||||
}
|
||||
}
|
||||
|
||||
export { CLAUDE_DIR, DB_PATH, TEXT_LIMIT, openDb, trunc, truncJson, extractText, filePath, isDir, readLines, fs, path, os };
|
||||
export { CLAUDE_DIR, DB_PATH, TEXT_LIMIT, openDb, rebuildMemoryFts, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, fs, path, os };
|
||||
|
||||
+30
-6
@@ -1,4 +1,4 @@
|
||||
import { CLAUDE_DIR, openDb, trunc, truncJson, extractText, filePath, isDir, readLines, fs, path } from './db.mjs';
|
||||
import { CLAUDE_DIR, openDb, rebuildMemoryFts, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, fs, path } from './db.mjs';
|
||||
|
||||
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
||||
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl');
|
||||
@@ -82,7 +82,7 @@ function indexJsonl(db, fi) {
|
||||
|
||||
const ins = {
|
||||
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
|
||||
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
||||
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
||||
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
||||
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
|
||||
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
|
||||
@@ -129,12 +129,14 @@ function indexJsonl(db, fi) {
|
||||
|
||||
const msg = obj.message || {};
|
||||
const text = extractText(msg.content);
|
||||
const contentType = extractContentType(msg.content);
|
||||
const isMeta = extractMessageIsMeta(obj, text);
|
||||
const usage = msg.usage || {};
|
||||
const aid = fi.isSubagent ? fi.agentId : (obj.agentId || null);
|
||||
|
||||
if (obj.uuid) {
|
||||
ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts,
|
||||
msg.role || obj.type, text, msg.model || null,
|
||||
msg.role || obj.type, text, contentType, isMeta, msg.model || null,
|
||||
obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null,
|
||||
obj.cwd || null, obj.attributionSkill || null);
|
||||
}
|
||||
@@ -244,12 +246,33 @@ function indexHistory(db) {
|
||||
}
|
||||
|
||||
const BUILD_DEBOUNCE_MS = 30000;
|
||||
const APP_HEARTBEAT_FRESH_MS = 60000;
|
||||
|
||||
function shouldSkipBuild(db, { now = Date.now() } = {}) {
|
||||
const appHeartbeat = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_heartbeat__'").get();
|
||||
const appSuccessfulBuild = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_last_successful_build__'").get();
|
||||
if (
|
||||
appHeartbeat && now - appHeartbeat.mtime < APP_HEARTBEAT_FRESH_MS &&
|
||||
appSuccessfulBuild && now - appSuccessfulBuild.mtime < APP_HEARTBEAT_FRESH_MS
|
||||
) {
|
||||
return { skip: true, reason: 'app_successful_build' };
|
||||
}
|
||||
const last = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__last_build__'").get();
|
||||
if (last && now - last.mtime < BUILD_DEBOUNCE_MS) {
|
||||
return { skip: true, reason: 'recent_build' };
|
||||
}
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
function buildIndex({ force = false } = {}) {
|
||||
const db = openDb();
|
||||
if (!force) {
|
||||
const last = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__last_build__'").get();
|
||||
if (last && Date.now() - last.mtime < BUILD_DEBOUNCE_MS) { db.close(); return; }
|
||||
const skip = shouldSkipBuild(db);
|
||||
if (skip.skip) { db.close(); return; }
|
||||
}
|
||||
|
||||
if (force) {
|
||||
db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run();
|
||||
}
|
||||
|
||||
const files = discoverJsonlFiles();
|
||||
@@ -270,6 +293,7 @@ function buildIndex({ force = false } = {}) {
|
||||
refreshSessionProjectPaths(db);
|
||||
indexHistory(db);
|
||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||
rebuildMemoryFts(db);
|
||||
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
@@ -279,4 +303,4 @@ function buildIndex({ force = false } = {}) {
|
||||
db.close();
|
||||
}
|
||||
|
||||
export { buildIndex, inferProjectPath, refreshSessionProjectPaths };
|
||||
export { buildIndex, inferProjectPath, refreshSessionProjectPaths, shouldSkipBuild };
|
||||
|
||||
+88
-32
@@ -1,4 +1,4 @@
|
||||
import { openDb, readLines, fs, path } from './db.mjs';
|
||||
import { readLines, fs, path } from './db.mjs';
|
||||
|
||||
function normalizeOpts(optsOrScalar, scalarKey = 'sessionId') {
|
||||
if (optsOrScalar == null) return {};
|
||||
@@ -45,6 +45,14 @@ function assertEnglishMemoryText(value, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSafeFtsQuery(text) {
|
||||
const tokens = String(text || '').match(/[\p{Letter}\p{Number}]+/gu) || [];
|
||||
return tokens
|
||||
.slice(0, 12)
|
||||
.map(token => `"${token}"`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function createQueryApi(db) {
|
||||
const q = (sql, ...p) => {
|
||||
assertReadOnlySql(sql);
|
||||
@@ -59,7 +67,7 @@ function createQueryApi(db) {
|
||||
};
|
||||
|
||||
const search = (text, opts = {}) => {
|
||||
const { limit = 20, sessionId, project, after, before, cwd } = opts;
|
||||
const { limit = 20, sessionId, project, after, before, cwd, includeMeta = false } = opts;
|
||||
let where = 'WHERE mf.text MATCH ?';
|
||||
const p = [text];
|
||||
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
|
||||
@@ -67,19 +75,21 @@ function createQueryApi(db) {
|
||||
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
|
||||
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
|
||||
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
|
||||
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
|
||||
p.push(limit);
|
||||
const rows = db.prepare(`
|
||||
SELECT m.uuid,m.session_id,m.text,m.role,m.timestamp,m.model,m.cwd,
|
||||
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,
|
||||
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
|
||||
rank
|
||||
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
||||
${where} ORDER BY rank LIMIT ?`).all(...p);
|
||||
return rows.map(r => {
|
||||
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
||||
const ctx = db.prepare(
|
||||
'SELECT uuid,text,role,timestamp,model FROM messages WHERE session_id=? AND uuid!=? ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6'
|
||||
`SELECT uuid,text,content_type,is_meta,role,timestamp,model FROM messages WHERE session_id=? AND uuid!=? ${metaClause} ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6`
|
||||
).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1);
|
||||
return {
|
||||
message: { uuid: r.uuid, text: r.text, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd },
|
||||
message: { uuid: r.uuid, text: r.text, content_type: r.content_type, is_meta: r.is_meta || 0, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd },
|
||||
session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started },
|
||||
rank: r.rank,
|
||||
context: ctx,
|
||||
@@ -110,7 +120,11 @@ function createQueryApi(db) {
|
||||
return chain;
|
||||
};
|
||||
|
||||
const thread = (sid) => db.prepare('SELECT * FROM messages WHERE session_id=? ORDER BY timestamp').all(sid);
|
||||
const thread = (sid, opts = {}) => {
|
||||
const includeMeta = opts?.includeMeta === true;
|
||||
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
||||
return db.prepare(`SELECT * FROM messages WHERE session_id=? ${metaClause} ORDER BY timestamp`).all(sid);
|
||||
};
|
||||
|
||||
const subagents = (optsOrSid) => {
|
||||
const opts = normalizeOpts(optsOrSid);
|
||||
@@ -267,7 +281,7 @@ function createQueryApi(db) {
|
||||
WITH names AS (
|
||||
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
||||
UNION
|
||||
SELECT project FROM memories WHERE project IS NOT NULL GROUP BY project
|
||||
SELECT project FROM memories WHERE project IS NOT NULL AND deleted_at IS NULL GROUP BY project
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT project, COUNT(*) AS session_count, MAX(COALESCE(ended_at, started_at)) AS last_session_at
|
||||
@@ -278,7 +292,7 @@ function createQueryApi(db) {
|
||||
memory_stats AS (
|
||||
SELECT project, COUNT(*) AS memory_count, MAX(created_at) AS last_memory_at
|
||||
FROM memories
|
||||
WHERE project IS NOT NULL
|
||||
WHERE project IS NOT NULL AND deleted_at IS NULL
|
||||
GROUP BY project
|
||||
)
|
||||
SELECT
|
||||
@@ -322,11 +336,11 @@ function createQueryApi(db) {
|
||||
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||
LIMIT ?
|
||||
`).all(currentProject.project, sessionLimit);
|
||||
const memoryTotal = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE project = ?').get(currentProject.project)?.c || 0;
|
||||
const memoryTotal = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE project = ? AND deleted_at IS NULL').get(currentProject.project)?.c || 0;
|
||||
const memoriesForProject = db.prepare(`
|
||||
SELECT id, path, summary, session_id, project, created_at
|
||||
SELECT id, path, anchors, summary, session_id, project, created_at
|
||||
FROM memories
|
||||
WHERE project = ?
|
||||
WHERE project = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(currentProject.project, memoryLimit);
|
||||
@@ -345,11 +359,11 @@ function createQueryApi(db) {
|
||||
FROM (
|
||||
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
||||
UNION
|
||||
SELECT project FROM memories WHERE project IS NOT NULL GROUP BY project
|
||||
SELECT project FROM memories WHERE project IS NOT NULL AND deleted_at IS NULL GROUP BY project
|
||||
)
|
||||
`).get()?.c || 0;
|
||||
const totalSessions = db.prepare('SELECT COUNT(*) AS c FROM sessions').get()?.c || 0;
|
||||
const totalMemories = db.prepare('SELECT COUNT(*) AS c FROM memories').get()?.c || 0;
|
||||
const totalMemories = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
||||
|
||||
return {
|
||||
current: {
|
||||
@@ -422,25 +436,32 @@ function createQueryApi(db) {
|
||||
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);
|
||||
let where = baseWhere + ' AND mem.deleted_at IS NULL';
|
||||
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);
|
||||
const hasQuery = String(query || '').trim().length > 0;
|
||||
const ftsQuery = buildSafeFtsQuery(query);
|
||||
if (!hasQuery) {
|
||||
params.push(limit);
|
||||
return db.prepare(`SELECT mem.* FROM memories mem ${join} WHERE ${where} ORDER BY mem.created_at DESC LIMIT ?`).all(...params);
|
||||
}
|
||||
if (!ftsQuery) return [];
|
||||
params.unshift(ftsQuery);
|
||||
params.push(limit);
|
||||
return db.prepare(`
|
||||
SELECT mem.*, mf.rank AS rank
|
||||
FROM memories_fts mf
|
||||
JOIN memories mem ON mem.rowid = mf.rowid
|
||||
${join}
|
||||
WHERE memories_fts MATCH ? AND ${where}
|
||||
ORDER BY mf.rank, mem.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(...params);
|
||||
};
|
||||
|
||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories, overview };
|
||||
}
|
||||
|
||||
function createRememberApi(db) {
|
||||
function createAttuneApi(db) {
|
||||
const resolveMemoryPath = (memoryPath, sessionId) => {
|
||||
let base = null;
|
||||
if (sessionId) {
|
||||
@@ -459,19 +480,54 @@ function createRememberApi(db) {
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => {
|
||||
const normalizeAnchors = (anchors) => {
|
||||
if (anchors == null) return null;
|
||||
let parsed = anchors;
|
||||
if (typeof anchors === 'string') {
|
||||
const trimmed = anchors.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error('remember() anchors must be a JSON array');
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) throw new Error('remember() anchors must be an array');
|
||||
for (const anchor of parsed) {
|
||||
if (!anchor || typeof anchor !== 'object' || Array.isArray(anchor)) {
|
||||
throw new Error('remember() anchors entries must be objects');
|
||||
}
|
||||
}
|
||||
return parsed.length ? JSON.stringify(parsed) : null;
|
||||
};
|
||||
|
||||
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }) => {
|
||||
if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
|
||||
assertEnglishMemoryText(summary, 'remember() summary');
|
||||
const normalizedPath = resolveMemoryPath(memoryPath, session_id);
|
||||
const normalizedAnchors = normalizeAnchors(anchors);
|
||||
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 };
|
||||
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run(
|
||||
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at);
|
||||
return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at };
|
||||
};
|
||||
|
||||
return { remember };
|
||||
const forget = ({ id, reason }) => {
|
||||
const deletionReason = String(reason || '').trim();
|
||||
if (!id || !deletionReason) throw new Error('forget() requires id and reason');
|
||||
const row = db.prepare('SELECT id, deleted_at, deleted_reason FROM memories WHERE id=?').get(id);
|
||||
if (!row) throw new Error(`forget() memory not found: ${id}`);
|
||||
if (row.deleted_at) {
|
||||
return { id, deleted_at: row.deleted_at, deleted_reason: row.deleted_reason, already_deleted: true };
|
||||
}
|
||||
const deleted_at = new Date().toISOString();
|
||||
db.prepare('UPDATE memories SET deleted_at=?, deleted_reason=? WHERE id=?').run(deleted_at, deletionReason, id);
|
||||
return { id, deleted_at, deleted_reason: deletionReason };
|
||||
};
|
||||
|
||||
return { remember, forget };
|
||||
}
|
||||
|
||||
export { createQueryApi, createRememberApi };
|
||||
export { createQueryApi, createAttuneApi };
|
||||
|
||||
+6
-6
@@ -7,7 +7,7 @@ const vm = require('node:vm');
|
||||
|
||||
import { DB_PATH, openDb } from './db.mjs';
|
||||
import { buildIndex } from './indexer.mjs';
|
||||
import { createQueryApi, createRememberApi } from './query.mjs';
|
||||
import { createQueryApi, createAttuneApi } from './query.mjs';
|
||||
|
||||
function executeScript(api, scriptContent) {
|
||||
const sandbox = {
|
||||
@@ -22,8 +22,8 @@ function executeQuery(db, scriptContent) {
|
||||
return executeScript(createQueryApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function executeRemember(db, scriptContent) {
|
||||
return executeScript(createRememberApi(db), scriptContent);
|
||||
function executeAttune(db, scriptContent) {
|
||||
return executeScript(createAttuneApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -49,16 +49,16 @@ function main() {
|
||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--remember' && args[1]) {
|
||||
if (args[0] === '--attune' && args[1]) {
|
||||
buildIndex();
|
||||
const db = openDb();
|
||||
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
||||
executeRemember(db, script)
|
||||
executeAttune(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.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 --attune <file.js>\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
|
||||
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
|
||||
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
||||
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
|
||||
is_meta INTEGER DEFAULT 0, model TEXT,
|
||||
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
||||
input_tokens INTEGER, output_tokens INTEGER,
|
||||
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
name TEXT, input_json TEXT, file_path TEXT);
|
||||
CREATE TABLE IF NOT EXISTS tool_results (
|
||||
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
content TEXT, file_path TEXT, is_error INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS subagents (
|
||||
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
|
||||
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
|
||||
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER, total_tokens INTEGER, status TEXT, workflow_name TEXT);
|
||||
CREATE TABLE IF NOT EXISTS workflow_agents (
|
||||
agent_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT,
|
||||
agent_type TEXT, description TEXT,
|
||||
phase TEXT, label TEXT, model TEXT, state TEXT,
|
||||
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS index_state (
|
||||
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS summaries (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
|
||||
source TEXT, content TEXT);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, uuid, session_id, text)
|
||||
VALUES (new.rowid, new.uuid, new.session_id, new.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
|
||||
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
|
||||
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
|
||||
INSERT INTO messages_fts(rowid, uuid, session_id, text)
|
||||
VALUES (new.rowid, new.uuid, new.session_id, new.text);
|
||||
END;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_tc_session_name ON tool_calls(session_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tc_file ON tool_calls(file_path);
|
||||
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, anchors TEXT, summary TEXT, created_at TEXT,
|
||||
deleted_at TEXT, deleted_reason TEXT);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
id UNINDEXED, path, summary,
|
||||
content=memories, content_rowid=rowid,
|
||||
tokenize='unicode61 remove_diacritics 1');
|
||||
CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN
|
||||
INSERT INTO memories_fts(rowid, id, path, summary)
|
||||
VALUES (new.rowid, new.id, new.path, new.summary);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
|
||||
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
|
||||
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
|
||||
INSERT INTO memories_fts(rowid, id, path, summary)
|
||||
VALUES (new.rowid, new.id, new.path, new.summary);
|
||||
END;
|
||||
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);
|
||||
Reference in New Issue
Block a user