linksee-memory 0.18.0 → 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.
@@ -1,56 +1,82 @@
1
- // Sleep-mode consolidation: cluster stale low-importance memories,
2
- // summarize into a single `learning`-layer entry, delete originals.
3
- // Implements Michie's memory principle 4 (sleep consolidation).
1
+ // Sleep-mode consolidation: cluster stale low-importance memories and write one
2
+ // `learning`-layer summary per cluster. Implements Michie's memory principle 4
3
+ // (sleep consolidation).
4
+ //
5
+ // 0.18.1: NOTHING IS DELETED. Up to 0.18.0 this file deleted the original rows of every
6
+ // cluster and then ran a "forget-sweep" that deleted every other cold row — 1,360 rows on
7
+ // 2026-10-29 and 559 (11 decisions among them) on 2026-11-05 on the maintainer's own database,
8
+ // with no notice and no way to opt out. Rows a hand-over needs, gone for a summary that said
9
+ // "N cold observations consolidated during sleep".
4
10
  //
5
11
  // Strategy (rule-based, no LLM required):
6
- // 1. Find candidates: layer IN (context, emotion, implementation),
7
- // protected=0, cold (heat<30), older than 7 days, cluster size >= 2.
12
+ // 1. Find candidates: layer IN (context, emotion, implementation), protected=0,
13
+ // JSON content, not already folded, cold (heat<30), older than 7 days, cluster size >= 2.
8
14
  // 2. Group by (entity_id, layer).
9
- // 3. Emit a structured summary into `learning` layer (protected=1),
10
- // then delete originals.
11
- // 4. Per-run forget-sweep drops expired memories that didn't cluster.
15
+ // 3. Emit a structured summary into `learning` layer (protected=1), then MARK the originals
16
+ // with `consolidated_into: <summary id>` in their content JSON (json_set). The rows stay.
17
+ // `recall` hides marked rows by default and reports how many (`consolidated_hidden`);
18
+ // `include_consolidated: true` shows them.
19
+ // 4. There is no forget-sweep any more. Cold rows that did not cluster are counted
20
+ // (`coldUnclustered`) and left alone.
21
+ //
22
+ // Plain-text rows (content that is not a JSON object) are never folded: the mark can only be
23
+ // written into JSON, and rewriting a user's text to add it is not this module's call. They are
24
+ // counted in `skippedPlainText`.
12
25
  import { computeHeat } from './heat-index.js';
13
26
  import { decideForgetting } from './forgetting.js';
14
27
  import { detectMemoryEdges } from './edge-detection.js';
28
+ import { faultPoint } from './test-faults.js';
15
29
  const CLUSTER_LAYERS = ['context', 'emotion', 'implementation'];
16
30
  const DEFAULT_MIN_AGE_DAYS = 7;
17
31
  const MAX_HEAT = 30;
18
32
  const MIN_CLUSTER_SIZE = 2;
19
- export function consolidate(db, opts = {}) {
20
- const now = Math.floor(Date.now() / 1000);
33
+ /** SQL fragment: the row has been folded into a summary (alias `m` for memories). */
34
+ export const CONSOLIDATED_SQL = `(json_valid(m.content) AND json_type(m.content) = 'object' AND json_extract(m.content, '$.consolidated_into') IS NOT NULL)`;
35
+ function heatOf(now, r) {
36
+ const daysSince = (now - r.last_accessed_at) / 86400;
37
+ return computeHeat({
38
+ accessesLast30d: daysSince < 30 ? r.access_count : 0,
39
+ accessesLast90d: daysSince < 90 ? r.access_count : 0,
40
+ daysSinceLastAccess: daysSince,
41
+ totalAccesses: r.access_count,
42
+ baseImportance: r.importance,
43
+ }).score;
44
+ }
45
+ /**
46
+ * What consolidate would do, without writing. Shared by `consolidate` (which applies it),
47
+ * the `consolidate --dry-run` subcommand, and the start-up notice.
48
+ */
49
+ export function planConsolidate(db, opts = {}, now = Math.floor(Date.now() / 1000)) {
21
50
  const minAgeDays = opts.min_age_days ?? DEFAULT_MIN_AGE_DAYS;
22
51
  const ageCutoff = now - minAgeDays * 86400;
23
- // Fetch candidates with age threshold. 'session' scope → same query currently
24
- // (Day 2: session filter needs session_id plumbing; will add when sessions
25
- // table is actually populated by the MCP server).
26
52
  const layerPlaceholders = CLUSTER_LAYERS.map(() => '?').join(',');
27
53
  const rows = db
28
54
  .prepare(`
29
55
  SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
30
56
  m.last_accessed_at, m.access_count, m.created_at, m.protected,
31
- m.altitude,
57
+ m.altitude, m.mem_type,
32
58
  e.name as entity_name
33
59
  FROM memories m
34
60
  JOIN entities e ON e.id = m.entity_id
35
61
  WHERE m.protected = 0
36
62
  AND m.layer IN (${layerPlaceholders})
37
63
  AND m.created_at <= ?
64
+ AND NOT ${CONSOLIDATED_SQL}
38
65
  ORDER BY m.entity_id, m.layer, m.created_at
39
66
  `)
40
67
  .all(...CLUSTER_LAYERS, ageCutoff);
41
- // Filter to cold-heat memories
42
- const cold = rows.filter((r) => {
43
- const daysSince = (now - r.last_accessed_at) / 86400;
44
- const heat = computeHeat({
45
- accessesLast30d: daysSince < 30 ? r.access_count : 0,
46
- accessesLast90d: daysSince < 90 ? r.access_count : 0,
47
- daysSinceLastAccess: daysSince,
48
- totalAccesses: r.access_count,
49
- baseImportance: r.importance,
50
- });
51
- return heat.score < MAX_HEAT;
52
- });
53
- // Group by (entity_id, layer)
68
+ // Cold-heat rows only; plain-text rows cannot carry the mark and are set aside.
69
+ const cold = [];
70
+ let skippedPlainText = 0;
71
+ for (const r of rows) {
72
+ if (heatOf(now, r) >= MAX_HEAT)
73
+ continue;
74
+ if (!isJsonObject(r.content)) {
75
+ skippedPlainText++;
76
+ continue;
77
+ }
78
+ cold.push(r);
79
+ }
54
80
  const groups = new Map();
55
81
  for (const r of cold) {
56
82
  const key = `${r.entity_id}::${r.layer}`;
@@ -58,44 +84,116 @@ export function consolidate(db, opts = {}) {
58
84
  arr.push(r);
59
85
  groups.set(key, arr);
60
86
  }
61
- const result = {
87
+ const clusters = [];
88
+ let coldUnclustered = 0;
89
+ for (const members of groups.values()) {
90
+ if (members.length < MIN_CLUSTER_SIZE) {
91
+ coldUnclustered += members.length;
92
+ continue;
93
+ }
94
+ clusters.push({
95
+ entity_id: members[0].entity_id,
96
+ entity_name: members[0].entity_name,
97
+ layer: members[0].layer,
98
+ ids: members.map((c) => c.id),
99
+ members,
100
+ examples: members.slice(0, 3).map((c) => fragment(c.content, 120)),
101
+ });
102
+ }
103
+ return {
62
104
  scanned: rows.length,
63
- clustersCompressed: 0,
64
- memoriesReplaced: 0,
65
- memoriesDropped: 0,
66
- learningIdsCreated: [],
67
- stalledTransitions: 0,
68
- memoryEdgesCreated: 0,
69
- decisionsSuperseded: 0,
105
+ clusters,
106
+ wouldFold: clusters.reduce((s, c) => s + c.ids.length, 0),
107
+ coldUnclustered,
108
+ skippedPlainText,
70
109
  };
110
+ }
111
+ function isJsonObject(content) {
112
+ try {
113
+ const v = JSON.parse(content);
114
+ return !!v && typeof v === 'object' && !Array.isArray(v);
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ function fragment(content, max) {
121
+ let text = content;
122
+ try {
123
+ const o = JSON.parse(content);
124
+ const core = o?.what ?? o?.title ?? o?.decision ?? o?.intent ?? o?.learned ?? o?.summary;
125
+ if (typeof core === 'string')
126
+ text = core;
127
+ }
128
+ catch { /* plain text */ }
129
+ text = String(text).replace(/\s+/g, ' ').trim();
130
+ return text.length > max ? text.slice(0, max) + '…' : text;
131
+ }
132
+ export function consolidate(db, opts = {}) {
133
+ const now = Math.floor(Date.now() / 1000);
71
134
  const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
72
135
  VALUES (?, 'learning', ?, ?, 1, ?)`);
73
- const deleteMemory = db.prepare('DELETE FROM memories WHERE id = ?');
136
+ // The mark, not a delete. Guarded so a row that is no longer a JSON object (changed since the
137
+ // plan) or already folded is left as it is: json_set on non-JSON would throw, and a throw
138
+ // inside the transaction rolls the whole run back — which is what we want for "unknown".
139
+ const markFolded = db.prepare(`UPDATE memories
140
+ SET content = json_set(content, '$.consolidated_into', ?)
141
+ WHERE id = ?
142
+ AND json_valid(content) AND json_type(content) = 'object'
143
+ AND json_extract(content, '$.consolidated_into') IS NULL`);
74
144
  const insertAudit = db.prepare(`INSERT INTO consolidations (learning_id, replaced_ids, replaced_count, entity_id, original_layer)
75
145
  VALUES (?, ?, ?, ?, ?)`);
76
146
  const insertEvent = db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)');
147
+ const markStalled = db.prepare(`
148
+ UPDATE memories SET content = json_set(content, '$.state', 'stalled')
149
+ WHERE json_valid(content)
150
+ AND json_extract(content, '$.state') = 'in_progress'
151
+ AND last_accessed_at < ?
152
+ AND protected = 0
153
+ `);
154
+ // ONE BEGIN IMMEDIATE transaction for the whole run (Codex review 1 of #23): the write lock is
155
+ // taken before the plan is read, so no other connection can change a candidate between the
156
+ // read and the mark; and the stalled sweep and edge detection are inside it too, so a failure
157
+ // anywhere rolls the whole run back (no summaries without marks, no folds without the rest).
158
+ // Order inside: plan → per cluster (summary row, audit row, marks, event) → stalled → edges.
159
+ // Nothing is deleted at any step.
77
160
  const tx = db.transaction(() => {
78
- for (const [key, cluster] of groups) {
79
- if (cluster.length < MIN_CLUSTER_SIZE)
80
- continue;
81
- const [entityIdStr, layer] = key.split('::');
82
- const entityId = Number(entityIdStr);
83
- const entityName = cluster[0].entity_name;
161
+ // 'session' scope → same query currently (session filter needs session_id plumbing).
162
+ const countRows = () => db.prepare('SELECT COUNT(*) AS c FROM memories').get().c;
163
+ const memoriesBefore = countRows();
164
+ const plan = planConsolidate(db, { min_age_days: opts.min_age_days }, now);
165
+ const result = {
166
+ scanned: plan.scanned,
167
+ clustersCompressed: 0,
168
+ memoriesFolded: 0,
169
+ memoriesDropped: 0,
170
+ coldUnclustered: plan.coldUnclustered,
171
+ skippedPlainText: plan.skippedPlainText,
172
+ learningIdsCreated: [],
173
+ memoriesBefore,
174
+ memoriesAfter: memoriesBefore,
175
+ stalledTransitions: 0,
176
+ memoryEdgesCreated: 0,
177
+ decisionsSuperseded: 0,
178
+ };
179
+ for (const cluster of plan.clusters) {
180
+ const { entity_id: entityId, entity_name: entityName, layer, members } = cluster;
84
181
  // Sort by importance desc, take top 3 as exemplars
85
- const byImp = [...cluster].sort((a, b) => b.importance - a.importance);
182
+ const byImp = [...members].sort((a, b) => b.importance - a.importance);
86
183
  const exemplars = byImp.slice(0, 3).map((c) => ({
184
+ id: c.id,
87
185
  fragment: c.content.length > 200 ? c.content.slice(0, 200) + '…' : c.content,
88
186
  when: new Date(c.created_at * 1000).toISOString().slice(0, 10),
89
187
  importance: c.importance,
90
188
  }));
91
- const timestamps = cluster.map((c) => c.created_at);
189
+ const timestamps = members.map((c) => c.created_at);
92
190
  const earliest = new Date(Math.min(...timestamps) * 1000).toISOString().slice(0, 10);
93
191
  const latest = new Date(Math.max(...timestamps) * 1000).toISOString().slice(0, 10);
94
- const avgImp = cluster.reduce((s, c) => s + c.importance, 0) / cluster.length;
95
- // Determine dominant altitude from cluster members (most frequent non-null value).
96
- // Consolidation summaries inherit the cluster's altitude so decay rates are preserved.
192
+ const avgImp = members.reduce((s, c) => s + c.importance, 0) / members.length;
193
+ // Dominant altitude from cluster members (most frequent non-null value), so decay rates
194
+ // are preserved on the summary.
97
195
  const altCounts = new Map();
98
- for (const c of cluster) {
196
+ for (const c of members) {
99
197
  if (c.altitude)
100
198
  altCounts.set(c.altitude, (altCounts.get(c.altitude) ?? 0) + 1);
101
199
  }
@@ -113,91 +211,89 @@ export function consolidate(db, opts = {}) {
113
211
  type: 'learning',
114
212
  state: 'done',
115
213
  original_layer: layer,
116
- count: cluster.length,
214
+ count: members.length,
117
215
  period: { from: earliest, to: latest },
118
- pattern: `${cluster.length} cold observations on "${entityName}" (${layer}) consolidated during sleep`,
216
+ pattern: `${members.length} cold observations on "${entityName}" (${layer}) folded into this summary; the originals are kept (recall({ include_consolidated: true }) shows them)`,
119
217
  exemplars,
120
- replaced_ids: cluster.map((c) => c.id),
218
+ // Kept under the old key so readers of pre-0.18.1 summaries still find the ids.
219
+ replaced_ids: members.map((c) => c.id),
220
+ folded_ids: members.map((c) => c.id),
121
221
  };
122
222
  const learningImportance = Math.max(avgImp, 0.55); // summaries are slightly more important than their avg source
123
- const sourceMeta = JSON.stringify({ origin: 'consolidate', replaced: cluster.length });
223
+ const sourceMeta = JSON.stringify({ origin: 'consolidate', folded: members.length });
124
224
  const ins = insertLearning.run(entityId, JSON.stringify(summary, null, 2), learningImportance, sourceMeta);
125
225
  const learningId = Number(ins.lastInsertRowid);
226
+ insertAudit.run(learningId, JSON.stringify(members.map((c) => c.id)), members.length, entityId, layer);
227
+ let marked = 0;
228
+ for (const c of members)
229
+ marked += markFolded.run(learningId, c.id).changes;
230
+ if (marked !== members.length) {
231
+ // A member changed under us (no longer JSON, or folded by a concurrent run). Unknown
232
+ // state → stop; the transaction rolls back everything, including the summary row.
233
+ throw new Error(`consolidate: cluster ${entityName}/${layer} marked ${marked} of ${members.length} rows; rolled back, nothing written`);
234
+ }
235
+ insertEvent.run(entityId, 'memory_consolidated', JSON.stringify({ learning_id: learningId, count: members.length, layer, deleted: 0 }));
126
236
  result.learningIdsCreated.push(learningId);
127
- insertAudit.run(learningId, JSON.stringify(cluster.map((c) => c.id)), cluster.length, entityId, layer);
128
- for (const c of cluster)
129
- deleteMemory.run(c.id);
130
- insertEvent.run(entityId, 'memory_consolidated', JSON.stringify({ learning_id: learningId, count: cluster.length, layer }));
131
237
  result.clustersCompressed++;
132
- result.memoriesReplaced += cluster.length;
238
+ result.memoriesFolded += members.length;
239
+ }
240
+ faultPoint('consolidate-after-mark');
241
+ // State transition sweep: in_progress memories untouched for 30+ days → stalled.
242
+ const STALLED_THRESHOLD_DAYS = 30;
243
+ result.stalledTransitions = markStalled.run(now - STALLED_THRESHOLD_DAYS * 86400).changes;
244
+ // Memory→memory edge detection: link superseding/contradicting decisions so the
245
+ // dashboard can render Pivot Chains and recall can surface superseded decisions.
246
+ // (Its own db.transaction becomes a savepoint inside this one.)
247
+ const edgeRes = detectMemoryEdges(db);
248
+ result.memoryEdgesCreated = edgeRes.edgesCreated;
249
+ result.decisionsSuperseded = edgeRes.supersededMarked;
250
+ faultPoint('consolidate-after-edges');
251
+ // Last line of defence, inside the transaction: the only rows a run may add are its
252
+ // summaries, and it may remove none. Anything else → throw → the whole run rolls back.
253
+ result.memoriesAfter = countRows();
254
+ if (result.memoriesAfter !== memoriesBefore + result.learningIdsCreated.length) {
255
+ throw new Error(`consolidate: memories ${memoriesBefore} → ${result.memoriesAfter} with ${result.learningIdsCreated.length} summaries; rolled back, nothing written`);
133
256
  }
257
+ // 0.18.1: the forget-sweep that deleted every remaining cold row is gone.
258
+ return result;
134
259
  });
135
- tx();
136
- // State transition sweep: in_progress memories untouched for 30+ days → stalled.
137
- // Uses json_set() to update the state field in content JSON atomically.
138
- const STALLED_THRESHOLD_DAYS = 30;
139
- const stalledCutoff = now - STALLED_THRESHOLD_DAYS * 86400;
140
- const stalledResult = db.prepare(`
141
- UPDATE memories SET content = json_set(content, '$.state', 'stalled')
142
- WHERE json_valid(content)
143
- AND json_extract(content, '$.state') = 'in_progress'
144
- AND last_accessed_at < ?
145
- AND protected = 0
146
- `).run(stalledCutoff);
147
- result.stalledTransitions = stalledResult.changes;
148
- // Memory→memory edge detection: link superseding/contradicting decisions so the
149
- // dashboard can render Pivot Chains and recall can surface superseded decisions.
150
- const edgeRes = detectMemoryEdges(db);
151
- result.memoryEdgesCreated = edgeRes.edgesCreated;
152
- result.decisionsSuperseded = edgeRes.supersededMarked;
153
- // Post-consolidate: forget-sweep remaining non-clustered cold memories
260
+ return tx.immediate();
261
+ }
262
+ /**
263
+ * How many rows the (removed) forget-sweep would have dropped — reported, never acted on.
264
+ * Kept for `consolidate --dry-run` so the user can see what earlier versions were deleting.
265
+ */
266
+ export function countSweepCandidates(db, now = Math.floor(Date.now() / 1000)) {
154
267
  const remaining = db
155
268
  .prepare('SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude FROM memories WHERE protected = 0')
156
269
  .all();
157
- const toDrop = [];
270
+ let n = 0;
158
271
  for (const r of remaining) {
159
272
  const daysSince = (now - r.last_accessed_at) / 86400;
160
- const heat = computeHeat({
161
- accessesLast30d: daysSince < 30 ? r.access_count : 0,
162
- accessesLast90d: daysSince < 90 ? r.access_count : 0,
163
- daysSinceLastAccess: daysSince,
164
- totalAccesses: r.access_count,
165
- baseImportance: r.importance,
166
- });
167
273
  const action = decideForgetting({
168
274
  daysSinceLastAccess: daysSince,
169
275
  importance: r.importance,
170
- heatScore: heat.score,
276
+ heatScore: heatOf(now, r),
171
277
  protected: r.protected === 1,
172
278
  layer: r.layer,
173
279
  altitude: r.altitude ?? undefined,
174
280
  });
175
281
  if (action === 'drop')
176
- toDrop.push(r.id);
177
- }
178
- if (toDrop.length > 0) {
179
- const del = db.prepare('DELETE FROM memories WHERE id = ?');
180
- const tx2 = db.transaction((ids) => {
181
- for (const id of ids)
182
- del.run(id);
183
- });
184
- tx2(toDrop);
185
- result.memoriesDropped = toDrop.length;
282
+ n++;
186
283
  }
187
- return result;
284
+ return n;
188
285
  }
189
286
  /**
190
287
  * Should the MCP server run consolidate on its own at start-up? (0.17.1, L-1-106 / audit F1)
191
288
  *
192
- * No, unless the user asked: consolidate deletes the original rows (keeping a 3 × 200-char
193
- * summary) and their edges, so it must not run without an explicit opt-in.
194
- * Opt in with LINKSEE_AUTO_CONSOLIDATE=1|true|on|weekly, or `--auto-consolidate` on the server
195
- * command line. The weekly gate inside the server still applies once enabled.
289
+ * No, unless the user asked. Opt in with exactly LINKSEE_AUTO_CONSOLIDATE=1 (0.18.1: true/on/
290
+ * yes/weekly no longer count), or `--auto-consolidate` on the server command line. The weekly gate inside the server still
291
+ * applies once enabled. Since 0.18.1 consolidate marks rows instead of deleting them, but it
292
+ * still rewrites content and hides rows from the default recall, so it stays opt-in.
196
293
  */
197
294
  export function autoConsolidateEnabled(env, argv) {
198
295
  if (argv.includes('--auto-consolidate'))
199
- return true;
200
- const v = (env.LINKSEE_AUTO_CONSOLIDATE ?? '').trim().toLowerCase();
201
- return v === '1' || v === 'true' || v === 'on' || v === 'yes' || v === 'weekly';
296
+ return true; // kept for compatibility (documented since 0.17.1)
297
+ return (env.LINKSEE_AUTO_CONSOLIDATE ?? '').trim() === '1';
202
298
  }
203
299
  //# sourceMappingURL=consolidate.js.map
@@ -14,8 +14,13 @@
14
14
  // Both are archived, never deleted: type → note, state → superseded, distill_verdict records
15
15
  // why. needs_distill stays TRUE — anchor #70 says the flag must not be dropped, and it stays
16
16
  // truthful: no agent ever rewrote this row. The queue, the pending count and the boot digest
17
- // exclude rows that carry a verdict. Layer and protection are untouched (a caveat stays a
18
- // caveat — it just stops being asked about). Reversible: delete distill_verdict from the JSON.
17
+ // exclude rows that carry a verdict. Reversible: delete distill_verdict from the JSON.
18
+ //
19
+ // 0.18.1 (Codex review 1 of #23): protected rows and the caveat layer are NOT triaged. The
20
+ // archive sets state → superseded, and the default recall hides superseded rows, so a short
21
+ // caveat ("never X") was being hidden at every server start. They stay in the queue instead.
22
+ // Rows archived by earlier versions are not un-archived automatically
23
+ // (recall({ include_superseded: true }) shows them).
19
24
  //
20
25
  // Measured before shipping (2026-09-07, 389 raw): noise 88, stale 51, queue 250 — and the
21
26
  // noise sample was read by a human before the rule was trusted.
@@ -51,6 +56,8 @@ export function triageDistillQueue(db, opts = {}) {
51
56
  WHERE json_valid(content)
52
57
  AND json_extract(content, '$.needs_distill') = 1
53
58
  AND json_extract(content, '$.distill_verdict') IS NULL
59
+ AND protected = 0
60
+ AND layer != 'caveat'
54
61
  AND created_at < ?`)
55
62
  .all(now - SETTLE_SECONDS);
56
63
  const report = { scanned: rows.length, noise: 0, stale: 0, remaining: 0, dryRun: !!opts.dryRun, samples: { noise: [], stale: [] } };
@@ -1,7 +1,8 @@
1
1
  // Ported from linksee-app/setup-learning-box.cjs:62-99 (Ebbinghaus forgetting curve).
2
2
  // Implements Michie's memory principle 6: active forgetting.
3
3
  // Protected memories (caveat layer) and goal layer bypass decay.
4
- // Returns true if this memory should be forgotten (compressed to summary or deleted).
4
+ // Risk score for forgetting. Since 0.18.1 nothing in the server acts on 'drop' by deleting:
5
+ // consolidate marks clustered rows and only counts the rest (see consolidate.ts).
5
6
  // Higher forgettingRisk → more likely to forget.
6
7
  export function forgettingRisk(input) {
7
8
  if (input.protected)
@@ -26,9 +27,9 @@ export function forgettingRisk(input) {
26
27
  const timeFactor = input.daysSinceLastAccess * (1 + input.daysSinceLastAccess / 30);
27
28
  return heatFactor * importanceFactor * timeFactor * altitudeMultiplier;
28
29
  }
29
- // Risk threshold above which memory is compressed (→ learning layer summary) and the original deleted.
30
+ // Risk threshold above which memory is compressed (→ learning layer summary; the original is kept and marked).
30
31
  export const COMPRESS_THRESHOLD = 50;
31
- // Risk threshold above which memory is entirely dropped.
32
+ // Risk threshold that earlier versions used to delete a row outright. Reported only, since 0.18.1.
32
33
  export const DROP_THRESHOLD = 200;
33
34
  export function decideForgetting(input) {
34
35
  const risk = forgettingRisk(input);
@@ -0,0 +1,22 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { ExtractedFileEdit, ExtractedMemory } from './session-extractor.js';
3
+ export interface SessionImportInput {
4
+ session_id: string;
5
+ memories: ExtractedMemory[];
6
+ file_edits: ExtractedFileEdit[];
7
+ stats: unknown;
8
+ }
9
+ export interface SessionImportResult {
10
+ inserted: number;
11
+ /** Extracted memories whose key the DB already had: left exactly as they are. */
12
+ kept: number;
13
+ /** Stored undistilled rows whose key the new extraction did not produce. Left in place. */
14
+ leftInPlace: number;
15
+ edits: number;
16
+ editsKept: number;
17
+ }
18
+ export declare function importSession(db: Database.Database, input: SessionImportInput, opts: {
19
+ entityId: number;
20
+ sourceJson: (src: object) => string;
21
+ startedAt: number;
22
+ }): SessionImportResult;
@@ -0,0 +1,161 @@
1
+ // session-import — write one extracted Claude Code session into the DB, APPEND-ONLY
2
+ // (0.18.1, Codex reviews 1–2 of #23).
3
+ //
4
+ // Up to 0.18.0 every re-import (the Stop hook runs one per turn) DELETEd the session's
5
+ // undistilled memory rows, its session_file_edits and every event mentioning it, and inserted
6
+ // them again — the memory DELETE outside the insert transaction. A failed insert lost the rows;
7
+ // a successful one renumbered them; deleting session_file_edits cascaded (ON DELETE CASCADE)
8
+ // into drift_edges and erased the user's dismiss/ack verdicts.
9
+ //
10
+ // The first 0.18.1 draft UPDATEd rows in place by a stable key. Review 2 showed that this
11
+ // overwrote what a user had corrected with remember({ memory_id, content }) (the fresh
12
+ // extraction won on same-named fields), and that replacing session_file_edits still dropped
13
+ // drift verdicts. So, by design decision: a re-import only ADDS.
14
+ //
15
+ // memories key = source.kind | turn_uuid | layer | file path (file_edit rows, from
16
+ // content.affects[0]) or the ordinal among same kind/turn/layer rows.
17
+ // A key the DB already has → the row is not touched (no UPDATE, no
18
+ // DELETE). A new key → INSERT.
19
+ // session_file_edits key = turn_uuid | file_path | operation | occurred_at | ordinal.
20
+ // Same rule: existing rows (and the drift_edges that point at them) are
21
+ // not touched; new ones are INSERTed.
22
+ // events one `session_imported` row per session; its payload (counts) is
23
+ // updated in place, or the row is inserted. Other events are not touched.
24
+ //
25
+ // Accepted cost (CHANGELOG, known limits): an improved extraction does not reach rows already
26
+ // imported — e.g. a file_edit row keeps the op count of the turn it was first imported in.
27
+ // Distilled rewrites still suppress the raw turn. Everything is one BEGIN IMMEDIATE transaction.
28
+ import { faultPoint } from './test-faults.js';
29
+ function parseObj(s) {
30
+ try {
31
+ const v = JSON.parse(s);
32
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function discriminatorOf(kind, content) {
39
+ if (kind !== 'file_edit')
40
+ return null;
41
+ const o = parseObj(content);
42
+ const a = Array.isArray(o?.affects) ? o.affects[0] : undefined;
43
+ return typeof a === 'string' ? a : null;
44
+ }
45
+ function memKeyer() {
46
+ const ordinals = new Map();
47
+ return (kind, turn, layer, content) => {
48
+ const base = `${kind}|${turn}|${layer}`;
49
+ const d = discriminatorOf(kind, content);
50
+ if (d !== null)
51
+ return `${base}|p:${d}`;
52
+ const n = ordinals.get(base) ?? 0;
53
+ ordinals.set(base, n + 1);
54
+ return `${base}|#${n}`;
55
+ };
56
+ }
57
+ function editKeyer() {
58
+ const ordinals = new Map();
59
+ return (turn, path, op, at) => {
60
+ const base = `${turn ?? ''}|${path}|${op}|${at}`;
61
+ const n = ordinals.get(base) ?? 0;
62
+ ordinals.set(base, n + 1);
63
+ return `${base}|#${n}`;
64
+ };
65
+ }
66
+ export function importSession(db, input, opts) {
67
+ const sid = input.session_id;
68
+ const sidNeedle = `%"session_id":"${sid}"%`;
69
+ const selDistilledTurns = db.prepare(`SELECT source FROM memories
70
+ WHERE source LIKE ? AND json_valid(content) AND json_extract(content, '$.distilled') = 1`);
71
+ const selStored = db.prepare(`SELECT id, layer, content, source FROM memories
72
+ WHERE source LIKE ?
73
+ AND (NOT json_valid(content) OR COALESCE(json_extract(content, '$.distilled'), 0) != 1)
74
+ ORDER BY id`);
75
+ const selEdits = db.prepare('SELECT turn_uuid, file_path, operation, occurred_at FROM session_file_edits WHERE session_id = ? ORDER BY id');
76
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
77
+ const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
78
+ const selEvt = db.prepare(`SELECT id FROM events WHERE kind = 'session_imported' AND json_valid(payload) AND json_extract(payload, '$.session_id') = ? ORDER BY id LIMIT 1`);
79
+ const updEvt = db.prepare('UPDATE events SET payload = ? WHERE id = ?');
80
+ const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
81
+ const run = db.transaction(() => {
82
+ const res = { inserted: 0, kept: 0, leftInPlace: 0, edits: 0, editsKept: 0 };
83
+ const distilledTurns = new Set();
84
+ for (const r of selDistilledTurns.all(sidNeedle)) {
85
+ const u = parseObj(r.source)?.turn_uuid;
86
+ if (u)
87
+ distilledTurns.add(String(u));
88
+ }
89
+ // Keys of the stored rows. Rows whose source has no kind (a much older version) get no key:
90
+ // they are never matched and never touched. The key is computed from the stored row as it
91
+ // is now (a user edit of `affects` changes it; the extraction is then inserted once more,
92
+ // the edited row is still not touched).
93
+ const storedByKey = new Map();
94
+ const storedKey = memKeyer();
95
+ const stored = selStored.all(sidNeedle);
96
+ for (const r of stored) {
97
+ const src = parseObj(r.source);
98
+ if (!src?.kind || src.session_id !== sid)
99
+ continue;
100
+ const k = storedKey(String(src.kind), String(src.turn_uuid ?? ''), r.layer, r.content);
101
+ if (!storedByKey.has(k))
102
+ storedByKey.set(k, r);
103
+ }
104
+ const freshKey = memKeyer();
105
+ const matched = new Set();
106
+ const memContentToId = new Map();
107
+ const now = Math.floor(Date.now() / 1000);
108
+ for (const m of input.memories) {
109
+ if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
110
+ continue; // distilled rewrite wins
111
+ const k = freshKey(m.source.kind, m.source.turn_uuid ?? '', m.layer, m.content);
112
+ const prev = storedByKey.get(k);
113
+ if (prev && !matched.has(prev.id)) {
114
+ matched.add(prev.id); // already imported: not touched
115
+ memContentToId.set(m.content, prev.id);
116
+ res.kept++;
117
+ continue;
118
+ }
119
+ const r = insMem.run(opts.entityId, m.layer, m.content, m.importance, opts.sourceJson(m.source), m.thread_id ?? null, m.occurred_at ?? now);
120
+ memContentToId.set(m.content, Number(r.lastInsertRowid));
121
+ res.inserted++;
122
+ }
123
+ res.leftInPlace = stored.length - matched.size;
124
+ faultPoint('import-after-memories');
125
+ // session_file_edits: existing rows (and the drift_edges pointing at them) are not touched.
126
+ const haveEdits = new Set();
127
+ const storedEditKey = editKeyer();
128
+ for (const e of selEdits.all(sid)) {
129
+ haveEdits.add(storedEditKey(e.turn_uuid, e.file_path, e.operation, e.occurred_at));
130
+ }
131
+ const freshEditKey = editKeyer();
132
+ for (const fe of input.file_edits) {
133
+ const k = freshEditKey(fe.turn_uuid ?? null, fe.file_path, fe.operation, fe.occurred_at);
134
+ if (haveEdits.has(k)) {
135
+ res.editsKept++;
136
+ continue;
137
+ }
138
+ const memId = fe.memory_content ? memContentToId.get(fe.memory_content) ?? null : null;
139
+ insEdit.run(fe.session_id, memId, fe.file_path, fe.operation, fe.turn_uuid ?? null, fe.context_snippet, fe.occurred_at);
140
+ res.edits++;
141
+ }
142
+ // One session_imported event per session, kept current. No other event is touched.
143
+ const payload = JSON.stringify({
144
+ session_id: sid,
145
+ memories: input.memories.length,
146
+ file_edits: input.file_edits.length,
147
+ stats: input.stats,
148
+ });
149
+ const evt = selEvt.get(sid);
150
+ if (evt)
151
+ updEvt.run(payload, evt.id);
152
+ else
153
+ insEvt.run(opts.entityId, 'session_imported', payload, opts.startedAt);
154
+ faultPoint('import-after-edits');
155
+ return res;
156
+ });
157
+ // IMMEDIATE: take the write lock before reading the stored keys, so another writer cannot
158
+ // add the same rows between the read and the insert.
159
+ return run.immediate();
160
+ }
161
+ //# sourceMappingURL=session-import.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;