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 +3 -1
- package/dist/bin/import-sessions.js +13 -3
- package/dist/bin/sync-session.js +1 -1
- package/dist/lib/supersede.d.ts +35 -0
- package/dist/lib/supersede.js +130 -0
- package/dist/mcp/server.js +185 -16
- package/dist/skill/SKILL.md +2 -2
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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 =
|
|
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++;
|
package/dist/bin/sync-session.js
CHANGED
|
@@ -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
|
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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
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",
|