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.
@@ -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 since 0.17.1 (L-1-106 / audit F1): consolidate deletes the original rows after
104
- // writing a summary that keeps only the top 3 × 200 characters, ignores thread_id, and cascades
105
- // away their edges — reasons and history lost with no action from the user. It now runs only
106
- // when asked: LINKSEE_AUTO_CONSOLIDATE=1 (or `--auto-consolidate` on the server command line).
107
- // There is no `consolidate` tool (merged away in 0.7.0). Rows already removed by earlier versions
108
- // are not restored.
109
- if (autoConsolidateEnabled(process.env, process.argv.slice(2)))
110
- setTimeout(() => {
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
- let shouldRun = true;
113
- try {
114
- // Check if consolidations table exists before querying
115
- const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='consolidations'").get();
116
- if (tableExists) {
117
- const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
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 { /* non-fatal */ }
131
- }, 3000);
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
- const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
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
- const result = db
527
- .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected, thread_id) VALUES (?, ?, ?, ?, ?, ?)')
528
- .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
529
- db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
530
- const mom = refreshMomentumForEntity(db, entityId);
531
- const memoryId = Number(result.lastInsertRowid);
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
- function runFtsQuery(query, layer, limit, axis) {
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 ? scored.filter((s) => s.heat_band === band) : scored;
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
- // Auto-sweep — also respect pin (importance >= 0.9) as protection
994
- const rows = db
995
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
996
- FROM memories
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
- // Preview: simulate by running against a transaction we roll back
1041
- // We don't have a clean "preview" path in lib/consolidate, so do a best-effort
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: candidates.length,
1062
- memories_replaced_if_run: totalReplaced,
1063
- preview: candidates.slice(0, 20).map((c) => ({
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.c,
1218
+ count: c.ids.length,
1067
1219
  })),
1068
- hint: 'Set dry_run=false to actually consolidate.',
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