linksee-memory 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,3 +1,10 @@
1
1
  import { type RmOptions } from 'node:fs';
2
2
  export declare function renameGuarded(from: string, to: string): void;
3
3
  export declare function rmGuarded(path: string, opts: RmOptions): void;
4
+ /**
5
+ * Named fault points inside a transaction (0.18.1, Codex review 1 of #23): the rollback tests
6
+ * need a failure AFTER some rows were written, not one at prepare() time.
7
+ * LINKSEE_TEST_FAULT=consolidate-after-mark | consolidate-after-edges | import-after-memories
8
+ * Unset (the normal case) this is a no-op.
9
+ */
10
+ export declare function faultPoint(name: string): void;
@@ -27,4 +27,20 @@ export function rmGuarded(path, opts) {
27
27
  inject('RM');
28
28
  rmSync(path, opts);
29
29
  }
30
+ /**
31
+ * Named fault points inside a transaction (0.18.1, Codex review 1 of #23): the rollback tests
32
+ * need a failure AFTER some rows were written, not one at prepare() time.
33
+ * LINKSEE_TEST_FAULT=consolidate-after-mark | consolidate-after-edges | import-after-memories
34
+ * Unset (the normal case) this is a no-op.
35
+ */
36
+ export function faultPoint(name) {
37
+ const spec = process.env.LINKSEE_TEST_FAULT;
38
+ if (!spec)
39
+ return;
40
+ if (spec.split(',').map((s) => s.trim()).includes(name)) {
41
+ const e = new Error(`injected fault at ${name}`);
42
+ e.code = 'EINJECTED';
43
+ throw e;
44
+ }
45
+ }
30
46
  //# sourceMappingURL=test-faults.js.map