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/README.md
CHANGED
|
@@ -8,11 +8,27 @@ Needs Node.js 20 or later, and Claude Code or Codex to try it (either one is eno
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
Claude Code, from inside a session (plugin: MCP server and skill, no hooks):
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
/plugin marketplace add michielinksee/linksee-memory
|
|
15
|
+
/plugin install linksee-memory@linksee-memory
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or from a shell, which also installs the hooks:
|
|
19
|
+
|
|
11
20
|
```bash
|
|
12
21
|
npx -y linksee-memory setup # Claude Code: MCP server, skill, hooks
|
|
13
22
|
codex mcp add linksee -- npx -y linksee-memory # Codex: the same memory
|
|
14
23
|
```
|
|
15
24
|
|
|
25
|
+
The plugin starts the server with `npx -y linksee-memory@latest` and ships the same skill `setup` installs. It has no hooks: the Stop hook (auto-capture) and the [re-injection guard](docs/guard.md) come only from `setup`. One or the other, not both:
|
|
26
|
+
|
|
27
|
+
- Already ran `setup`? Do not add the plugin. With both, Claude Code runs the server twice (`linksee` and `plugin:linksee-memory:linksee`).
|
|
28
|
+
- Installed the plugin and want the hooks? Disable it (`/plugin` → Installed → linksee-memory → disable, or `claude plugin disable linksee-memory@linksee-memory`), then run `npx -y linksee-memory setup`. The memory file is the same; nothing is lost.
|
|
29
|
+
- Plugin only: no hooks, so nothing is saved unless the agent calls `remember` (the skill tells it when).
|
|
30
|
+
- Windows: after installing the plugin, run `/mcp` and check that `plugin:linksee-memory:linksee` is connected. If it is not, disable the plugin first (previous bullet), then run `npx -y linksee-memory setup` from the shell block above. `setup` registers the server with `claude mcp add`, the form that is known to work on Windows, and it does not disable the plugin for you.
|
|
31
|
+
|
|
16
32
|
One command for Claude Code. `setup --dry-run` prints what it would change. It edits `~/.claude/settings.json`, so the hooks apply in every repo, and keeps the old file as `settings.json.bak-<timestamp>` ([what it writes](#what-setup-writes)).
|
|
17
33
|
|
|
18
34
|
## First round trip
|
|
@@ -233,6 +249,12 @@ We installed them and ran one scenario across all of them. Storing and recalling
|
|
|
233
249
|
**How do I stop Claude Code from repeating the same mistake it made last week?**
|
|
234
250
|
Record it as a caveat (`remember({ content, layer: 'caveat' })`). Caveats are protected from forgetting and come back when `recall` touches the same ground again. To have Claude Code stop the repeat before it lands, record it as an anchor with a `violation_signal`, then harden it (`resolve_drift(action: "harden")`); without hardening, a match only warns.
|
|
235
251
|
|
|
252
|
+
**Does Linksee delete old memories on its own?**
|
|
253
|
+
Not since 0.18.1. Earlier versions had a "sleep" pass (`consolidate`) that folded cold memories into a short summary and deleted the originals — on the maintainer's own database it removed 1,360 rows on 2026-10-29 and 559 on 2026-11-05 without saying so. Now the only thing that removes a memory row is `remember({ memory_id, forget: true })`, by request — the Stop-hook re-import only adds rows (never touches ones it already wrote) and the start-up triage leaves caveats and protected rows alone. (Before importing, the normal schema migration runs; it can raise `protected` from 0 to 1 for rows with importance ≥ 0.9 and fill columns an older schema lacks, but it does not change a memory's text, relations or verdicts.) Consolidation still exists, by hand (`npx -y linksee-memory consolidate --dry-run` to see, `--apply` to fold) or opt-in at start-up (`LINKSEE_AUTO_CONSOLIDATE=1`); it writes one summary per cluster and marks the originals `consolidated_into`, which `recall` hides by default (`consolidated_hidden` says how many; `include_consolidated: true` shows them). Rows that an earlier version deleted come back only from a backup of `~/.linksee-memory/`.
|
|
254
|
+
|
|
255
|
+
**Linksee は古い記憶を勝手に消しますか?**
|
|
256
|
+
0.18.1 からは消しません。それ以前の版には「睡眠中の整理」(`consolidate`)があり、冷えた記憶を短い要約に畳んで原本を削除していました(開発者自身の DB で 2026-10-29 に 1,360 行、2026-11-05 に 559 行が、知らせも無く消えています)。いまは `remember({ memory_id, forget: true })` で頼んだ 1 行以外、記憶の行を消す処理はありません(Stop フックの再取り込みは追記だけで既存の行に触らず、起動時の整理は caveat と保護行に触りません。取り込みの前に通常の schema 移行が走り、保護フラグの補正(importance 0.9 以上の行の protected を 0→1 にする方向だけ)と旧 schema の列補完が起き得ますが、記憶の本文・関係・判定は変えません)。整理そのものは残っています。手で回す(`npx -y linksee-memory consolidate --dry-run` で中身を見て、`--apply` で畳む)か、起動時に走らせたい時だけ `LINKSEE_AUTO_CONSOLIDATE=1` を付けます。畳んだ原本には `consolidated_into` が付いて残り、`recall` は既定でそれを隠して件数(`consolidated_hidden`)だけ返します(`include_consolidated: true` で出ます)。前の版が消した行は、`~/.linksee-memory/` のバックアップからしか戻りません。
|
|
257
|
+
|
|
236
258
|
**開発の意思決定履歴をMCPサーバーで残しておく定番のやり方はありますか?**
|
|
237
259
|
`remember({ content, anchor: {} })` の1回で、記録と決定の登録が同時に入ります(Claude Code では再注入も)。反する編集で警告を出すには `violation_signal` を足し、拒否まで行うにはさらに `resolve_drift(action: "harden")` で hard にします。`drift_status` がその台帳で、各決定が いま守られているか(🔵)・ずれているか(🔴)・誰も確かめていないか(⚫)を示します。
|
|
238
260
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
interface Args {
|
|
3
|
+
dryRun: boolean;
|
|
4
|
+
apply: boolean;
|
|
5
|
+
minAgeDays?: number;
|
|
6
|
+
json: boolean;
|
|
7
|
+
help: boolean;
|
|
8
|
+
bad?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseArgs(argv: readonly string[]): Args;
|
|
11
|
+
export declare function main(argv?: readonly string[]): number;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory consolidate --dry-run | --apply [--min-age-days N] [--json] (0.18.1)
|
|
3
|
+
//
|
|
4
|
+
// Runs sleep-mode consolidation by hand. Since 0.18.1 it never deletes a row: each cluster of
|
|
5
|
+
// cold, unprotected, JSON-content memories (same entity, same layer, older than N days, 2 or
|
|
6
|
+
// more) gets one `learning`-layer summary, and the originals are marked
|
|
7
|
+
// `consolidated_into: <summary id>` in their content JSON. `recall` hides marked rows by
|
|
8
|
+
// default and says how many (`consolidated_hidden`); `include_consolidated: true` shows them.
|
|
9
|
+
//
|
|
10
|
+
// --dry-run prints what --apply would fold (counts, then up to 3 example clusters). Opens the
|
|
11
|
+
// DB read-only and does not run migrations: memory.db is not written. A schema older
|
|
12
|
+
// than this build → "migrate first", exit 1. Also prints how many rows the
|
|
13
|
+
// forget-sweep of 0.18.0 and earlier would have deleted.
|
|
14
|
+
// --apply folds, in one BEGIN IMMEDIATE transaction. Rows marked, 0 deleted. Any failure —
|
|
15
|
+
// including a row count other than before + summaries, checked inside the
|
|
16
|
+
// transaction — rolls the whole run back; exit 2.
|
|
17
|
+
// No flag prints this usage and exits 1 — the DB is not opened.
|
|
18
|
+
//
|
|
19
|
+
// The server's own run at start-up stays off unless LINKSEE_AUTO_CONSOLIDATE=1.
|
|
20
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import Database from 'better-sqlite3';
|
|
24
|
+
import { openDb, runMigrations, getDbPath, DbRefusedError, probeReadonly, migrateCommand } from '../db/migrate.js';
|
|
25
|
+
import { planConsolidate, consolidate, countSweepCandidates } from '../lib/consolidate.js';
|
|
26
|
+
const USAGE = `linksee-memory consolidate — fold cold memories into summaries (nothing is deleted)
|
|
27
|
+
|
|
28
|
+
--dry-run show what --apply would fold; read-only, writes nothing
|
|
29
|
+
--apply fold: one learning-layer summary per cluster, originals marked
|
|
30
|
+
consolidated_into and kept (recall hides them by default)
|
|
31
|
+
--min-age-days N only rows older than N days are candidates (default 7)
|
|
32
|
+
--json machine-readable output
|
|
33
|
+
-h, --help this message
|
|
34
|
+
|
|
35
|
+
Exactly one of --dry-run / --apply is required.
|
|
36
|
+
DB: ${getDbPath()}
|
|
37
|
+
`;
|
|
38
|
+
export function parseArgs(argv) {
|
|
39
|
+
const a = { dryRun: false, apply: false, json: false, help: false };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const v = argv[i];
|
|
42
|
+
if (v === '--dry-run' || v === '--dry')
|
|
43
|
+
a.dryRun = true;
|
|
44
|
+
else if (v === '--apply')
|
|
45
|
+
a.apply = true;
|
|
46
|
+
else if (v === '--json')
|
|
47
|
+
a.json = true;
|
|
48
|
+
else if (v === '-h' || v === '--help')
|
|
49
|
+
a.help = true;
|
|
50
|
+
else if (v === '--min-age-days') {
|
|
51
|
+
const n = Number(argv[++i]);
|
|
52
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
53
|
+
a.bad = `--min-age-days needs a number >= 0, got "${argv[i] ?? ''}"`;
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
a.minAgeDays = n;
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
a.bad = `unknown argument "${v}"`;
|
|
60
|
+
}
|
|
61
|
+
return a;
|
|
62
|
+
}
|
|
63
|
+
/** The schema version this build writes (from schema.sql), or 0 if it cannot be read. */
|
|
64
|
+
function targetSchema() {
|
|
65
|
+
try {
|
|
66
|
+
const sql = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'db', 'schema.sql'), 'utf8');
|
|
67
|
+
const m = sql.match(/VALUES \('schema_version', '(\d+)'\)/);
|
|
68
|
+
return m ? Number(m[1]) : 0;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* --dry-run: SQLITE_OPEN_READONLY, no runMigrations, so memory.db is not written (0.18.1, Codex
|
|
76
|
+
* review 1 of #23). The -wal/-shm a read-only open may add are removed again (probeReadonly's
|
|
77
|
+
* tidy). A schema older than this build → "migrate first", exit 1.
|
|
78
|
+
*/
|
|
79
|
+
function dryRun(args) {
|
|
80
|
+
const path = getDbPath();
|
|
81
|
+
const { probe, tidy } = probeReadonly(path);
|
|
82
|
+
if (probe.kind === 'missing') {
|
|
83
|
+
process.stdout.write(`consolidate --dry-run: no database at ${path}; nothing to fold\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (probe.kind !== 'version') {
|
|
87
|
+
tidy();
|
|
88
|
+
process.stderr.write(`consolidate --dry-run: cannot read ${path} read-only (${probe.kind}); nothing was done\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const want = targetSchema();
|
|
92
|
+
if (want === 0 || probe.version < want) {
|
|
93
|
+
tidy();
|
|
94
|
+
process.stderr.write(`consolidate --dry-run: the database is at schema ${probe.version}, this version expects ${want || '(unknown)'}. ` +
|
|
95
|
+
`Migrate first (\`${migrateCommand()}\`, or start the server of this version once); nothing was done.\n`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
99
|
+
try {
|
|
100
|
+
const before = db.prepare('SELECT COUNT(*) AS c FROM memories').get().c;
|
|
101
|
+
const plan = planConsolidate(db, { min_age_days: args.minAgeDays });
|
|
102
|
+
const sweepWouldHaveDropped = countSweepCandidates(db);
|
|
103
|
+
if (args.json) {
|
|
104
|
+
process.stdout.write(JSON.stringify({
|
|
105
|
+
dry_run: true,
|
|
106
|
+
memories: before,
|
|
107
|
+
scanned: plan.scanned,
|
|
108
|
+
clusters: plan.clusters.length,
|
|
109
|
+
would_fold: plan.wouldFold,
|
|
110
|
+
would_delete: 0,
|
|
111
|
+
cold_unclustered: plan.coldUnclustered,
|
|
112
|
+
skipped_plain_text: plan.skippedPlainText,
|
|
113
|
+
sweep_pre_0_18_1_would_have_deleted: sweepWouldHaveDropped,
|
|
114
|
+
examples: plan.clusters.slice(0, 3).map((c) => ({ entity: c.entity_name, layer: c.layer, ids: c.ids, examples: c.examples })),
|
|
115
|
+
}, null, 2) + '\n');
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const lines = [
|
|
119
|
+
`consolidate --dry-run (read-only; nothing written)`,
|
|
120
|
+
` memories in DB: ${before}`,
|
|
121
|
+
` scanned (candidates): ${plan.scanned}`,
|
|
122
|
+
` clusters: ${plan.clusters.length}`,
|
|
123
|
+
` rows --apply would mark: ${plan.wouldFold} (consolidated_into; rows stay)`,
|
|
124
|
+
` rows --apply would delete: 0`,
|
|
125
|
+
` cold but unclustered: ${plan.coldUnclustered} (left alone)`,
|
|
126
|
+
` plain-text, skipped: ${plan.skippedPlainText} (the mark needs JSON content)`,
|
|
127
|
+
` the forget-sweep of 0.18.0 and earlier would have deleted: ${sweepWouldHaveDropped}`,
|
|
128
|
+
];
|
|
129
|
+
if (plan.clusters.length > 0) {
|
|
130
|
+
lines.push('', ` examples (${Math.min(3, plan.clusters.length)} of ${plan.clusters.length} clusters):`);
|
|
131
|
+
for (const c of plan.clusters.slice(0, 3)) {
|
|
132
|
+
lines.push(` ${c.entity_name} / ${c.layer}: ${c.ids.length} rows (ids ${c.ids.slice(0, 5).join(', ')}${c.ids.length > 5 ? ', …' : ''})`);
|
|
133
|
+
for (const ex of c.examples)
|
|
134
|
+
lines.push(` - ${ex}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
lines.push('', ` to fold: npx linksee-memory consolidate --apply`);
|
|
138
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
db.close();
|
|
143
|
+
tidy();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function main(argv = process.argv.slice(2)) {
|
|
147
|
+
const args = parseArgs(argv);
|
|
148
|
+
if (args.help) {
|
|
149
|
+
process.stdout.write(USAGE);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
if (args.bad) {
|
|
153
|
+
process.stderr.write(`${args.bad}\n\n${USAGE}`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
if (args.dryRun === args.apply) {
|
|
157
|
+
// neither, or both: refuse before touching the DB
|
|
158
|
+
process.stderr.write(`consolidate: pass exactly one of --dry-run or --apply (nothing was done)\n\n${USAGE}`);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (args.dryRun)
|
|
162
|
+
return dryRun(args);
|
|
163
|
+
let db;
|
|
164
|
+
try {
|
|
165
|
+
db = openDb();
|
|
166
|
+
runMigrations(db);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e instanceof DbRefusedError) {
|
|
170
|
+
process.stderr.write(`${e.message}\n`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
let r;
|
|
177
|
+
try {
|
|
178
|
+
r = consolidate(db, { scope: 'all', min_age_days: args.minAgeDays });
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
process.stderr.write(`consolidate --apply failed and was rolled back; nothing was written: ${e?.message ?? e}\n`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
if (args.json) {
|
|
185
|
+
process.stdout.write(JSON.stringify({ applied: true, ...r }, null, 2) + '\n');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
process.stdout.write([
|
|
189
|
+
`consolidate --apply`,
|
|
190
|
+
` summaries written: ${r.clustersCompressed} (learning layer, ids ${r.learningIdsCreated.slice(0, 5).join(', ')}${r.learningIdsCreated.length > 5 ? ', …' : ''})`,
|
|
191
|
+
` rows marked consolidated_into: ${r.memoriesFolded} (still in the DB; recall({ include_consolidated: true }) shows them)`,
|
|
192
|
+
` rows deleted: ${r.memoriesDropped}`,
|
|
193
|
+
` memories before / after: ${r.memoriesBefore} / ${r.memoriesAfter}`,
|
|
194
|
+
` in_progress → stalled: ${r.stalledTransitions}`,
|
|
195
|
+
` decision edges detected: ${r.memoryEdgesCreated} (superseded marked: ${r.decisionsSuperseded})`,
|
|
196
|
+
].join('\n') + '\n');
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
db.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const isMain = (() => {
|
|
205
|
+
try {
|
|
206
|
+
const a = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
207
|
+
const b = realpathSync(fileURLToPath(import.meta.url));
|
|
208
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
if (isMain)
|
|
215
|
+
process.exit(main(process.argv.slice(2)));
|
|
216
|
+
//# sourceMappingURL=consolidate.js.map
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// node dist/bin/import-sessions.js --all (scans all ~/.claude/projects/*)
|
|
7
7
|
// node dist/bin/import-sessions.js --session-file <path> (single jsonl — used by hook)
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Re-imports are idempotent and append-only (0.18.1): rows already imported are never touched;
|
|
10
|
+
// new ones are inserted, in one BEGIN IMMEDIATE transaction per session — see lib/session-import.ts.
|
|
10
11
|
import { readdirSync, statSync } from 'node:fs';
|
|
11
12
|
import { join } from 'node:path';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
@@ -14,6 +15,7 @@ import { openDb, runMigrations } from '../db/migrate.js';
|
|
|
14
15
|
import { parseSessionFile, detectProjectName } from '../lib/session-parser.js';
|
|
15
16
|
import { extractSession } from '../lib/session-extractor.js';
|
|
16
17
|
import { normalizeEntityName } from '../lib/normalize.js';
|
|
18
|
+
import { importSession } from '../lib/session-import.js';
|
|
17
19
|
const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
|
|
18
20
|
function usage() {
|
|
19
21
|
console.log(`Usage:
|
|
@@ -40,35 +42,6 @@ function collectJsonlFiles(projectDir) {
|
|
|
40
42
|
return [];
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
|
-
// Idempotent wipe: remove all rows tied to a given session_id BEFORE re-inserting.
|
|
44
|
-
// Uses LIKE matching on the JSON-encoded source field for memories/events.
|
|
45
|
-
// DISTILLED memories survive the wipe: a session stays ACTIVE while its raw extractions
|
|
46
|
-
// get distilled (dream → remember(memory_id) rewrite), and the next Stop-hook re-import
|
|
47
|
-
// must not destroy that human/agent-curated rewrite and resurrect the raw utterance.
|
|
48
|
-
function wipeSession(db, sessionId) {
|
|
49
|
-
const sidNeedle = `%"session_id":"${sessionId}"%`;
|
|
50
|
-
const editDel = db.prepare('DELETE FROM session_file_edits WHERE session_id = ?').run(sessionId);
|
|
51
|
-
const memDel = db.prepare(`DELETE FROM memories WHERE source LIKE ?
|
|
52
|
-
AND (NOT json_valid(content) OR COALESCE(json_extract(content, '$.distilled'), 0) != 1)`).run(sidNeedle);
|
|
53
|
-
const evtDel = db.prepare('DELETE FROM events WHERE payload LIKE ?').run(sidNeedle);
|
|
54
|
-
return { memories: memDel.changes, edits: editDel.changes, events: evtDel.changes };
|
|
55
|
-
}
|
|
56
|
-
// A re-extracted memory must NOT be re-inserted if its source turn already has a surviving
|
|
57
|
-
// distilled rewrite (matched by turn_uuid — the stable key shared by raw and rewrite).
|
|
58
|
-
function makeDistilledTurnSet(db, sessionId) {
|
|
59
|
-
const rows = db.prepare(`SELECT source FROM memories
|
|
60
|
-
WHERE source LIKE ? AND json_valid(content) AND json_extract(content, '$.distilled') = 1`).all(`%"session_id":"${sessionId}"%`);
|
|
61
|
-
const set = new Set();
|
|
62
|
-
for (const r of rows) {
|
|
63
|
-
try {
|
|
64
|
-
const uuid = JSON.parse(r.source)?.turn_uuid;
|
|
65
|
-
if (uuid)
|
|
66
|
-
set.add(String(uuid));
|
|
67
|
-
}
|
|
68
|
-
catch { /* ignore malformed source */ }
|
|
69
|
-
}
|
|
70
|
-
return set;
|
|
71
|
-
}
|
|
72
45
|
async function main() {
|
|
73
46
|
const args = process.argv.slice(2);
|
|
74
47
|
if (args.includes('-h') || args.includes('--help')) {
|
|
@@ -86,9 +59,8 @@ async function main() {
|
|
|
86
59
|
const projectArgs = args.filter((a, i) => !a.startsWith('--')
|
|
87
60
|
&& (sessionFileIdx < 0 || i !== sessionFileIdx + 1)
|
|
88
61
|
&& (viaIdx < 0 || i !== viaIdx + 1));
|
|
89
|
-
// The session keys are kept as they were:
|
|
90
|
-
//
|
|
91
|
-
// Known, not changed here: wipeSession runs outside the re-insert transaction.
|
|
62
|
+
// The session keys are kept as they were: importSession finds these rows by the substring
|
|
63
|
+
// `"session_id":"…"` (position does not matter, the exact text does).
|
|
92
64
|
const sourceJson = (src) => JSON.stringify({ ...src, client: 'claude-code', via });
|
|
93
65
|
// Single-file mode (used by the Stop hook)
|
|
94
66
|
if (sessionFile) {
|
|
@@ -115,11 +87,6 @@ async function main() {
|
|
|
115
87
|
}
|
|
116
88
|
if (!db)
|
|
117
89
|
return;
|
|
118
|
-
// Idempotent: wipe any prior data for THIS session before re-inserting
|
|
119
|
-
// (distilled rewrites survive — see wipeSession), then skip re-extracting
|
|
120
|
-
// any turn that already has a distilled rewrite.
|
|
121
|
-
const wiped = wipeSession(db, result.session_id);
|
|
122
|
-
const distilledTurns = makeDistilledTurnSet(db, result.session_id);
|
|
123
90
|
// Resolve project entity (canonical_key = project:<name>, so same project across
|
|
124
91
|
// different sessions/cwds collapses to one entity)
|
|
125
92
|
let projectEntityId;
|
|
@@ -135,35 +102,18 @@ async function main() {
|
|
|
135
102
|
const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
|
|
136
103
|
projectEntityId = Number(ins.lastInsertRowid);
|
|
137
104
|
}
|
|
138
|
-
// created_at = the source turn's true timestamp
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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));
|
|
151
|
-
memContentToId.set(m.content, Number(res.lastInsertRowid));
|
|
152
|
-
inserted.memories++;
|
|
153
|
-
}
|
|
154
|
-
for (const fe of result.file_edits) {
|
|
155
|
-
const memId = fe.memory_content ? memContentToId.get(fe.memory_content) ?? null : null;
|
|
156
|
-
insEdit.run(fe.session_id, memId, fe.file_path, fe.operation, fe.turn_uuid ?? null, fe.context_snippet, fe.occurred_at);
|
|
157
|
-
inserted.edits++;
|
|
158
|
-
}
|
|
159
|
-
insEvt.run(projectEntityId, 'session_imported', JSON.stringify({
|
|
160
|
-
session_id: result.session_id,
|
|
161
|
-
memories: result.memories.length,
|
|
162
|
-
file_edits: result.file_edits.length,
|
|
163
|
-
stats: result.stats,
|
|
164
|
-
}), parsed.started_at || Math.floor(Date.now() / 1000));
|
|
165
|
-
})();
|
|
166
|
-
console.log(`[ok] ${result.session_id.slice(0, 8)} (${projectName}): wiped ${wiped.memories}m/${wiped.edits}e/${wiped.events}ev → inserted ${inserted.memories}m/${inserted.edits}e`);
|
|
105
|
+
// created_at = the source turn's true timestamp (a re-import must not restamp old content).
|
|
106
|
+
let r;
|
|
107
|
+
try {
|
|
108
|
+
r = importSession(db, result, { entityId: projectEntityId, sourceJson, startedAt: parsed.started_at || Math.floor(Date.now() / 1000) });
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
// The transaction rolled back: the session's earlier rows are as they were.
|
|
112
|
+
console.error(`[error] ${result.session_id.slice(0, 8)} (${projectName}): ${e?.message ?? e} — nothing written`);
|
|
113
|
+
db.close();
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
console.log(`[ok] ${result.session_id.slice(0, 8)} (${projectName}): inserted ${r.inserted}m + ${r.edits}e, kept ${r.kept}m + ${r.editsKept}e untouched, left ${r.leftInPlace}m (append-only, 0 deleted)`);
|
|
167
117
|
db.close();
|
|
168
118
|
return;
|
|
169
119
|
}
|
|
@@ -259,43 +209,14 @@ async function main() {
|
|
|
259
209
|
const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
|
|
260
210
|
projectEntityId = Number(ins.lastInsertRowid);
|
|
261
211
|
}
|
|
262
|
-
// Idempotent: wipe any prior data for THIS session before re-inserting (Phase B).
|
|
263
|
-
// Distilled rewrites survive the wipe and suppress re-insertion of their raw turn.
|
|
264
|
-
wipeSession(db, result.session_id);
|
|
265
|
-
const distilledTurns = makeDistilledTurnSet(db, result.session_id);
|
|
266
|
-
// Insert memories + file_edits in a single transaction per session.
|
|
267
|
-
// created_at = source-turn timestamp (see single-file mode above for why).
|
|
268
|
-
const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
269
|
-
const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
270
|
-
const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
|
|
271
|
-
const tx = db.transaction(() => {
|
|
272
|
-
const memContentToId = new Map();
|
|
273
|
-
for (const m of result.memories) {
|
|
274
|
-
if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
|
|
275
|
-
continue; // distilled rewrite wins
|
|
276
|
-
const srcJson = sourceJson(m.source);
|
|
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));
|
|
278
|
-
memContentToId.set(m.content, Number(res.lastInsertRowid));
|
|
279
|
-
agg.memories_inserted++;
|
|
280
|
-
}
|
|
281
|
-
for (const fe of result.file_edits) {
|
|
282
|
-
const memId = fe.memory_content ? memContentToId.get(fe.memory_content) ?? null : null;
|
|
283
|
-
insEdit.run(fe.session_id, memId, fe.file_path, fe.operation, fe.turn_uuid ?? null, fe.context_snippet, fe.occurred_at);
|
|
284
|
-
agg.file_edits_inserted++;
|
|
285
|
-
}
|
|
286
|
-
insEvt.run(projectEntityId, 'session_imported', JSON.stringify({
|
|
287
|
-
session_id: result.session_id,
|
|
288
|
-
memories: result.memories.length,
|
|
289
|
-
file_edits: result.file_edits.length,
|
|
290
|
-
stats: result.stats,
|
|
291
|
-
}), parsed.started_at || Math.floor(Date.now() / 1000));
|
|
292
|
-
});
|
|
293
212
|
try {
|
|
294
|
-
|
|
213
|
+
const r = importSession(db, result, { entityId: projectEntityId, sourceJson, startedAt: parsed.started_at || Math.floor(Date.now() / 1000) });
|
|
214
|
+
agg.memories_inserted += r.inserted;
|
|
215
|
+
agg.file_edits_inserted += r.edits;
|
|
295
216
|
}
|
|
296
217
|
catch (e) {
|
|
297
218
|
agg.errors++;
|
|
298
|
-
console.warn(` [tx error] ${f}: ${e?.message ?? e}`);
|
|
219
|
+
console.warn(` [tx error] ${f}: ${e?.message ?? e} (rolled back; earlier rows kept)`);
|
|
299
220
|
}
|
|
300
221
|
}
|
|
301
222
|
}
|
|
@@ -2,23 +2,78 @@ import type Database from 'better-sqlite3';
|
|
|
2
2
|
export interface ConsolidateResult {
|
|
3
3
|
scanned: number;
|
|
4
4
|
clustersCompressed: number;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** Rows marked `consolidated_into` (kept in the table; hidden from default recall). */
|
|
6
|
+
memoriesFolded: number;
|
|
7
|
+
/** Always 0 since 0.18.1 — kept so older readers of the result do not break. */
|
|
8
|
+
memoriesDropped: 0;
|
|
9
|
+
/** Cold rows that did not form a cluster. Nothing is done to them. */
|
|
10
|
+
coldUnclustered: number;
|
|
11
|
+
/** Cold rows with plain-text content, left out because the mark needs JSON. */
|
|
12
|
+
skippedPlainText: number;
|
|
7
13
|
learningIdsCreated: number[];
|
|
14
|
+
/** Row counts read inside the run's transaction (0.18.1). after = before + summaries, or the run rolls back. */
|
|
15
|
+
memoriesBefore: number;
|
|
16
|
+
memoriesAfter: number;
|
|
8
17
|
stalledTransitions: number;
|
|
9
18
|
memoryEdgesCreated: number;
|
|
10
19
|
decisionsSuperseded: number;
|
|
11
20
|
}
|
|
21
|
+
interface Candidate {
|
|
22
|
+
id: number;
|
|
23
|
+
entity_id: number;
|
|
24
|
+
entity_name: string;
|
|
25
|
+
layer: string;
|
|
26
|
+
content: string;
|
|
27
|
+
importance: number;
|
|
28
|
+
last_accessed_at: number;
|
|
29
|
+
access_count: number;
|
|
30
|
+
created_at: number;
|
|
31
|
+
protected: number;
|
|
32
|
+
altitude: string | null;
|
|
33
|
+
mem_type: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface ConsolidatePlanCluster {
|
|
36
|
+
entity_id: number;
|
|
37
|
+
entity_name: string;
|
|
38
|
+
layer: string;
|
|
39
|
+
ids: number[];
|
|
40
|
+
members: Candidate[];
|
|
41
|
+
/** Up to 3 content fragments (first 120 chars), for a dry-run listing. */
|
|
42
|
+
examples: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface ConsolidatePlan {
|
|
45
|
+
scanned: number;
|
|
46
|
+
clusters: ConsolidatePlanCluster[];
|
|
47
|
+
/** Number of rows that `--apply` would mark. */
|
|
48
|
+
wouldFold: number;
|
|
49
|
+
coldUnclustered: number;
|
|
50
|
+
skippedPlainText: number;
|
|
51
|
+
}
|
|
52
|
+
/** SQL fragment: the row has been folded into a summary (alias `m` for memories). */
|
|
53
|
+
export declare const CONSOLIDATED_SQL = "(json_valid(m.content) AND json_type(m.content) = 'object' AND json_extract(m.content, '$.consolidated_into') IS NOT NULL)";
|
|
54
|
+
/**
|
|
55
|
+
* What consolidate would do, without writing. Shared by `consolidate` (which applies it),
|
|
56
|
+
* the `consolidate --dry-run` subcommand, and the start-up notice.
|
|
57
|
+
*/
|
|
58
|
+
export declare function planConsolidate(db: Database.Database, opts?: {
|
|
59
|
+
min_age_days?: number;
|
|
60
|
+
}, now?: number): ConsolidatePlan;
|
|
12
61
|
export declare function consolidate(db: Database.Database, opts?: {
|
|
13
62
|
scope?: 'all' | 'session';
|
|
14
63
|
min_age_days?: number;
|
|
15
64
|
}): ConsolidateResult;
|
|
65
|
+
/**
|
|
66
|
+
* How many rows the (removed) forget-sweep would have dropped — reported, never acted on.
|
|
67
|
+
* Kept for `consolidate --dry-run` so the user can see what earlier versions were deleting.
|
|
68
|
+
*/
|
|
69
|
+
export declare function countSweepCandidates(db: Database.Database, now?: number): number;
|
|
16
70
|
/**
|
|
17
71
|
* Should the MCP server run consolidate on its own at start-up? (0.17.1, L-1-106 / audit F1)
|
|
18
72
|
*
|
|
19
|
-
* No, unless the user asked
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
73
|
+
* No, unless the user asked. Opt in with exactly LINKSEE_AUTO_CONSOLIDATE=1 (0.18.1: true/on/
|
|
74
|
+
* yes/weekly no longer count), or `--auto-consolidate` on the server command line. The weekly gate inside the server still
|
|
75
|
+
* applies once enabled. Since 0.18.1 consolidate marks rows instead of deleting them, but it
|
|
76
|
+
* still rewrites content and hides rows from the default recall, so it stays opt-in.
|
|
23
77
|
*/
|
|
24
78
|
export declare function autoConsolidateEnabled(env: Record<string, string | undefined>, argv: readonly string[]): boolean;
|
|
79
|
+
export {};
|