linksee-memory 0.17.1 → 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.
@@ -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';
@@ -64,6 +65,8 @@ const SUBCOMMANDS = {
64
65
  detect: 'detect-drift.js',
65
66
  export: 'export-report.js',
66
67
  'share-draft': 'share-draft.js',
68
+ migrate: 'migrate-legacy.js', // 0.17.2: schema 1–5 → current, without starting the server
69
+ runtime: 'runtime.js', // 0.17.2: runtime list | prune
67
70
  };
68
71
  const subcommand = process.argv[2];
69
72
  if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
@@ -200,6 +203,10 @@ const TOOLS = [
200
203
  force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
201
204
  memory_id: { type: 'number', description: 'Set to update an existing memory instead of creating. Only content/layer/importance are changed.' },
202
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
+ },
203
210
  },
204
211
  },
205
212
  },
@@ -224,6 +231,7 @@ const TOOLS = [
224
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.' },
225
232
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
226
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).' },
227
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.' },
228
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' }] },
229
237
  project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
@@ -500,7 +508,16 @@ function handleRemember(args) {
500
508
  hint: 'If you meant to save an extracted insight from that paste, summarize it in your own words first.',
501
509
  });
502
510
  }
503
- 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
+ }
504
521
  const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
505
522
  // Auto-classify: if content is plain text (not JSON with 3-axis fields),
506
523
  // wrap it in structured JSON so VIRTUAL generated columns can extract axes.
@@ -512,6 +529,24 @@ function handleRemember(args) {
512
529
  }
513
530
  }
514
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
+ }
515
550
  if (!isAlreadyStructured) {
516
551
  const structured = {
517
552
  altitude: inferAltitude(rawContent),
@@ -521,20 +556,97 @@ function handleRemember(args) {
521
556
  };
522
557
  rawContent = JSON.stringify(structured);
523
558
  }
524
- const result = db
525
- .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected, thread_id) VALUES (?, ?, ?, ?, ?, ?)')
526
- .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
527
- db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
528
- const mom = refreshMomentumForEntity(db, entityId);
529
- 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
+ }
530
631
  const out = {
531
632
  ok: true,
532
633
  memory_id: memoryId,
533
634
  entity_id: entityId,
534
635
  layer,
535
636
  pinned: importance >= 0.9,
536
- momentum: { score: mom.score, band: mom.band },
637
+ ...(mom ? { momentum: { score: mom.score, band: mom.band } } : {}),
537
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
+ }
538
650
  // "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
539
651
  // Picking remember meant the decision was stored but never re-injected — the exact failure
540
652
  // the product exists to prevent. One call now does both when the caller asks for it.
@@ -629,12 +741,22 @@ function appendAxisFilters(sql, params, filters) {
629
741
  }
630
742
  return sql;
631
743
  }
632
- 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') {
633
755
  let sql = `
634
756
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
635
757
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
636
758
  m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
637
- bm25(memories_fts) as bm25_score
759
+ m.source as _source, bm25(memories_fts) as bm25_score
638
760
  FROM memories_fts
639
761
  JOIN memories m ON m.id = memories_fts.rowid
640
762
  JOIN entities e ON e.id = m.entity_id
@@ -647,16 +769,17 @@ function runFtsQuery(query, layer, limit, axis) {
647
769
  }
648
770
  if (axis)
649
771
  sql = appendAxisFilters(sql, params, axis);
772
+ sql = appendSupersededFilter(sql, sup);
650
773
  sql += ' ORDER BY bm25_score ASC LIMIT ?';
651
774
  params.push(limit);
652
775
  return db.prepare(sql).all(...params);
653
776
  }
654
- function runLikeQuery(query, entityName, layer, limit, axis) {
777
+ function runLikeQuery(query, entityName, layer, limit, axis, sup = 'include') {
655
778
  let sql = `
656
779
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
657
780
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
658
781
  m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
659
- 0 as bm25_score
782
+ m.source as _source, 0 as bm25_score
660
783
  FROM memories m
661
784
  JOIN entities e ON e.id = m.entity_id
662
785
  WHERE 1=1
@@ -672,6 +795,7 @@ function runLikeQuery(query, entityName, layer, limit, axis) {
672
795
  }
673
796
  if (axis)
674
797
  sql = appendAxisFilters(sql, params, axis);
798
+ sql = appendSupersededFilter(sql, sup);
675
799
  if (query && !entityName) {
676
800
  sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
677
801
  params.push(`%${query}%`, `%${query}%`);
@@ -748,9 +872,29 @@ function handleRecall(args) {
748
872
  let searchMethod = 'like';
749
873
  const ftsQuery = toFtsQuery(args.query ?? '');
750
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
+ }
751
895
  if (canUseFts) {
752
- const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis);
753
- 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);
754
898
  const seen = new Map();
755
899
  for (const r of ftsRows)
756
900
  seen.set(r.id, { ...r, _via: 'fts' });
@@ -771,7 +915,7 @@ function handleRecall(args) {
771
915
  searchMethod = 'like';
772
916
  }
773
917
  else {
774
- 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' }));
775
919
  searchMethod = 'like';
776
920
  }
777
921
  const useFts = searchMethod !== 'like';
@@ -866,8 +1010,27 @@ function handleRecall(args) {
866
1010
  },
867
1011
  };
868
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;
869
1032
  // Apply band filter AFTER scoring (needs heat.band)
870
- const filtered = band ? scored.filter((s) => s.heat_band === band) : scored;
1033
+ const filtered = band ? visible.filter((s) => s.heat_band === band) : visible;
871
1034
  // Sort by composite (best first)
872
1035
  filtered.sort((a, b) => b.composite_score - a.composite_score);
873
1036
  // Dedup near-identical memories (same entity + near-identical core text).
@@ -920,6 +1083,10 @@ function handleRecall(args) {
920
1083
  content: parsedContent,
921
1084
  importance: r.importance,
922
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) } : {}),
923
1090
  // Ranking internals only on request — they roughly double the per-memory cost and an
924
1091
  // agent acting on the memory needs what/why/state, not the scorer's arithmetic.
925
1092
  ...(args?.explain ? {
@@ -964,6 +1131,7 @@ function handleRecall(args) {
964
1131
  type: axis.mem_type ?? null,
965
1132
  state: axis.mem_state ?? null,
966
1133
  },
1134
+ ...(supersededHidden > 0 ? { superseded_hidden: supersededHidden >= HIDDEN_COUNT_CAP ? `${HIDDEN_COUNT_CAP}+` : supersededHidden, superseded_hint: 'include_superseded: true to show them' } : {}),
967
1135
  memories: memoriesOut,
968
1136
  });
969
1137
  }
@@ -1382,6 +1550,9 @@ async function handleRememberUnified(args) {
1382
1550
  }
1383
1551
  // Update mode
1384
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
+ }
1385
1556
  return handleUpdateMemory(args);
1386
1557
  }
1387
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.1",
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",