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.
- package/README.md +126 -822
- package/dist/bin/import-sessions.js +13 -3
- package/dist/bin/migrate-legacy.d.ts +33 -0
- package/dist/bin/migrate-legacy.js +501 -0
- package/dist/bin/runtime.d.ts +52 -0
- package/dist/bin/runtime.js +381 -0
- package/dist/bin/setup.js +1 -1
- package/dist/bin/sync-session.js +1 -1
- package/dist/db/migrate.d.ts +53 -2
- package/dist/db/migrate.js +106 -37
- package/dist/lib/hook-runtime.js +9 -4
- package/dist/lib/supersede.d.ts +35 -0
- package/dist/lib/supersede.js +130 -0
- package/dist/lib/test-faults.d.ts +3 -0
- package/dist/lib/test-faults.js +30 -0
- package/dist/mcp/server.js +187 -16
- package/dist/skill/SKILL.md +2 -2
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -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
|
|
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 →
|
|
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.
|
|
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",
|