diff --git a/bridge/memory.js b/bridge/memory.js index 567923a..335675e 100644 --- a/bridge/memory.js +++ b/bridge/memory.js @@ -113,7 +113,8 @@ function opGet(state, args) { for (const e of state.entries) { if (ids.indexOf(e.id) !== -1) { found.push(e); totalBytes += e.bytes || 0; } } - for (const e of found) { e.lastRecalled = Date.now(); e.recallCount = (e.recallCount || 0) + 1; } + const nowTs = nowIso(); + for (const e of found) { e.lastRecalled = nowTs; e.recallCount = (e.recallCount || 0) + 1; } return { items: found.map(function (e) { return { id: e.id, key: e.key, track: e.track, scope: e.scope, importance: e.importance, tags: e.tags || [], text: e.text, bytes: e.bytes, version: e.version }; }), totalBytes: totalBytes, requested: ids.length, found: found.length }; } @@ -144,6 +145,41 @@ function opSearch(state, args) { return { items: hits.map(function (e) { return { id: e.id, key: e.key, track: e.track, importance: e.importance, state: e.state || null, tags: e.tags || [], bytes: e.bytes, preview: preview(e.text, pv) }; }), totalBytes: totalBytes, matched: hits.length }; } +function opStats(state, args) { + const now = Date.now(); + const staleDays = Math.max(1, Number(args.staleDays) || 30); + const staleMs = staleDays * 24 * 3600 * 1000; + const all = state.entries.map(function (e) { + const created = e.createdAt ? Date.parse(e.createdAt) : now; + const recalled = e.lastRecalled ? Date.parse(e.lastRecalled) : 0; + return { + id: e.id, key: e.key, track: e.track, scope: e.scope, + importance: e.importance, bytes: e.bytes || 0, + recallCount: e.recallCount || 0, + lastRecalled: e.lastRecalled || null, + ageDays: Math.round((now - created) / 86400000), + idleDays: recalled ? Math.round((now - recalled) / 86400000) : Math.round((now - created) / 86400000), + }; + }); + const used = all.filter(function (e) { return e.recallCount > 0; }).sort(function (a, b) { return b.recallCount - a.recallCount; }); + const dead = all.filter(function (e) { return e.recallCount === 0 && e.idleDays >= staleDays; }).sort(function (a, b) { return b.bytes - a.bytes; }); + const fresh = all.filter(function (e) { return e.recallCount === 0 && e.idleDays < staleDays; }); + const neverAlways = all.filter(function (e) { return e.importance === 'always' && e.recallCount === 0; }); + let totalBytes = 0; for (const e of all) totalBytes += e.bytes; + return { + total: all.length, + totalBytes: totalBytes, + usedCount: used.length, + neverUsedCount: all.length - used.length, + staleDays: staleDays, + top: used.slice(0, Number(args.limit) || 15), + dead: dead.slice(0, Number(args.limit) || 15), + freshCount: fresh.length, + neverAlwaysCount: neverAlways.length, + neverAlways: neverAlways.slice(0, 20), + }; +} + function opBudgets(state) { const out = []; const groups = {}; @@ -255,6 +291,7 @@ function query(state, args) { if (op === 'unread') return opUnread(state, args); if (op === 'search') return opSearch(state, args); if (op === 'budgets') return opBudgets(state); + if (op === 'stats') return opStats(state, args); if (op === 'write') return opWrite(state, Object.assign({}, args, { op: args.subop || 'add' })); // note: add/replace/remove handled above throw new Error('unknown op: ' + op); @@ -271,6 +308,12 @@ function getInjection(state, chatId, workspaceKey) { if (e.importance !== 'always') continue; if (e.track === 'agent' && e.scope === 'user-global') { entries.push(e); continue; } } + // Recall accounting: every entry that goes into the injected block counts. + const nowTs = nowIso(); + for (const e of entries) { + e.lastRecalled = nowTs; + e.recallCount = (e.recallCount || 0) + 1; + } const texts = entries.map(function (e) { return String(e.text); }); const fpBase = entries.map(function (e) { return e.id + ':' + (e.version || 1); }).sort().join('|'); const fp = crypto.createHash('sha256').update(fpBase).digest('hex').slice(0, 16); diff --git a/bridge/tools.js b/bridge/tools.js index f307b4b..95ef6e2 100644 --- a/bridge/tools.js +++ b/bridge/tools.js @@ -53,7 +53,7 @@ const TOOL_DEFS = [ { name: 'dsh_git_diff', description: 'git diff (read-only, dumps to file if too large)', params: { path: 'string?', staged: 'bool?', maxBytes: 'int? default 48000' } }, { name: 'dsh_handoff', description: '[Recommended for multi-step tasks] Local agent (dsh headless) runs the full plan autonomously: reads/searches/edits/runs commands with no step-by-step driving, then returns only the final result. planText must be a self-contained full plan (with acceptance criteria). After calling, end your reply immediately; do not output further tool calls', params: { planText: 'string full plan text', workspace: 'string?', taskNote: 'string? task title (used for log names)', timeoutMs: 'int? default 300000, max 1800000' } }, { name: 'dsh_log', description: 'Read tail of execution log (.dsh-memory/logs/execution-log.jsonl, one entry per tool call)', params: { lines: 'int? default 100, max 1000' } }, - { name: 'dsh_memory', description: 'Persistent memory. op: list|get|recent|unread|search|budgets|directives|add|replace|remove. list/get/recent/unread/search return items with bytes and totalBytes in meta; always check totalBytes before get. write op: add|replace|remove.', params: { op: 'string', track: 'string?', scope: 'string?', importance: 'string?', since: 'string?', limit: 'int?', ids: 'string[]?', id: 'string?', query: 'string?', entry: 'object?', text: 'string?', tags: 'string[]?', state: 'string?', chatId: 'string?' } }, + { name: 'dsh_memory', description: 'Persistent memory. op: list|get|recent|unread|search|budgets|stats|directives|add|replace|remove. list/get/recent/unread/search return items with bytes and totalBytes in meta; always check totalBytes before get. write op: add|replace|remove.', params: { op: 'string', track: 'string?', scope: 'string?', importance: 'string?', since: 'string?', limit: 'int?', ids: 'string[]?', id: 'string?', query: 'string?', entry: 'object?', text: 'string?', tags: 'string[]?', state: 'string?', chatId: 'string?' } }, ]; // ---------------------------------------------------------------------------