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.
- package/README.md +22 -0
- package/dist/bin/consolidate.d.ts +12 -0
- package/dist/bin/consolidate.js +216 -0
- package/dist/bin/import-sessions.js +21 -100
- package/dist/lib/consolidate.d.ts +61 -6
- package/dist/lib/consolidate.js +199 -103
- package/dist/lib/distill-triage.js +9 -2
- package/dist/lib/forgetting.js +4 -3
- package/dist/lib/session-import.d.ts +22 -0
- package/dist/lib/session-import.js +161 -0
- package/dist/lib/test-faults.d.ts +7 -0
- package/dist/lib/test-faults.js +16 -0
- package/dist/mcp/server.js +95 -109
- package/dist/skill/SKILL.md +15 -13
- package/package.json +4 -2
package/dist/lib/consolidate.js
CHANGED
|
@@ -1,56 +1,82 @@
|
|
|
1
|
-
// Sleep-mode consolidation: cluster stale low-importance memories
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
42
|
-
const cold =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 = [...
|
|
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 =
|
|
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 =
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
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:
|
|
214
|
+
count: members.length,
|
|
117
215
|
period: { from: earliest, to: latest },
|
|
118
|
-
pattern: `${
|
|
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
|
-
|
|
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',
|
|
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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
|
|
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.
|
|
18
|
-
//
|
|
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: [] } };
|
package/dist/lib/forgetting.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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;
|