linksee-memory 0.17.2 → 0.18.0

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 CHANGED
@@ -21,6 +21,8 @@ A new install has an empty memory, so "how did we solve this before?" finds noth
21
21
 
22
22
  1. In Claude Code or Codex, say: *"Remember: in this repo we decided to use X, because Y. Use Linksee."*
23
23
  2. Open a **new** session — in either one — and ask: *"What did we decide about X? Use Linksee."*
24
+ 3. Two repos: record a decision in repo A, then in repo B (e.g. Codex) ask the same. It **does** come back — the memory is per machine, not per repo — labelled `entity: repo-a` and `by: <client>`; `entity_name` on the recall narrows by a part of the entity name (`repo-a` also matches `repo-a-backup`).
25
+ 4. Reverse it: `remember` the new decision with `supersedes: <old memory_id>`. `recall` then shows only the new one (`supersedes #<id>`); `include_superseded: true` shows both.
24
26
 
25
27
  ## What it does not do
26
28
 
@@ -100,7 +102,7 @@ an agent can hold without a manual.
100
102
  | Tool | What it does |
101
103
  |---|---|
102
104
  | `recall` | **Start here.** No arguments → the **session brief**: what needs attention, where you are on the Map, open loops, top entities. `query` → search; `path` → a file's edit history with the user intent behind each edit; `where: "<topic>"` → your position on the Current Truth Map + blast radius; `dream: true` → the triage session (North Star, orphaned proposals, distill queue, friction). |
103
- | `remember` | **Save / update / delete.** `content` is the only required field — entity and layer default to the project you're in. Add `anchor: {}` to also record it as a decision (in Claude Code, re-injected on session start and before a matching Edit/Write/Bash); add `violation_signal` to it so a contradicting edit can be detected. |
105
+ | `remember` | **Save / update / delete.** `content` is the only required field — entity and layer default to the project you're in. Add `anchor: {}` to also record it as a decision (in Claude Code, re-injected on session start and before a matching Edit/Write/Bash); add `violation_signal` to it so a contradicting edit can be detected. `supersedes: <id>` replaces an older memory of the same entity (recall hides the old one). |
104
106
  | `read_smart` | **Token-saving file reader** with AST diff caching. Re-read unchanged = ~50 tokens; modified = changed chunks only. |
105
107
  | `drift_status` | **"What's drifting right now?"** The truth map: 🔴 drift / 🟡 review / ⚪ held / 🔵 verified / ⚫ unverified, with the evidence for each. `anchor_id` → deep-dive into one decision. |
106
108
  | `declare_anchor` | **Record a normative claim** — `decision` / `prohibition` / `constraint` the detector checks against reality, or `proposal`: an option you presented that the user never addressed, parked as a review item. |
@@ -79,7 +79,17 @@ async function main() {
79
79
  const scanAll = args.includes('--all');
80
80
  const sessionFileIdx = args.indexOf('--session-file');
81
81
  const sessionFile = sessionFileIdx >= 0 ? args[sessionFileIdx + 1] : null;
82
- const projectArgs = args.filter((a, i) => !a.startsWith('--') && (sessionFileIdx < 0 || i !== sessionFileIdx + 1));
82
+ // --via stop-hook is passed by sync-session (the Claude Code Stop hook). Anything else is a
83
+ // manual or batch import. Recorded in memories.source next to the session keys (0.18.0).
84
+ const viaIdx = args.indexOf('--via');
85
+ const via = viaIdx >= 0 && args[viaIdx + 1] === 'stop-hook' ? 'stop-hook' : 'import';
86
+ const projectArgs = args.filter((a, i) => !a.startsWith('--')
87
+ && (sessionFileIdx < 0 || i !== sessionFileIdx + 1)
88
+ && (viaIdx < 0 || i !== viaIdx + 1));
89
+ // The session keys are kept as they were: wipeSession and makeDistilledTurnSet find these rows
90
+ // by the substring `"session_id":"…"` (position does not matter, the exact text does).
91
+ // Known, not changed here: wipeSession runs outside the re-insert transaction.
92
+ const sourceJson = (src) => JSON.stringify({ ...src, client: 'claude-code', via });
83
93
  // Single-file mode (used by the Stop hook)
84
94
  if (sessionFile) {
85
95
  const db = dryRun ? null : openDb();
@@ -137,7 +147,7 @@ async function main() {
137
147
  for (const m of result.memories) {
138
148
  if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
139
149
  continue; // distilled rewrite wins
140
- const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source), m.thread_id ?? null, m.occurred_at ?? Math.floor(Date.now() / 1000));
150
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, sourceJson(m.source), m.thread_id ?? null, m.occurred_at ?? Math.floor(Date.now() / 1000));
141
151
  memContentToId.set(m.content, Number(res.lastInsertRowid));
142
152
  inserted.memories++;
143
153
  }
@@ -263,7 +273,7 @@ async function main() {
263
273
  for (const m of result.memories) {
264
274
  if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
265
275
  continue; // distilled rewrite wins
266
- const srcJson = JSON.stringify(m.source);
276
+ const srcJson = sourceJson(m.source);
267
277
  const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson, m.thread_id ?? null, m.occurred_at ?? Math.floor(Date.now() / 1000));
268
278
  memContentToId.set(m.content, Number(res.lastInsertRowid));
269
279
  agg.memories_inserted++;
@@ -77,7 +77,7 @@ async function main() {
77
77
  log(`transcript missing: ${transcriptPath}`);
78
78
  process.exit(0);
79
79
  }
80
- const r = spawnSync(process.execPath, [importerPath, '--session-file', transcriptPath], {
80
+ const r = spawnSync(process.execPath, [importerPath, '--session-file', transcriptPath, '--via', 'stop-hook'], {
81
81
  encoding: 'utf8',
82
82
  timeout: 30000,
83
83
  });
@@ -0,0 +1,35 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const MAX_SUPERSEDES = 20;
3
+ export declare class SupersedeRefused extends Error {
4
+ readonly memoryId: number | null;
5
+ readonly reason: string;
6
+ constructor(memoryId: number | null, reason: string);
7
+ }
8
+ /** Normalize the `supersedes` argument to a de-duplicated list of positive integers, or throw. */
9
+ export declare function parseSupersedesArg(raw: unknown): number[];
10
+ interface OldRow {
11
+ id: number;
12
+ entity_id: number;
13
+ content: string;
14
+ source: string | null;
15
+ }
16
+ /**
17
+ * Check every id against the DB. Throws SupersedeRefused on the first id that cannot be
18
+ * confirmed. Call inside the same transaction as the INSERT so the check and the write agree.
19
+ */
20
+ export declare function checkSupersedes(db: Database.Database, ids: number[], entityId: number, newId?: number): OldRow[];
21
+ /**
22
+ * Write the edges (new → old, relation 'supersedes') and mark each old row superseded.
23
+ * JSON content: state → 'superseded' and superseded_by → newId (same representation the edge
24
+ * detector and distill triage already use). Plain-text content is left as written; the edge
25
+ * alone is what recall folds on.
26
+ */
27
+ export declare function applySupersedes(db: Database.Database, newId: number, rows: OldRow[]): void;
28
+ /** source JSON for a row written through MCP. clientInfo is what the host sent in initialize. */
29
+ export declare function mcpSource(clientInfo: {
30
+ name?: unknown;
31
+ version?: unknown;
32
+ } | undefined | null): string;
33
+ /** Short "by" label for recall. Only what the row itself recorded; never inferred. */
34
+ export declare function byFromSource(source: string | null | undefined): string | undefined;
35
+ export {};
@@ -0,0 +1,130 @@
1
+ // Reversal and attribution for memories (0.18.0).
2
+ //
3
+ // Two of the four cases an outside developer named (Discussion #16) where decision memory breaks:
4
+ // 1. Reversal — "we decided X" then "no, Y". Before 0.18 nothing wrote a `supersedes` edge, so
5
+ // recall returned both and ranked by recency/score. Now `remember({ supersedes })` writes the
6
+ // edge and marks the old row; recall folds it unless asked.
7
+ // 2. Who said it — `memories.source` was never filled from the MCP side. Now each new row
8
+ // records the client name/version the MCP host sent in `initialize` (self-reported, not proof).
9
+ //
10
+ // Rules (fail-closed): an id that does not exist, the new row itself, a row of another entity,
11
+ // or a row the next session import will rewrite (raw Stop-hook capture) is refused, and nothing
12
+ // is written — no new row, no edge, no state change.
13
+ export const MAX_SUPERSEDES = 20;
14
+ export class SupersedeRefused extends Error {
15
+ memoryId;
16
+ reason;
17
+ constructor(memoryId, reason) {
18
+ super(reason);
19
+ this.memoryId = memoryId;
20
+ this.reason = reason;
21
+ this.name = 'SupersedeRefused';
22
+ }
23
+ }
24
+ /** Normalize the `supersedes` argument to a de-duplicated list of positive integers, or throw. */
25
+ export function parseSupersedesArg(raw) {
26
+ if (raw === undefined || raw === null)
27
+ return [];
28
+ const list = Array.isArray(raw) ? raw : [raw];
29
+ if (list.length === 0)
30
+ return [];
31
+ if (list.length > MAX_SUPERSEDES) {
32
+ throw new SupersedeRefused(null, `supersedes takes at most ${MAX_SUPERSEDES} ids (got ${list.length})`);
33
+ }
34
+ const out = [];
35
+ for (const v of list) {
36
+ const n = typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v.trim()) : v;
37
+ if (typeof n !== 'number' || !Number.isSafeInteger(n) || n <= 0) {
38
+ throw new SupersedeRefused(null, `supersedes must be memory ids (positive integers); got ${JSON.stringify(v)}`);
39
+ }
40
+ if (!out.includes(n))
41
+ out.push(n);
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * Check every id against the DB. Throws SupersedeRefused on the first id that cannot be
47
+ * confirmed. Call inside the same transaction as the INSERT so the check and the write agree.
48
+ */
49
+ export function checkSupersedes(db, ids, entityId, newId) {
50
+ const get = db.prepare('SELECT id, entity_id, content, source FROM memories WHERE id = ?');
51
+ const rows = [];
52
+ for (const id of ids) {
53
+ if (newId !== undefined && id === newId)
54
+ throw new SupersedeRefused(id, `memory #${id} is the memory being written — a memory cannot supersede itself`);
55
+ const row = get.get(id);
56
+ if (!row)
57
+ throw new SupersedeRefused(id, `memory #${id} does not exist`);
58
+ if (row.entity_id !== entityId) {
59
+ throw new SupersedeRefused(id, `memory #${id} belongs to another entity (entity_id ${row.entity_id}, this one is ${entityId}) — pass the same entity_name, or remember it under that entity`);
60
+ }
61
+ if (isRawSessionCapture(row)) {
62
+ throw new SupersedeRefused(id, `memory #${id} is a raw capture from a Claude Code session; the next import of that session rewrites it with a new id, so the link would not last. Rewrite it first with remember({ memory_id: ${id}, content: { ..., "distilled": true } }) — distilled rows survive re-import — then supersede it`);
63
+ }
64
+ rows.push(row);
65
+ }
66
+ return rows;
67
+ }
68
+ /** Raw Stop-hook / batch-import row (source carries a session_id) that has not been distilled. */
69
+ function isRawSessionCapture(row) {
70
+ if (!row.source)
71
+ return false;
72
+ let src;
73
+ try {
74
+ src = JSON.parse(row.source);
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ if (!src || typeof src !== 'object' || typeof src.session_id !== 'string')
80
+ return false;
81
+ try {
82
+ const c = JSON.parse(row.content);
83
+ if (c && typeof c === 'object' && (c.distilled === true || c.distilled === 1))
84
+ return false;
85
+ }
86
+ catch { /* plain text → not distilled */ }
87
+ return true;
88
+ }
89
+ /**
90
+ * Write the edges (new → old, relation 'supersedes') and mark each old row superseded.
91
+ * JSON content: state → 'superseded' and superseded_by → newId (same representation the edge
92
+ * detector and distill triage already use). Plain-text content is left as written; the edge
93
+ * alone is what recall folds on.
94
+ */
95
+ export function applySupersedes(db, newId, rows) {
96
+ const edge = db.prepare(`INSERT OR IGNORE INTO memory_edges (from_memory_id, to_memory_id, relation) VALUES (?, ?, 'supersedes')`);
97
+ const mark = db.prepare(`UPDATE memories SET content = json_set(content, '$.state', 'superseded', '$.superseded_by', ?)
98
+ WHERE id = ? AND json_valid(content) AND json_type(content) = 'object'`);
99
+ for (const r of rows) {
100
+ edge.run(newId, r.id);
101
+ mark.run(newId, r.id);
102
+ }
103
+ }
104
+ // ── Attribution ──────────────────────────────────────────────────────────────
105
+ const clean = (s, max) => String(s ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max);
106
+ /** source JSON for a row written through MCP. clientInfo is what the host sent in initialize. */
107
+ export function mcpSource(clientInfo) {
108
+ const name = clientInfo ? clean(clientInfo.name, 64) : '';
109
+ if (!name)
110
+ return JSON.stringify({ client: 'unknown', via: 'mcp' });
111
+ const version = clientInfo ? clean(clientInfo.version, 32) : '';
112
+ return JSON.stringify(version ? { client: name, version, via: 'mcp' } : { client: name, via: 'mcp' });
113
+ }
114
+ /** Short "by" label for recall. Only what the row itself recorded; never inferred. */
115
+ export function byFromSource(source) {
116
+ if (!source)
117
+ return undefined;
118
+ try {
119
+ const s = JSON.parse(source);
120
+ if (s && typeof s === 'object') {
121
+ if (typeof s.client === 'string' && s.client)
122
+ return s.client;
123
+ if (typeof s.origin === 'string' && s.origin)
124
+ return s.origin;
125
+ }
126
+ }
127
+ catch { /* legacy plain-text source */ }
128
+ return undefined;
129
+ }
130
+ //# sourceMappingURL=supersede.js.map
@@ -27,6 +27,7 @@ import { logAnchorTouch } from '../lib/anchor-touch.js';
27
27
  import { triageDistillQueue, distillPending } from '../lib/distill-triage.js';
28
28
  import { whereAmI, listMapProjects } from '../lib/map-view.js';
29
29
  import { buildShareDraft, KINDS, RUNTIMES } from '../lib/share-draft.js';
30
+ import { parseSupersedesArg, checkSupersedes, applySupersedes, SupersedeRefused, mcpSource, byFromSource } from '../lib/supersede.js';
30
31
  import { readFileSync, existsSync } from 'node:fs';
31
32
  import { fileURLToPath } from 'node:url';
32
33
  import { dirname, join } from 'node:path';
@@ -202,6 +203,10 @@ const TOOLS = [
202
203
  force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
203
204
  memory_id: { type: 'number', description: 'Set to update an existing memory instead of creating. Only content/layer/importance are changed.' },
204
205
  forget: { type: 'boolean', default: false, description: 'Set true + memory_id to delete a memory. Caveat-layer and pinned memories cannot be deleted.' },
206
+ supersedes: {
207
+ 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).',
208
+ anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' } }],
209
+ },
205
210
  },
206
211
  },
207
212
  },
@@ -226,6 +231,7 @@ const TOOLS = [
226
231
  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
232
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
228
233
  scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
234
+ include_superseded: { type: 'boolean', default: false, description: 'Also return memories that a newer memory superseded (hidden by default; each carries superseded_by).' },
229
235
  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
236
  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
237
  project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
@@ -502,7 +508,16 @@ function handleRemember(args) {
502
508
  hint: 'If you meant to save an extracted insight from that paste, summarize it in your own words first.',
503
509
  });
504
510
  }
505
- const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
511
+ // supersedes: parse before anything is written; a malformed value refuses the whole call.
512
+ let supersedeIds;
513
+ try {
514
+ supersedeIds = parseSupersedesArg(args.supersedes);
515
+ }
516
+ catch (e) {
517
+ if (e instanceof SupersedeRefused)
518
+ return JSON.stringify({ ok: false, rejected: 'supersedes', reason: e.message, saved: false });
519
+ throw e;
520
+ }
506
521
  const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
507
522
  // Auto-classify: if content is plain text (not JSON with 3-axis fields),
508
523
  // wrap it in structured JSON so VIRTUAL generated columns can extract axes.
@@ -514,6 +529,24 @@ function handleRemember(args) {
514
529
  }
515
530
  }
516
531
  catch { /* not JSON = needs wrapping */ }
532
+ // With supersedes, a JSON object that lacks any of altitude/type/state is refused before
533
+ // anything is written: it would be re-wrapped as {what: "<the JSON>"} and its state re-guessed,
534
+ // so an explicit state in it could not be honoured or even confirmed. No clever rescue —
535
+ // plain text, or JSON with all three, only.
536
+ if (supersedeIds.length > 0 && !isAlreadyStructured) {
537
+ let parsedObj = false;
538
+ try {
539
+ const p = JSON.parse(rawContent);
540
+ parsedObj = p !== null && typeof p === 'object';
541
+ }
542
+ catch { /* plain text */ }
543
+ if (parsedObj) {
544
+ return JSON.stringify({
545
+ ok: false, rejected: 'supersedes', saved: false,
546
+ 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.',
547
+ });
548
+ }
549
+ }
517
550
  if (!isAlreadyStructured) {
518
551
  const structured = {
519
552
  altitude: inferAltitude(rawContent),
@@ -523,20 +556,97 @@ function handleRemember(args) {
523
556
  };
524
557
  rawContent = JSON.stringify(structured);
525
558
  }
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);
559
+ // A memory that names what it supersedes IS the current decision.
560
+ // • State GUESSED from the text (inferState reads 「代わりに」 / "replaced" — words about the
561
+ // OLD decision — as this row being superseded): corrected to 'decided', and the reply says so.
562
+ // • State given EXPLICITLY as 'superseded' together with supersedes: the two contradict each
563
+ // other and we cannot tell which one the caller meant, so nothing is written.
564
+ let stateNote;
565
+ if (supersedeIds.length > 0) {
566
+ let c;
567
+ try {
568
+ c = JSON.parse(rawContent);
569
+ }
570
+ catch {
571
+ c = null; /* structured above, always JSON */
572
+ }
573
+ if (c && typeof c === 'object' && c.state === 'superseded') {
574
+ if (isAlreadyStructured) {
575
+ return JSON.stringify({
576
+ ok: false, rejected: 'supersedes', saved: false,
577
+ 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.',
578
+ });
579
+ }
580
+ c.state = 'decided';
581
+ rawContent = JSON.stringify(c);
582
+ stateNote = 'the state guessed from the text was "superseded"; stored as "decided" because this memory supersedes another one';
583
+ }
584
+ }
585
+ // Who wrote it: the client name/version the MCP host sent in initialize (self-reported).
586
+ const source = mcpSource(server.getClientVersion());
587
+ // Entity, row, event, supersedes edges and the old rows' state change commit together or not
588
+ // at all: if any supersedes id cannot be confirmed, nothing — not even a new entity — is left.
589
+ let entityId, memoryId, supersededAnchors = [];
590
+ try {
591
+ ({ entityId, memoryId, supersededAnchors } = db.transaction(() => {
592
+ const eid = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
593
+ const result = db
594
+ .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected, source, thread_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
595
+ .run(eid, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, source, args.thread_id ?? null);
596
+ const mid = Number(result.lastInsertRowid);
597
+ // Checked after the INSERT so a self-reference is seen as one (the id exists only now);
598
+ // a refusal throws and the transaction rolls the INSERT back.
599
+ const oldRows = supersedeIds.length > 0 ? checkSupersedes(db, supersedeIds, eid, mid) : [];
600
+ if (oldRows.length > 0)
601
+ applySupersedes(db, mid, oldRows);
602
+ 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 }));
603
+ const anchors = [];
604
+ for (const r of oldRows) {
605
+ try {
606
+ const c = JSON.parse(r.content);
607
+ if (c && Number.isInteger(c.anchor_id))
608
+ anchors.push(c.anchor_id);
609
+ }
610
+ catch { /* plain text */ }
611
+ }
612
+ return { entityId: eid, memoryId: mid, supersededAnchors: anchors };
613
+ })());
614
+ }
615
+ catch (e) {
616
+ if (e instanceof SupersedeRefused) {
617
+ return JSON.stringify({ ok: false, rejected: 'supersedes', memory_id: e.memoryId, reason: e.message, saved: false });
618
+ }
619
+ throw e;
620
+ }
621
+ // The memory is committed from here on. Helpers that fail after this point must not turn a
622
+ // saved memory into ok:false — the caller would retry and save it twice.
623
+ const warnings = [];
624
+ let mom = null;
625
+ try {
626
+ mom = refreshMomentumForEntity(db, entityId);
627
+ }
628
+ catch (e) {
629
+ warnings.push(`saved, but the momentum refresh failed: ${String(e?.message ?? e).slice(0, 200)}`);
630
+ }
532
631
  const out = {
533
632
  ok: true,
534
633
  memory_id: memoryId,
535
634
  entity_id: entityId,
536
635
  layer,
537
636
  pinned: importance >= 0.9,
538
- momentum: { score: mom.score, band: mom.band },
637
+ ...(mom ? { momentum: { score: mom.score, band: mom.band } } : {}),
539
638
  };
639
+ if (warnings.length > 0)
640
+ out.warnings = warnings;
641
+ if (supersedeIds.length > 0)
642
+ out.supersedes = supersedeIds;
643
+ if (stateNote)
644
+ out.state_note = stateNote;
645
+ if (supersededAnchors.length > 0) {
646
+ // The memory link does not touch the gate. Say so instead of retiring an anchor on our own.
647
+ out.anchors_still_enforced = supersededAnchors;
648
+ 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> }).`;
649
+ }
540
650
  // "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
541
651
  // Picking remember meant the decision was stored but never re-injected — the exact failure
542
652
  // the product exists to prevent. One call now does both when the caller asks for it.
@@ -631,12 +741,22 @@ function appendAxisFilters(sql, params, filters) {
631
741
  }
632
742
  return sql;
633
743
  }
634
- function runFtsQuery(query, layer, limit, axis) {
744
+ // NULL-safe: plain-text rows and JSON without a state have mem_state NULL, and
745
+ // `NOT (NULL = 'superseded' OR …)` is NULL — which would drop them (Codex review 2 of #21).
746
+ 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'))`;
747
+ function appendSupersededFilter(sql, mode) {
748
+ if (mode === 'exclude')
749
+ return sql + ` AND NOT ${SUPERSEDED_SQL}`;
750
+ if (mode === 'only')
751
+ return sql + ` AND ${SUPERSEDED_SQL}`;
752
+ return sql;
753
+ }
754
+ function runFtsQuery(query, layer, limit, axis, sup = 'include') {
635
755
  let sql = `
636
756
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
637
757
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
638
758
  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
759
+ m.source as _source, bm25(memories_fts) as bm25_score
640
760
  FROM memories_fts
641
761
  JOIN memories m ON m.id = memories_fts.rowid
642
762
  JOIN entities e ON e.id = m.entity_id
@@ -649,16 +769,17 @@ function runFtsQuery(query, layer, limit, axis) {
649
769
  }
650
770
  if (axis)
651
771
  sql = appendAxisFilters(sql, params, axis);
772
+ sql = appendSupersededFilter(sql, sup);
652
773
  sql += ' ORDER BY bm25_score ASC LIMIT ?';
653
774
  params.push(limit);
654
775
  return db.prepare(sql).all(...params);
655
776
  }
656
- function runLikeQuery(query, entityName, layer, limit, axis) {
777
+ function runLikeQuery(query, entityName, layer, limit, axis, sup = 'include') {
657
778
  let sql = `
658
779
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
659
780
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
660
781
  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
782
+ m.source as _source, 0 as bm25_score
662
783
  FROM memories m
663
784
  JOIN entities e ON e.id = m.entity_id
664
785
  WHERE 1=1
@@ -674,6 +795,7 @@ function runLikeQuery(query, entityName, layer, limit, axis) {
674
795
  }
675
796
  if (axis)
676
797
  sql = appendAxisFilters(sql, params, axis);
798
+ sql = appendSupersededFilter(sql, sup);
677
799
  if (query && !entityName) {
678
800
  sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
679
801
  params.push(`%${query}%`, `%${query}%`);
@@ -750,9 +872,29 @@ function handleRecall(args) {
750
872
  let searchMethod = 'like';
751
873
  const ftsQuery = toFtsQuery(args.query ?? '');
752
874
  const canUseFts = !!ftsQuery && !args.entity_name;
875
+ // Superseded rows are left out in SQL by default; asking for them (include_superseded, or
876
+ // mem_state:'superseded') keeps them. The hidden ones are counted with the same filters.
877
+ const showSuperseded = args?.include_superseded === true || axis.mem_state === 'superseded';
878
+ const sup = showSuperseded ? 'include' : 'exclude';
879
+ const HIDDEN_COUNT_CAP = 1000;
880
+ let supersededHidden = 0;
881
+ if (!showSuperseded) {
882
+ const hiddenIds = new Set();
883
+ if (canUseFts) {
884
+ for (const r of runFtsQuery(ftsQuery, layer, HIDDEN_COUNT_CAP, axis, 'only'))
885
+ hiddenIds.add(r.id);
886
+ for (const r of runLikeQuery(args.query, undefined, layer, HIDDEN_COUNT_CAP, axis, 'only'))
887
+ hiddenIds.add(r.id);
888
+ }
889
+ else {
890
+ for (const r of runLikeQuery(args.query, args.entity_name, layer, HIDDEN_COUNT_CAP, axis, 'only'))
891
+ hiddenIds.add(r.id);
892
+ }
893
+ supersededHidden = hiddenIds.size;
894
+ }
753
895
  if (canUseFts) {
754
- const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis);
755
- const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis);
896
+ const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis, sup);
897
+ const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis, sup);
756
898
  const seen = new Map();
757
899
  for (const r of ftsRows)
758
900
  seen.set(r.id, { ...r, _via: 'fts' });
@@ -773,7 +915,7 @@ function handleRecall(args) {
773
915
  searchMethod = 'like';
774
916
  }
775
917
  else {
776
- rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis).map((r) => ({ ...r, _via: 'like' }));
918
+ rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis, sup).map((r) => ({ ...r, _via: 'like' }));
777
919
  searchMethod = 'like';
778
920
  }
779
921
  const useFts = searchMethod !== 'like';
@@ -868,8 +1010,27 @@ function handleRecall(args) {
868
1010
  },
869
1011
  };
870
1012
  });
1013
+ // Supersedes links among the candidates, both directions (indexed on both columns).
1014
+ const supersededBy = new Map(); // old id → newest id that supersedes it
1015
+ const supersedesOf = new Map(); // new id → old ids
1016
+ const allIds = scored.map((r) => r.id);
1017
+ for (let i = 0; i < allIds.length; i += 400) { // chunked: offset can make the pool large
1018
+ const ids = allIds.slice(i, i + 400);
1019
+ const ph = ids.map(() => '?').join(',');
1020
+ const links = db.prepare(`SELECT from_memory_id AS f, to_memory_id AS t FROM memory_edges
1021
+ WHERE relation = 'supersedes' AND (from_memory_id IN (${ph}) OR to_memory_id IN (${ph}))
1022
+ ORDER BY from_memory_id ASC`).all(...ids, ...ids);
1023
+ for (const l of links) {
1024
+ if (supersededBy.has(l.t) && (supersedesOf.get(l.f) ?? []).includes(l.t))
1025
+ continue; // seen in an earlier chunk
1026
+ supersededBy.set(l.t, Math.max(supersededBy.get(l.t) ?? 0, l.f));
1027
+ supersedesOf.set(l.f, [...(supersedesOf.get(l.f) ?? []), l.t]);
1028
+ }
1029
+ }
1030
+ // Superseded rows were already left out in SQL (before the LIMIT) unless asked for.
1031
+ const visible = scored;
871
1032
  // Apply band filter AFTER scoring (needs heat.band)
872
- const filtered = band ? scored.filter((s) => s.heat_band === band) : scored;
1033
+ const filtered = band ? visible.filter((s) => s.heat_band === band) : visible;
873
1034
  // Sort by composite (best first)
874
1035
  filtered.sort((a, b) => b.composite_score - a.composite_score);
875
1036
  // Dedup near-identical memories (same entity + near-identical core text).
@@ -922,6 +1083,10 @@ function handleRecall(args) {
922
1083
  content: parsedContent,
923
1084
  importance: r.importance,
924
1085
  pinned: r.importance >= 0.9,
1086
+ // One short field each, only when the row has it (no tokens spent on absence).
1087
+ ...(byFromSource(r._source) ? { by: byFromSource(r._source) } : {}),
1088
+ ...(supersedesOf.has(r.id) ? { supersedes: supersedesOf.get(r.id).map((x) => `#${x}`).join(' ') } : {}),
1089
+ ...(supersededBy.has(r.id) ? { superseded_by: supersededBy.get(r.id) } : {}),
925
1090
  // Ranking internals only on request — they roughly double the per-memory cost and an
926
1091
  // agent acting on the memory needs what/why/state, not the scorer's arithmetic.
927
1092
  ...(args?.explain ? {
@@ -966,6 +1131,7 @@ function handleRecall(args) {
966
1131
  type: axis.mem_type ?? null,
967
1132
  state: axis.mem_state ?? null,
968
1133
  },
1134
+ ...(supersededHidden > 0 ? { superseded_hidden: supersededHidden >= HIDDEN_COUNT_CAP ? `${HIDDEN_COUNT_CAP}+` : supersededHidden, superseded_hint: 'include_superseded: true to show them' } : {}),
969
1135
  memories: memoriesOut,
970
1136
  });
971
1137
  }
@@ -1384,6 +1550,9 @@ async function handleRememberUnified(args) {
1384
1550
  }
1385
1551
  // Update mode
1386
1552
  if (args.memory_id) {
1553
+ if (args.supersedes !== undefined && args.supersedes !== null) {
1554
+ 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 });
1555
+ }
1387
1556
  return handleUpdateMemory(args);
1388
1557
  }
1389
1558
  // Create mode (default). Only content is required; everything an agent used to have to
@@ -155,7 +155,7 @@ open → decided → in_progress → done
155
155
  |---|---|---|
156
156
  | `affects` | string[] | File paths or areas this touches. Critical for future `recall_file` accuracy |
157
157
  | `next_action` | string / null | What should happen next. Null if done/completed |
158
- | `supersedes_id` | number / null | Memory ID this replaces (builds pivot chains) |
158
+ | `supersedes_id` | number / null | Memory ID this replaces. Content only — the link that recall acts on is the `remember` argument `supersedes: <id>` (0.18.0) |
159
159
  | `prior_belief` | string | What we used to think (for learnings — enables belief-update tracking) |
160
160
  | `evidence_refs` | object[] | Links to evidence: `{"type": "session"|"file"|"url", "id": "...", "label": "..."}`. Store as REFERENCES, never inline the full content |
161
161
 
@@ -435,7 +435,7 @@ User: "Let's switch to Stripe for payments"
435
435
  → Look for: type="decision", state="decided" memories on the same topic
436
436
  2. If past decision found:
437
437
  a. Tell user: "Previously we decided <X> because <reason>. Override?"
438
- b. If user confirms override → use supersedes_id to link to old decision
438
+ b. If user confirms override → `remember({ content, supersedes: <old memory_id> })` (recall then hides the old decision)
439
439
  c. If user says "oh right, keep it" → no new memory needed, proceed
440
440
  3. If no past decision found → proceed to record (see Case E below)
441
441
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Hand a project over — to your next session, to Cursor/Codex/Gemini, to your successor — with the reasons attached. Local-first memory MCP for coding agents: decisions re-injected before the agent acts, drift detection across every project you run. One SQLite file, nothing leaves your machine.",
6
6
  "type": "module",