linksee-memory 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/bin/consolidate.d.ts +12 -0
- package/dist/bin/consolidate.js +216 -0
- package/dist/bin/import-sessions.js +29 -98
- package/dist/bin/sync-session.js +1 -1
- package/dist/lib/consolidate.d.ts +61 -6
- package/dist/lib/consolidate.js +199 -103
- package/dist/lib/distill-triage.js +9 -2
- package/dist/lib/forgetting.js +4 -3
- package/dist/lib/session-import.d.ts +22 -0
- package/dist/lib/session-import.js +161 -0
- package/dist/lib/supersede.d.ts +35 -0
- package/dist/lib/supersede.js +130 -0
- package/dist/lib/test-faults.d.ts +7 -0
- package/dist/lib/test-faults.js +16 -0
- package/dist/mcp/server.js +265 -110
- package/dist/skill/SKILL.md +17 -15
- package/package.json +4 -2
package/dist/mcp/server.js
CHANGED
|
@@ -8,9 +8,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import { openDb, runMigrations, DbRefusedError } from '../db/migrate.js';
|
|
10
10
|
import { computeHeat } from '../lib/heat-index.js';
|
|
11
|
-
import { decideForgetting } from '../lib/forgetting.js';
|
|
12
11
|
import { refreshMomentumForEntity } from '../lib/momentum.js';
|
|
13
|
-
import { consolidate as runConsolidate, autoConsolidateEnabled } from '../lib/consolidate.js';
|
|
12
|
+
import { consolidate as runConsolidate, autoConsolidateEnabled, planConsolidate, CONSOLIDATED_SQL } from '../lib/consolidate.js';
|
|
14
13
|
import { isPastedExternalContent } from '../lib/session-parser.js';
|
|
15
14
|
import { inferAltitude, inferType, inferState } from '../lib/session-extractor.js';
|
|
16
15
|
import { normalizeEntityName } from '../lib/normalize.js';
|
|
@@ -27,6 +26,7 @@ import { logAnchorTouch } from '../lib/anchor-touch.js';
|
|
|
27
26
|
import { triageDistillQueue, distillPending } from '../lib/distill-triage.js';
|
|
28
27
|
import { whereAmI, listMapProjects } from '../lib/map-view.js';
|
|
29
28
|
import { buildShareDraft, KINDS, RUNTIMES } from '../lib/share-draft.js';
|
|
29
|
+
import { parseSupersedesArg, checkSupersedes, applySupersedes, SupersedeRefused, mcpSource, byFromSource } from '../lib/supersede.js';
|
|
30
30
|
import { readFileSync, existsSync } from 'node:fs';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
32
|
import { dirname, join } from 'node:path';
|
|
@@ -66,6 +66,7 @@ const SUBCOMMANDS = {
|
|
|
66
66
|
'share-draft': 'share-draft.js',
|
|
67
67
|
migrate: 'migrate-legacy.js', // 0.17.2: schema 1–5 → current, without starting the server
|
|
68
68
|
runtime: 'runtime.js', // 0.17.2: runtime list | prune
|
|
69
|
+
consolidate: 'consolidate.js', // 0.18.1: consolidate --dry-run | --apply (marks, never deletes)
|
|
69
70
|
};
|
|
70
71
|
const subcommand = process.argv[2];
|
|
71
72
|
if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
|
|
@@ -100,35 +101,46 @@ try {
|
|
|
100
101
|
catch { /* never block startup */ }
|
|
101
102
|
// Auto-maintenance: consolidate stale memories on startup (non-blocking).
|
|
102
103
|
//
|
|
103
|
-
// OFF by default
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
// OFF by default (0.17.1 intended this; 0.18.1 makes it hold and takes the delete out):
|
|
105
|
+
// up to 0.18.0 consolidate deleted the original rows after writing a summary that kept only the
|
|
106
|
+
// top 3 × 200 characters, then a forget-sweep deleted every other cold row — reasons and history
|
|
107
|
+
// lost with no action from the user (1,360 rows on 2026-10-29, 559 on 2026-11-05 on the
|
|
108
|
+
// maintainer's DB). Since 0.18.1 nothing is deleted: originals are marked `consolidated_into`
|
|
109
|
+
// and hidden from the default recall. It still runs only when asked: LINKSEE_AUTO_CONSOLIDATE=1
|
|
110
|
+
// (or `--auto-consolidate` on the server command line); by hand: `npx linksee-memory consolidate
|
|
111
|
+
// --dry-run | --apply`. When off, one stderr line says how many rows a run would fold, so a
|
|
112
|
+
// growing database is noticed without anything being touched. There is no `consolidate` MCP tool
|
|
113
|
+
// (merged away in 0.7.0). Rows already removed by earlier versions are not restored.
|
|
114
|
+
const autoConsolidateOn = autoConsolidateEnabled(process.env, process.argv.slice(2));
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
try {
|
|
117
|
+
if (!autoConsolidateOn) {
|
|
118
|
+
// Read-only: count what a run would fold. Never throws out of here, never writes.
|
|
119
|
+
const plan = planConsolidate(db, { min_age_days: 7 });
|
|
120
|
+
process.stderr.write(`[linksee-memory] auto-consolidate is off (set LINKSEE_AUTO_CONSOLIDATE=1 to enable); ${plan.wouldFold} cold memories would be folded` +
|
|
121
|
+
` (run: npx linksee-memory consolidate --dry-run)\n`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let shouldRun = true;
|
|
111
125
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
|
|
119
|
-
shouldRun = false;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
shouldRun = false; /* genuinely unexpected — skip to be safe */
|
|
124
|
-
}
|
|
125
|
-
if (shouldRun) {
|
|
126
|
-
runConsolidate(db, { scope: 'all', min_age_days: 7 });
|
|
127
|
-
process.stderr.write('[linksee-memory] auto-consolidate complete\n');
|
|
126
|
+
// Check if consolidations table exists before querying
|
|
127
|
+
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='consolidations'").get();
|
|
128
|
+
if (tableExists) {
|
|
129
|
+
const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
|
|
130
|
+
if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
|
|
131
|
+
shouldRun = false;
|
|
128
132
|
}
|
|
129
133
|
}
|
|
130
|
-
catch {
|
|
131
|
-
|
|
134
|
+
catch {
|
|
135
|
+
shouldRun = false; /* genuinely unexpected — skip to be safe */
|
|
136
|
+
}
|
|
137
|
+
if (shouldRun) {
|
|
138
|
+
const r = runConsolidate(db, { scope: 'all', min_age_days: 7 });
|
|
139
|
+
process.stderr.write(`[linksee-memory] auto-consolidate complete: ${r.clustersCompressed} summaries, ${r.memoriesFolded} rows marked consolidated_into (0 deleted)\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch { /* non-fatal */ }
|
|
143
|
+
}, 3000);
|
|
132
144
|
const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, {
|
|
133
145
|
capabilities: {
|
|
134
146
|
tools: {},
|
|
@@ -202,6 +214,10 @@ const TOOLS = [
|
|
|
202
214
|
force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
|
|
203
215
|
memory_id: { type: 'number', description: 'Set to update an existing memory instead of creating. Only content/layer/importance are changed.' },
|
|
204
216
|
forget: { type: 'boolean', default: false, description: 'Set true + memory_id to delete a memory. Caveat-layer and pinned memories cannot be deleted.' },
|
|
217
|
+
supersedes: {
|
|
218
|
+
description: 'Create mode only: the memory id(s) this new memory replaces (a reversed or updated decision). Writes a supersedes link and marks the old memory superseded; recall then hides it unless include_superseded is true. Refused — and nothing is saved — if an id does not exist, belongs to another entity, or is a raw session capture — and when content is JSON missing any of altitude/type/state (send plain text or all three).',
|
|
219
|
+
anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' } }],
|
|
220
|
+
},
|
|
205
221
|
},
|
|
206
222
|
},
|
|
207
223
|
},
|
|
@@ -226,6 +242,8 @@ const TOOLS = [
|
|
|
226
242
|
path: { type: 'string', description: 'File path or substring. When set, returns file edit history with per-edit user-intent context instead of memory search.' },
|
|
227
243
|
max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
|
|
228
244
|
scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
|
|
245
|
+
include_superseded: { type: 'boolean', default: false, description: 'Also return memories that a newer memory superseded (hidden by default; each carries superseded_by).' },
|
|
246
|
+
include_consolidated: { type: 'boolean', default: false, description: 'Also return memories that consolidate folded into a summary (hidden by default, still in the DB; each carries consolidated_into = the summary id).' },
|
|
229
247
|
explain: { type: 'boolean', default: false, description: 'Include ranking internals (composite, heat, band, momentum, match_reasons, score_breakdown). Off by default so more memories fit the token budget.' },
|
|
230
248
|
where: { description: 'Locate on the Current Truth Map. A topic string, or true to auto-locate from the files you edited recently. Returns your node, journey stage, blast radius, and the decision behind it.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
|
|
231
249
|
project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
|
|
@@ -502,7 +520,16 @@ function handleRemember(args) {
|
|
|
502
520
|
hint: 'If you meant to save an extracted insight from that paste, summarize it in your own words first.',
|
|
503
521
|
});
|
|
504
522
|
}
|
|
505
|
-
|
|
523
|
+
// supersedes: parse before anything is written; a malformed value refuses the whole call.
|
|
524
|
+
let supersedeIds;
|
|
525
|
+
try {
|
|
526
|
+
supersedeIds = parseSupersedesArg(args.supersedes);
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
if (e instanceof SupersedeRefused)
|
|
530
|
+
return JSON.stringify({ ok: false, rejected: 'supersedes', reason: e.message, saved: false });
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
506
533
|
const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
|
|
507
534
|
// Auto-classify: if content is plain text (not JSON with 3-axis fields),
|
|
508
535
|
// wrap it in structured JSON so VIRTUAL generated columns can extract axes.
|
|
@@ -514,6 +541,24 @@ function handleRemember(args) {
|
|
|
514
541
|
}
|
|
515
542
|
}
|
|
516
543
|
catch { /* not JSON = needs wrapping */ }
|
|
544
|
+
// With supersedes, a JSON object that lacks any of altitude/type/state is refused before
|
|
545
|
+
// anything is written: it would be re-wrapped as {what: "<the JSON>"} and its state re-guessed,
|
|
546
|
+
// so an explicit state in it could not be honoured or even confirmed. No clever rescue —
|
|
547
|
+
// plain text, or JSON with all three, only.
|
|
548
|
+
if (supersedeIds.length > 0 && !isAlreadyStructured) {
|
|
549
|
+
let parsedObj = false;
|
|
550
|
+
try {
|
|
551
|
+
const p = JSON.parse(rawContent);
|
|
552
|
+
parsedObj = p !== null && typeof p === 'object';
|
|
553
|
+
}
|
|
554
|
+
catch { /* plain text */ }
|
|
555
|
+
if (parsedObj) {
|
|
556
|
+
return JSON.stringify({
|
|
557
|
+
ok: false, rejected: 'supersedes', saved: false,
|
|
558
|
+
reason: 'with supersedes, content must be plain text or JSON with all of altitude, type and state. This JSON lacks at least one, so its state cannot be confirmed. Send the content as plain text, or add altitude/type/state, and save again.',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
517
562
|
if (!isAlreadyStructured) {
|
|
518
563
|
const structured = {
|
|
519
564
|
altitude: inferAltitude(rawContent),
|
|
@@ -523,20 +568,97 @@ function handleRemember(args) {
|
|
|
523
568
|
};
|
|
524
569
|
rawContent = JSON.stringify(structured);
|
|
525
570
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
571
|
+
// A memory that names what it supersedes IS the current decision.
|
|
572
|
+
// • State GUESSED from the text (inferState reads 「代わりに」 / "replaced" — words about the
|
|
573
|
+
// OLD decision — as this row being superseded): corrected to 'decided', and the reply says so.
|
|
574
|
+
// • State given EXPLICITLY as 'superseded' together with supersedes: the two contradict each
|
|
575
|
+
// other and we cannot tell which one the caller meant, so nothing is written.
|
|
576
|
+
let stateNote;
|
|
577
|
+
if (supersedeIds.length > 0) {
|
|
578
|
+
let c;
|
|
579
|
+
try {
|
|
580
|
+
c = JSON.parse(rawContent);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
c = null; /* structured above, always JSON */
|
|
584
|
+
}
|
|
585
|
+
if (c && typeof c === 'object' && c.state === 'superseded') {
|
|
586
|
+
if (isAlreadyStructured) {
|
|
587
|
+
return JSON.stringify({
|
|
588
|
+
ok: false, rejected: 'supersedes', saved: false,
|
|
589
|
+
reason: 'content says state "superseded" but the memory also supersedes another one — a memory that replaces an older one is the current one. Remove state "superseded" (or set the state you mean) and save again.',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
c.state = 'decided';
|
|
593
|
+
rawContent = JSON.stringify(c);
|
|
594
|
+
stateNote = 'the state guessed from the text was "superseded"; stored as "decided" because this memory supersedes another one';
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Who wrote it: the client name/version the MCP host sent in initialize (self-reported).
|
|
598
|
+
const source = mcpSource(server.getClientVersion());
|
|
599
|
+
// Entity, row, event, supersedes edges and the old rows' state change commit together or not
|
|
600
|
+
// at all: if any supersedes id cannot be confirmed, nothing — not even a new entity — is left.
|
|
601
|
+
let entityId, memoryId, supersededAnchors = [];
|
|
602
|
+
try {
|
|
603
|
+
({ entityId, memoryId, supersededAnchors } = db.transaction(() => {
|
|
604
|
+
const eid = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
|
|
605
|
+
const result = db
|
|
606
|
+
.prepare('INSERT INTO memories (entity_id, layer, content, importance, protected, source, thread_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
607
|
+
.run(eid, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, source, args.thread_id ?? null);
|
|
608
|
+
const mid = Number(result.lastInsertRowid);
|
|
609
|
+
// Checked after the INSERT so a self-reference is seen as one (the id exists only now);
|
|
610
|
+
// a refusal throws and the transaction rolls the INSERT back.
|
|
611
|
+
const oldRows = supersedeIds.length > 0 ? checkSupersedes(db, supersedeIds, eid, mid) : [];
|
|
612
|
+
if (oldRows.length > 0)
|
|
613
|
+
applySupersedes(db, mid, oldRows);
|
|
614
|
+
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(eid, 'memory_stored', JSON.stringify(supersedeIds.length > 0 ? { layer, memory_id: mid, supersedes: supersedeIds } : { layer, memory_id: mid }));
|
|
615
|
+
const anchors = [];
|
|
616
|
+
for (const r of oldRows) {
|
|
617
|
+
try {
|
|
618
|
+
const c = JSON.parse(r.content);
|
|
619
|
+
if (c && Number.isInteger(c.anchor_id))
|
|
620
|
+
anchors.push(c.anchor_id);
|
|
621
|
+
}
|
|
622
|
+
catch { /* plain text */ }
|
|
623
|
+
}
|
|
624
|
+
return { entityId: eid, memoryId: mid, supersededAnchors: anchors };
|
|
625
|
+
})());
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
if (e instanceof SupersedeRefused) {
|
|
629
|
+
return JSON.stringify({ ok: false, rejected: 'supersedes', memory_id: e.memoryId, reason: e.message, saved: false });
|
|
630
|
+
}
|
|
631
|
+
throw e;
|
|
632
|
+
}
|
|
633
|
+
// The memory is committed from here on. Helpers that fail after this point must not turn a
|
|
634
|
+
// saved memory into ok:false — the caller would retry and save it twice.
|
|
635
|
+
const warnings = [];
|
|
636
|
+
let mom = null;
|
|
637
|
+
try {
|
|
638
|
+
mom = refreshMomentumForEntity(db, entityId);
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
warnings.push(`saved, but the momentum refresh failed: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
642
|
+
}
|
|
532
643
|
const out = {
|
|
533
644
|
ok: true,
|
|
534
645
|
memory_id: memoryId,
|
|
535
646
|
entity_id: entityId,
|
|
536
647
|
layer,
|
|
537
648
|
pinned: importance >= 0.9,
|
|
538
|
-
momentum: { score: mom.score, band: mom.band },
|
|
649
|
+
...(mom ? { momentum: { score: mom.score, band: mom.band } } : {}),
|
|
539
650
|
};
|
|
651
|
+
if (warnings.length > 0)
|
|
652
|
+
out.warnings = warnings;
|
|
653
|
+
if (supersedeIds.length > 0)
|
|
654
|
+
out.supersedes = supersedeIds;
|
|
655
|
+
if (stateNote)
|
|
656
|
+
out.state_note = stateNote;
|
|
657
|
+
if (supersededAnchors.length > 0) {
|
|
658
|
+
// The memory link does not touch the gate. Say so instead of retiring an anchor on our own.
|
|
659
|
+
out.anchors_still_enforced = supersededAnchors;
|
|
660
|
+
out.anchor_hint = `The superseded memory is enforced as anchor ${supersededAnchors.map((a) => '#' + a).join(', ')}; the gate still applies it. To retire it: resolve_drift({ anchor_id, action: 'supersede', superseded_by: <new anchor id> }).`;
|
|
661
|
+
}
|
|
540
662
|
// "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
|
|
541
663
|
// Picking remember meant the decision was stored but never re-injected — the exact failure
|
|
542
664
|
// the product exists to prevent. One call now does both when the caller asks for it.
|
|
@@ -631,12 +753,33 @@ function appendAxisFilters(sql, params, filters) {
|
|
|
631
753
|
}
|
|
632
754
|
return sql;
|
|
633
755
|
}
|
|
634
|
-
|
|
756
|
+
// NULL-safe: plain-text rows and JSON without a state have mem_state NULL, and
|
|
757
|
+
// `NOT (NULL = 'superseded' OR …)` is NULL — which would drop them (Codex review 2 of #21).
|
|
758
|
+
const SUPERSEDED_SQL = `(COALESCE(m.mem_state, '') = 'superseded' OR EXISTS (SELECT 1 FROM memory_edges me WHERE me.to_memory_id = m.id AND me.relation = 'supersedes'))`;
|
|
759
|
+
const SHOW_ALL = { sup: 'include', con: 'include' };
|
|
760
|
+
function appendSupersededFilter(sql, mode) {
|
|
761
|
+
if (mode === 'exclude')
|
|
762
|
+
return sql + ` AND NOT ${SUPERSEDED_SQL}`;
|
|
763
|
+
if (mode === 'only')
|
|
764
|
+
return sql + ` AND ${SUPERSEDED_SQL}`;
|
|
765
|
+
return sql;
|
|
766
|
+
}
|
|
767
|
+
function appendConsolidatedFilter(sql, mode) {
|
|
768
|
+
if (mode === 'exclude')
|
|
769
|
+
return sql + ` AND NOT ${CONSOLIDATED_SQL}`;
|
|
770
|
+
if (mode === 'only')
|
|
771
|
+
return sql + ` AND ${CONSOLIDATED_SQL}`;
|
|
772
|
+
return sql;
|
|
773
|
+
}
|
|
774
|
+
function appendHiddenFilters(sql, h) {
|
|
775
|
+
return appendConsolidatedFilter(appendSupersededFilter(sql, h.sup), h.con);
|
|
776
|
+
}
|
|
777
|
+
function runFtsQuery(query, layer, limit, axis, hidden = SHOW_ALL) {
|
|
635
778
|
let sql = `
|
|
636
779
|
SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
|
|
637
780
|
m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
|
|
638
781
|
m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
|
|
639
|
-
bm25(memories_fts) as bm25_score
|
|
782
|
+
m.source as _source, bm25(memories_fts) as bm25_score
|
|
640
783
|
FROM memories_fts
|
|
641
784
|
JOIN memories m ON m.id = memories_fts.rowid
|
|
642
785
|
JOIN entities e ON e.id = m.entity_id
|
|
@@ -649,16 +792,17 @@ function runFtsQuery(query, layer, limit, axis) {
|
|
|
649
792
|
}
|
|
650
793
|
if (axis)
|
|
651
794
|
sql = appendAxisFilters(sql, params, axis);
|
|
795
|
+
sql = appendHiddenFilters(sql, hidden);
|
|
652
796
|
sql += ' ORDER BY bm25_score ASC LIMIT ?';
|
|
653
797
|
params.push(limit);
|
|
654
798
|
return db.prepare(sql).all(...params);
|
|
655
799
|
}
|
|
656
|
-
function runLikeQuery(query, entityName, layer, limit, axis) {
|
|
800
|
+
function runLikeQuery(query, entityName, layer, limit, axis, hidden = SHOW_ALL) {
|
|
657
801
|
let sql = `
|
|
658
802
|
SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
|
|
659
803
|
m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
|
|
660
804
|
m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
|
|
661
|
-
0 as bm25_score
|
|
805
|
+
m.source as _source, 0 as bm25_score
|
|
662
806
|
FROM memories m
|
|
663
807
|
JOIN entities e ON e.id = m.entity_id
|
|
664
808
|
WHERE 1=1
|
|
@@ -674,6 +818,7 @@ function runLikeQuery(query, entityName, layer, limit, axis) {
|
|
|
674
818
|
}
|
|
675
819
|
if (axis)
|
|
676
820
|
sql = appendAxisFilters(sql, params, axis);
|
|
821
|
+
sql = appendHiddenFilters(sql, hidden);
|
|
677
822
|
if (query && !entityName) {
|
|
678
823
|
sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
|
|
679
824
|
params.push(`%${query}%`, `%${query}%`);
|
|
@@ -727,6 +872,13 @@ function contentSignature(raw, entityId) {
|
|
|
727
872
|
return ''; // too short to dedup safely
|
|
728
873
|
return `${entityId}::${norm}`;
|
|
729
874
|
}
|
|
875
|
+
// The summary id a folded row points at (0.18.1), or undefined for an unfolded / plain-text row.
|
|
876
|
+
function consolidatedIntoOf(parsedContent) {
|
|
877
|
+
if (!parsedContent || typeof parsedContent !== 'object' || Array.isArray(parsedContent))
|
|
878
|
+
return undefined;
|
|
879
|
+
const v = parsedContent.consolidated_into;
|
|
880
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
881
|
+
}
|
|
730
882
|
function handleRecall(args) {
|
|
731
883
|
// max_tokens is enforced for real at assembly time (greedy fill by measured
|
|
732
884
|
// serialized size), not via a crude fixed per-memory estimate. hardLimit caps COUNT.
|
|
@@ -750,9 +902,37 @@ function handleRecall(args) {
|
|
|
750
902
|
let searchMethod = 'like';
|
|
751
903
|
const ftsQuery = toFtsQuery(args.query ?? '');
|
|
752
904
|
const canUseFts = !!ftsQuery && !args.entity_name;
|
|
905
|
+
// Superseded rows are left out in SQL by default; asking for them (include_superseded, or
|
|
906
|
+
// mem_state:'superseded') keeps them. The hidden ones are counted with the same filters.
|
|
907
|
+
const showSuperseded = args?.include_superseded === true || axis.mem_state === 'superseded';
|
|
908
|
+
// Consolidated rows (0.18.1: marked `consolidated_into`, kept in the table) are hidden the
|
|
909
|
+
// same way; include_consolidated shows them. The two counts do not overlap: a row that is
|
|
910
|
+
// both superseded and consolidated is counted once, under superseded.
|
|
911
|
+
const showConsolidated = args?.include_consolidated === true;
|
|
912
|
+
const sup = showSuperseded ? 'include' : 'exclude';
|
|
913
|
+
const con = showConsolidated ? 'include' : 'exclude';
|
|
914
|
+
const hidden = { sup, con };
|
|
915
|
+
const HIDDEN_COUNT_CAP = 1000;
|
|
916
|
+
const countHidden = (h) => {
|
|
917
|
+
const hiddenIds = new Set();
|
|
918
|
+
if (canUseFts) {
|
|
919
|
+
for (const r of runFtsQuery(ftsQuery, layer, HIDDEN_COUNT_CAP, axis, h))
|
|
920
|
+
hiddenIds.add(r.id);
|
|
921
|
+
for (const r of runLikeQuery(args.query, undefined, layer, HIDDEN_COUNT_CAP, axis, h))
|
|
922
|
+
hiddenIds.add(r.id);
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
for (const r of runLikeQuery(args.query, args.entity_name, layer, HIDDEN_COUNT_CAP, axis, h))
|
|
926
|
+
hiddenIds.add(r.id);
|
|
927
|
+
}
|
|
928
|
+
return hiddenIds.size;
|
|
929
|
+
};
|
|
930
|
+
const supersededHidden = showSuperseded ? 0 : countHidden({ sup: 'only', con: 'include' });
|
|
931
|
+
const consolidatedHidden = showConsolidated ? 0 : countHidden({ sup, con: 'only' });
|
|
932
|
+
const capped = (n) => (n >= HIDDEN_COUNT_CAP ? `${HIDDEN_COUNT_CAP}+` : n);
|
|
753
933
|
if (canUseFts) {
|
|
754
|
-
const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis);
|
|
755
|
-
const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis);
|
|
934
|
+
const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis, hidden);
|
|
935
|
+
const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis, hidden);
|
|
756
936
|
const seen = new Map();
|
|
757
937
|
for (const r of ftsRows)
|
|
758
938
|
seen.set(r.id, { ...r, _via: 'fts' });
|
|
@@ -773,7 +953,7 @@ function handleRecall(args) {
|
|
|
773
953
|
searchMethod = 'like';
|
|
774
954
|
}
|
|
775
955
|
else {
|
|
776
|
-
rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis).map((r) => ({ ...r, _via: 'like' }));
|
|
956
|
+
rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis, hidden).map((r) => ({ ...r, _via: 'like' }));
|
|
777
957
|
searchMethod = 'like';
|
|
778
958
|
}
|
|
779
959
|
const useFts = searchMethod !== 'like';
|
|
@@ -868,8 +1048,27 @@ function handleRecall(args) {
|
|
|
868
1048
|
},
|
|
869
1049
|
};
|
|
870
1050
|
});
|
|
1051
|
+
// Supersedes links among the candidates, both directions (indexed on both columns).
|
|
1052
|
+
const supersededBy = new Map(); // old id → newest id that supersedes it
|
|
1053
|
+
const supersedesOf = new Map(); // new id → old ids
|
|
1054
|
+
const allIds = scored.map((r) => r.id);
|
|
1055
|
+
for (let i = 0; i < allIds.length; i += 400) { // chunked: offset can make the pool large
|
|
1056
|
+
const ids = allIds.slice(i, i + 400);
|
|
1057
|
+
const ph = ids.map(() => '?').join(',');
|
|
1058
|
+
const links = db.prepare(`SELECT from_memory_id AS f, to_memory_id AS t FROM memory_edges
|
|
1059
|
+
WHERE relation = 'supersedes' AND (from_memory_id IN (${ph}) OR to_memory_id IN (${ph}))
|
|
1060
|
+
ORDER BY from_memory_id ASC`).all(...ids, ...ids);
|
|
1061
|
+
for (const l of links) {
|
|
1062
|
+
if (supersededBy.has(l.t) && (supersedesOf.get(l.f) ?? []).includes(l.t))
|
|
1063
|
+
continue; // seen in an earlier chunk
|
|
1064
|
+
supersededBy.set(l.t, Math.max(supersededBy.get(l.t) ?? 0, l.f));
|
|
1065
|
+
supersedesOf.set(l.f, [...(supersedesOf.get(l.f) ?? []), l.t]);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
// Superseded rows were already left out in SQL (before the LIMIT) unless asked for.
|
|
1069
|
+
const visible = scored;
|
|
871
1070
|
// Apply band filter AFTER scoring (needs heat.band)
|
|
872
|
-
const filtered = band ?
|
|
1071
|
+
const filtered = band ? visible.filter((s) => s.heat_band === band) : visible;
|
|
873
1072
|
// Sort by composite (best first)
|
|
874
1073
|
filtered.sort((a, b) => b.composite_score - a.composite_score);
|
|
875
1074
|
// Dedup near-identical memories (same entity + near-identical core text).
|
|
@@ -922,6 +1121,11 @@ function handleRecall(args) {
|
|
|
922
1121
|
content: parsedContent,
|
|
923
1122
|
importance: r.importance,
|
|
924
1123
|
pinned: r.importance >= 0.9,
|
|
1124
|
+
// One short field each, only when the row has it (no tokens spent on absence).
|
|
1125
|
+
...(byFromSource(r._source) ? { by: byFromSource(r._source) } : {}),
|
|
1126
|
+
...(supersedesOf.has(r.id) ? { supersedes: supersedesOf.get(r.id).map((x) => `#${x}`).join(' ') } : {}),
|
|
1127
|
+
...(supersededBy.has(r.id) ? { superseded_by: supersededBy.get(r.id) } : {}),
|
|
1128
|
+
...(consolidatedIntoOf(parsedContent) !== undefined ? { consolidated_into: consolidatedIntoOf(parsedContent) } : {}),
|
|
925
1129
|
// Ranking internals only on request — they roughly double the per-memory cost and an
|
|
926
1130
|
// agent acting on the memory needs what/why/state, not the scorer's arithmetic.
|
|
927
1131
|
...(args?.explain ? {
|
|
@@ -966,6 +1170,8 @@ function handleRecall(args) {
|
|
|
966
1170
|
type: axis.mem_type ?? null,
|
|
967
1171
|
state: axis.mem_state ?? null,
|
|
968
1172
|
},
|
|
1173
|
+
...(supersededHidden > 0 ? { superseded_hidden: capped(supersededHidden), superseded_hint: 'include_superseded: true to show them' } : {}),
|
|
1174
|
+
...(consolidatedHidden > 0 ? { consolidated_hidden: capped(consolidatedHidden), consolidated_hint: 'include_consolidated: true to show them (rows folded into a summary; still in the DB)' } : {}),
|
|
969
1175
|
memories: memoriesOut,
|
|
970
1176
|
});
|
|
971
1177
|
}
|
|
@@ -990,82 +1196,28 @@ function handleForget(args) {
|
|
|
990
1196
|
const res = db.prepare('DELETE FROM memories WHERE id = ?').run(args.memory_id);
|
|
991
1197
|
return JSON.stringify({ ok: true, deleted: res.changes, memory_id: args.memory_id });
|
|
992
1198
|
}
|
|
993
|
-
//
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
WHERE protected = 0 AND importance < 0.9`)
|
|
998
|
-
.all();
|
|
999
|
-
const now = Math.floor(Date.now() / 1000);
|
|
1000
|
-
const actions = [];
|
|
1001
|
-
for (const r of rows) {
|
|
1002
|
-
const daysSince = (now - r.last_accessed_at) / 86400;
|
|
1003
|
-
const heat = computeHeat({
|
|
1004
|
-
accessesLast30d: daysSince < 30 ? r.access_count : 0,
|
|
1005
|
-
accessesLast90d: daysSince < 90 ? r.access_count : 0,
|
|
1006
|
-
daysSinceLastAccess: daysSince,
|
|
1007
|
-
totalAccesses: r.access_count,
|
|
1008
|
-
baseImportance: r.importance,
|
|
1009
|
-
});
|
|
1010
|
-
const action = decideForgetting({
|
|
1011
|
-
daysSinceLastAccess: daysSince,
|
|
1012
|
-
importance: r.importance,
|
|
1013
|
-
heatScore: heat.score,
|
|
1014
|
-
protected: r.protected === 1,
|
|
1015
|
-
layer: r.layer,
|
|
1016
|
-
altitude: r.altitude ?? undefined,
|
|
1017
|
-
});
|
|
1018
|
-
if (action !== 'keep')
|
|
1019
|
-
actions.push({ id: r.id, action });
|
|
1020
|
-
}
|
|
1021
|
-
const toDropIds = actions.filter((a) => a.action === 'drop').map((a) => a.id);
|
|
1022
|
-
if (!args.dry_run) {
|
|
1023
|
-
const del = db.prepare('DELETE FROM memories WHERE id = ?');
|
|
1024
|
-
const tx = db.transaction((ids) => { for (const id of ids)
|
|
1025
|
-
del.run(id); });
|
|
1026
|
-
tx(toDropIds);
|
|
1027
|
-
}
|
|
1028
|
-
return JSON.stringify({
|
|
1029
|
-
ok: true,
|
|
1030
|
-
dry_run: !!args.dry_run,
|
|
1031
|
-
scanned: rows.length,
|
|
1032
|
-
to_drop: toDropIds.length,
|
|
1033
|
-
to_compress: actions.filter((a) => a.action === 'compress').length,
|
|
1034
|
-
sample_ids_to_drop: toDropIds.slice(0, 10),
|
|
1035
|
-
});
|
|
1199
|
+
// 0.18.1: the id-less auto-sweep that used to follow here (DELETE every row whose forgetting
|
|
1200
|
+
// score crossed a threshold) is removed. No caller reached it; it is gone so that the only
|
|
1201
|
+
// memory DELETE in the server is the explicit one above.
|
|
1202
|
+
return JSON.stringify({ ok: false, error: 'memory_id required: forget deletes one memory by id; there is no sweep' });
|
|
1036
1203
|
}
|
|
1037
1204
|
function handleConsolidate(args) {
|
|
1038
1205
|
const dryRun = !!args?.dry_run;
|
|
1039
1206
|
if (dryRun) {
|
|
1040
|
-
//
|
|
1041
|
-
|
|
1042
|
-
// read-only audit: count candidates using the same rules.
|
|
1043
|
-
const now = Math.floor(Date.now() / 1000);
|
|
1044
|
-
const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
|
|
1045
|
-
const candidates = db.prepare(`
|
|
1046
|
-
SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
|
|
1047
|
-
FROM memories m
|
|
1048
|
-
JOIN entities e ON e.id = m.entity_id
|
|
1049
|
-
WHERE m.protected = 0
|
|
1050
|
-
AND m.importance < 0.9
|
|
1051
|
-
AND m.layer IN ('context', 'emotion', 'implementation')
|
|
1052
|
-
AND m.created_at <= ?
|
|
1053
|
-
GROUP BY m.entity_id, m.layer
|
|
1054
|
-
HAVING c >= 2
|
|
1055
|
-
ORDER BY c DESC
|
|
1056
|
-
`).all(ageCutoff);
|
|
1057
|
-
const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
|
|
1207
|
+
// Read-only preview, the same plan `--apply` would act on (0.18.1).
|
|
1208
|
+
const plan = planConsolidate(db, { min_age_days: typeof args?.min_age_days === 'number' ? args.min_age_days : undefined });
|
|
1058
1209
|
return JSON.stringify({
|
|
1059
1210
|
ok: true,
|
|
1060
1211
|
dry_run: true,
|
|
1061
|
-
clusters:
|
|
1062
|
-
|
|
1063
|
-
|
|
1212
|
+
clusters: plan.clusters.length,
|
|
1213
|
+
memories_folded_if_run: plan.wouldFold,
|
|
1214
|
+
deleted_if_run: 0,
|
|
1215
|
+
preview: plan.clusters.slice(0, 20).map((c) => ({
|
|
1064
1216
|
entity: c.entity_name,
|
|
1065
1217
|
layer: c.layer,
|
|
1066
|
-
count: c.
|
|
1218
|
+
count: c.ids.length,
|
|
1067
1219
|
})),
|
|
1068
|
-
hint: 'Set dry_run=false to
|
|
1220
|
+
hint: 'Set dry_run=false to consolidate (originals are kept and marked consolidated_into).',
|
|
1069
1221
|
});
|
|
1070
1222
|
}
|
|
1071
1223
|
const result = runConsolidate(db, {
|
|
@@ -1384,6 +1536,9 @@ async function handleRememberUnified(args) {
|
|
|
1384
1536
|
}
|
|
1385
1537
|
// Update mode
|
|
1386
1538
|
if (args.memory_id) {
|
|
1539
|
+
if (args.supersedes !== undefined && args.supersedes !== null) {
|
|
1540
|
+
return JSON.stringify({ ok: false, rejected: 'supersedes', reason: 'supersedes applies when creating a memory, not when updating one (memory_id). Remember the new decision without memory_id and pass supersedes: <old id>.', saved: false });
|
|
1541
|
+
}
|
|
1387
1542
|
return handleUpdateMemory(args);
|
|
1388
1543
|
}
|
|
1389
1544
|
// Create mode (default). Only content is required; everything an agent used to have to
|