lucid-mem 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ amr add "I prefer concise summaries" # global memory
25
25
  amr add "uses Kokoro-82M for TTS" --project # this repo's memory
26
26
  amr search "tts"
27
27
  amr link other-repo # auto-attach another repo's memory to sessions here
28
+ amr peer add <url> # compose a teammate's memory repo into your context
28
29
  amr discover # which hot repos still lack memory
29
30
  amr dream --fix --compact # dedupe + archive overflow
30
31
  ```
package/lib/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  cmdEnsure,
8
8
  cmdIndex,
9
9
  cmdLink,
10
+ cmdPeer,
10
11
  cmdPolicy,
11
12
  cmdRemote,
12
13
  cmdSearch,
@@ -33,6 +34,7 @@ usage: amr <command> [args] [flags]
33
34
  link <name...> [--for <p>] auto-attach other repos' memories to this repo's sessions
34
35
  unlink <name...> [--for <p>] remove auto-attach links
35
36
  policy [name key bool] per-repo policy: noglobal, private (no args = list)
37
+ peer [add <url>|remove <n>] attach another memory repo (multiplayer); clones into <AMR_HOME>-peers
36
38
  remote [set <url>] show or set the git remote (private repos only)
37
39
  sync pull --rebase + push (no-op without a remote)
38
40
  doctor check repo, PATH, and per-host wiring
@@ -115,6 +117,8 @@ export function main(argv) {
115
117
  return cmdUnlink(pos, flags);
116
118
  case 'policy':
117
119
  return cmdPolicy(pos);
120
+ case 'peer':
121
+ return cmdPeer(pos, flags);
118
122
  case 'remote':
119
123
  return cmdRemote(pos);
120
124
  case 'sync':
package/lib/commands.mjs CHANGED
@@ -27,6 +27,17 @@ import {
27
27
  const out = (s = '') => process.stdout.write(s + '\n');
28
28
  const err = (s) => process.stderr.write(s + '\n');
29
29
 
30
+ // Peer memory repos (amr.json extraRepos): separate memory repos cloned
31
+ // read-only next to AMR_HOME so teammates' memories compose into context
32
+ // without merging repos. Never written to — sync only pulls them.
33
+ const peerRoot = (home) =>
34
+ path.join(path.dirname(home), `${path.basename(home)}-peers`);
35
+ const peerDir = (home, name) => path.join(peerRoot(home), name);
36
+ const peerNameOf = (url) =>
37
+ normName(
38
+ path.basename(String(url).replace(/[\\/]+$/, '')).replace(/\.git$/i, '')
39
+ ) || 'peer';
40
+
30
41
  function mdFilesUnder(home, absDir) {
31
42
  const files = [];
32
43
  (function walk(dir) {
@@ -138,6 +149,40 @@ export function cmdContext(flags) {
138
149
  '',
139
150
  `<!-- ${unknown.length} linked project name(s) unresolved (no memory file): ${unknown.join(', ')} -->`
140
151
  );
152
+ // Peer repos: other memory repos attached via `amr peer add`. Each
153
+ // contributes its global digest plus its own copy of the current
154
+ // project's file — the multiplayer case (teammate memory for the
155
+ // same repo). Bounded: max 3 peers, 10 global + 8 project bullets.
156
+ const missingPeers = [];
157
+ for (const p of (cfg.extraRepos || []).slice(0, 3)) {
158
+ const dir = peerDir(home, p.name);
159
+ const pm = path.join(dir, 'MEMORY.md');
160
+ if (!fs.existsSync(pm)) {
161
+ missingPeers.push(p.name);
162
+ continue;
163
+ }
164
+ const g = cap(
165
+ bullets(
166
+ fs.readFileSync(pm, 'utf8').split(/^##\s+Index\s*$/m)[0] || ''
167
+ ),
168
+ 10
169
+ );
170
+ if (g.length)
171
+ lines.push('', `## Peer: ${p.name} (read-only)`, g.join('\n'));
172
+ if (proj) {
173
+ const pf = path.join(dir, 'projects', `${proj.slug}.md`);
174
+ if (fs.existsSync(pf)) {
175
+ const pb = cap(bullets(fs.readFileSync(pf, 'utf8')), 8);
176
+ if (pb.length)
177
+ lines.push(`### ${p.name} memory for this project`, pb.join('\n'));
178
+ }
179
+ }
180
+ }
181
+ if (missingPeers.length)
182
+ lines.push(
183
+ '',
184
+ `<!-- configured peer(s) not cloned: ${missingPeers.join(', ')} — run amr peer -->`
185
+ );
141
186
  // Recall pointer: archive/ holds compacted (oldest) entries —
142
187
  // searchable but never session-injected.
143
188
  const adir = path.join(home, 'archive');
@@ -345,8 +390,80 @@ export function cmdRemote(args) {
345
390
  out(r.stdout || 'no remote configured');
346
391
  }
347
392
 
393
+ export function cmdPeer(args, flags) {
394
+ const home = ensureRepo();
395
+ const cfg = loadConfig(home);
396
+ const peers = (cfg.extraRepos ||= []);
397
+ const [sub, ...rest] = args;
398
+
399
+ if (!sub) {
400
+ if (!peers.length)
401
+ return out('no peers configured — amr peer add <url-or-path>');
402
+ for (const p of peers) {
403
+ const dir = peerDir(home, p.name);
404
+ out(
405
+ `${p.name}: ${p.url} -> ${
406
+ fs.existsSync(dir) ? `${dir} (cloned)` : 'NOT CLONED'
407
+ }`
408
+ );
409
+ }
410
+ return;
411
+ }
412
+
413
+ if (sub === 'add') {
414
+ let url = rest[0];
415
+ if (!url)
416
+ throw new Error('usage: amr peer add <url-or-path> [--name x]');
417
+ // Bare local paths resolve against cwd, not the peer root.
418
+ if (!/^[a-z]+:\/\/|^git@|^ssh:/i.test(url)) url = path.resolve(url);
419
+ const name = normName(flags.name || peerNameOf(url));
420
+ if (peers.some((p) => p.name === name))
421
+ throw new Error(`peer "${name}" already configured`);
422
+ const dir = peerDir(home, name);
423
+ if (!fs.existsSync(dir)) {
424
+ fs.mkdirSync(peerRoot(home), { recursive: true });
425
+ const r = git(peerRoot(home), ['clone', '--depth', '50', '--', url, dir], {
426
+ timeout: 120000,
427
+ });
428
+ if (r.code !== 0)
429
+ throw new Error(`clone failed: ${(r.stderr || '').trim()}`);
430
+ }
431
+ if (!fs.existsSync(path.join(dir, 'MEMORY.md')))
432
+ err('warning: no MEMORY.md in clone — may not be an agent memory repo');
433
+ peers.push({ name, url });
434
+ saveConfig(home, cfg);
435
+ commit(home, ['amr.json'], `Add memory peer: ${name}`);
436
+ out(`peer ${name} -> ${dir}`);
437
+ return;
438
+ }
439
+
440
+ if (sub === 'remove') {
441
+ const name = normName(rest[0] || '');
442
+ const i = peers.findIndex((p) => p.name === name);
443
+ if (i < 0) throw new Error(`unknown peer "${rest[0] || ''}"`);
444
+ peers.splice(i, 1);
445
+ if (!peers.length) delete cfg.extraRepos;
446
+ saveConfig(home, cfg);
447
+ commit(home, ['amr.json'], `Remove memory peer: ${name}`);
448
+ out(`detached peer ${name}; clone left at ${peerDir(home, name)}`);
449
+ return;
450
+ }
451
+
452
+ throw new Error('usage: amr peer [add <url> [--name x] | remove <name>]');
453
+ }
454
+
348
455
  export function cmdSync() {
349
456
  const home = ensureRepo();
457
+ // Refresh read-only peer clones first; a stale peer is a warn, not a
458
+ // blocker for syncing our own memory.
459
+ const cfg = loadConfig(home);
460
+ for (const p of cfg.extraRepos || []) {
461
+ const dir = peerDir(home, p.name);
462
+ if (!fs.existsSync(dir)) continue;
463
+ const r = git(dir, ['pull', '--ff-only'], { timeout: 10000 });
464
+ if (r.code !== 0)
465
+ err(`peer ${p.name} pull failed: ${(r.stderr || '').trim()}`);
466
+ }
350
467
  if (!hasRemote(home)) return out('no remote configured; local only');
351
468
  const hasUpstream =
352
469
  git(home, ['rev-parse', '--abbrev-ref', '@{u}']).code === 0;
package/lib/harvest.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from 'node:module';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -75,8 +76,102 @@ const SOURCES = [
75
76
  ],
76
77
  ext: ['.jsonl', '.json'],
77
78
  },
79
+ {
80
+ host: 'opencode',
81
+ roots: [
82
+ path.join(home, '.local', 'share', 'opencode'),
83
+ path.join(localappdata, 'opencode'),
84
+ ],
85
+ ext: ['.db'],
86
+ match: /^opencode\.db$/i,
87
+ },
88
+ {
89
+ host: 'antigravity',
90
+ roots: [path.join(home, '.gemini', 'antigravity', 'conversations')],
91
+ ext: ['.db'],
92
+ },
93
+ // windsurf: Cascade keeps conversations in protobuf/leveldb under
94
+ // %APPDATA%/Windsurf and embedding blobs under ~/.codeium/database;
95
+ // no user-message transcript store to harvest.
78
96
  ];
79
97
 
98
+ // node:sqlite is built in from Node 22.5; DB sources degrade silently
99
+ // on older runtimes (file-based hosts keep working). Lazy require so
100
+ // the ExperimentalWarning never pollutes non-harvest invocations.
101
+ let DatabaseSync;
102
+ const loadSqlite = () => {
103
+ if (DatabaseSync !== undefined) return DatabaseSync;
104
+ try {
105
+ DatabaseSync = createRequire(import.meta.url)('node:sqlite').DatabaseSync;
106
+ } catch {
107
+ DatabaseSync = null;
108
+ }
109
+ return DatabaseSync;
110
+ };
111
+
112
+ const openDb = (p) => {
113
+ const DS = loadSqlite();
114
+ if (!DS) return null;
115
+ try {
116
+ return new DS(p, { readOnly: true });
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ // Antigravity step payloads are protobuf serialized as {"0":byte,...}.
123
+ // step_type 14 is the user message; pull printable runs out of the
124
+ // bytes and drop ID-shaped strings (uuids, base64url tokens).
125
+ const agyUserText = (payload) => {
126
+ let buf;
127
+ if (Buffer.isBuffer(payload) || payload instanceof Uint8Array) {
128
+ buf = Buffer.from(payload);
129
+ } else {
130
+ let obj;
131
+ try {
132
+ obj = typeof payload === 'string' ? JSON.parse(payload) : payload;
133
+ } catch {
134
+ return '';
135
+ }
136
+ if (!obj || typeof obj !== 'object') return '';
137
+ buf = Buffer.from(Object.values(obj));
138
+ }
139
+ const text = buf.toString('utf8');
140
+ const runs =
141
+ text.match(/[ -~\u00a0-\u2fff\ue000-\ufffc]{15,}/g) || [];
142
+ // Keep prose runs: several words, mostly letters, little punctuation
143
+ // junk, no filename dumps or injected <TAG> blobs. The protobuf echoes
144
+ // the user string twice inside one payload; collapse the repeat.
145
+ const JUNK = /[-_$/\\*{}<>="#|~`@^[\]]/g;
146
+ const seen = new Set();
147
+ const kept = [];
148
+ for (const raw of runs) {
149
+ let t = raw
150
+ .trim()
151
+ .replace(/^[^\w<]+/, '')
152
+ .replace(/^[A-Z](?=[A-Z][a-z])/, '');
153
+ for (let len = t.length >> 1; len >= 15; len--) {
154
+ const i = t.indexOf(t.slice(0, len), 1);
155
+ if (i > 0) {
156
+ t = t.slice(0, i).replace(/\s+\S{1,4}["']?$/, '').trim();
157
+ break;
158
+ }
159
+ }
160
+ t = t.replace(/["'\s]+$/, '').trim();
161
+ if (t.split(/\s+/).filter(Boolean).length < 3) continue;
162
+ if (!/[a-z]/.test(t)) continue;
163
+ if (/^<[A-Za-z_][A-Za-z0-9_-]*[\s>]/.test(t)) continue;
164
+ if (/\.(png|jpe?g|gif|webp|exe|dll|db|md|json|log)\b/i.test(t))
165
+ continue;
166
+ if ((t.match(/[A-Za-z]/g) || []).length / t.length < 0.5) continue;
167
+ if ((t.match(JUNK) || []).length / t.length > 0.3) continue;
168
+ if (seen.has(t)) continue;
169
+ seen.add(t);
170
+ kept.push(t);
171
+ }
172
+ return kept.join(' ');
173
+ };
174
+
80
175
  const MAX_PER_HOST = 100;
81
176
 
82
177
  // ---------- transcript extraction ----------
@@ -188,6 +283,47 @@ const EXTRACT = {
188
283
  out.push(clean(meta.prompt.replace(/<[^>]+>/g, ' ')));
189
284
  return out;
190
285
  },
286
+ // opencode keeps sessions in drizzle sqlite: message.data.role +
287
+ // part.data joined on message_id; text parts carry user prose.
288
+ opencode(p, since = 0) {
289
+ const db = openDb(p);
290
+ if (!db) return [];
291
+ try {
292
+ return db
293
+ .prepare(
294
+ `select p.data d from part p
295
+ join message m on p.message_id = m.id
296
+ where json_extract(m.data,'$.role') = 'user'
297
+ and json_extract(p.data,'$.type') = 'text'
298
+ and m.time_created >= ?
299
+ order by m.time_created`
300
+ )
301
+ .all(since)
302
+ .map((r) => clean(JSON.parse(r.d).text))
303
+ .filter((t) => !isNoise(t));
304
+ } catch {
305
+ return [];
306
+ } finally {
307
+ db.close();
308
+ }
309
+ },
310
+ // antigravity stores one sqlite trajectory db per conversation;
311
+ // steps.step_type 14 rows carry the user prompt as protobuf bytes.
312
+ antigravity(p) {
313
+ const db = openDb(p);
314
+ if (!db) return [];
315
+ try {
316
+ return db
317
+ .prepare('select step_payload from steps where step_type = 14')
318
+ .all()
319
+ .map((r) => clean(agyUserText(r.step_payload)))
320
+ .filter((t) => !isNoise(t));
321
+ } catch {
322
+ return [];
323
+ } finally {
324
+ db.close();
325
+ }
326
+ },
191
327
  generic(p) {
192
328
  if (p.endsWith('.jsonl'))
193
329
  return parseJsonl(p)
@@ -228,7 +364,8 @@ export function collectSessions(since) {
228
364
  if (e.isDirectory()) walk(p);
229
365
  else if (
230
366
  e.isFile() &&
231
- src.ext.includes(path.extname(e.name).toLowerCase())
367
+ src.ext.includes(path.extname(e.name).toLowerCase()) &&
368
+ (!src.match || src.match.test(e.name))
232
369
  ) {
233
370
  try {
234
371
  const st = fs.statSync(p);
@@ -252,14 +389,17 @@ export function collectSessions(since) {
252
389
  }
253
390
 
254
391
  // Normalize found transcript files to their user-message streams.
255
- export function extractMessages(found, { max = 200, min = 15 } = {}) {
392
+ export function extractMessages(
393
+ found,
394
+ { max = 200, min = 15, since = 0 } = {}
395
+ ) {
256
396
  const msgs = [];
257
397
  for (const f of found) {
258
398
  if (msgs.length >= max) break;
259
399
  const ext = EXTRACT[f.host] || EXTRACT.generic;
260
400
  let texts = [];
261
401
  try {
262
- texts = ext(f.path).slice(0, MAX_PER_FILE);
402
+ texts = ext(f.path, since).slice(0, MAX_PER_FILE);
263
403
  } catch {
264
404
  continue;
265
405
  }
@@ -287,7 +427,7 @@ export function cmdHarvest(flags) {
287
427
  if (flags.extract) {
288
428
  const max = numFlag(flags.max || '200', 200);
289
429
  const min = numFlag(flags.min || '15', 15);
290
- const msgs = extractMessages(found, { max, min });
430
+ const msgs = extractMessages(found, { max, min, since });
291
431
  if (flags.json) return out(JSON.stringify(msgs, null, 2));
292
432
  for (const m of msgs)
293
433
  out(
package/lib/install.mjs CHANGED
@@ -737,6 +737,14 @@ export function cmdDoctor() {
737
737
  }
738
738
  if (dead.length)
739
739
  out(`config : ${dead.length} unresolvable project key(s): ${dead.join(', ')}`);
740
+ const missingPeers = (cfg.extraRepos || []).filter(
741
+ (p) =>
742
+ !fs.existsSync(
743
+ path.join(path.dirname(homeDir), `${path.basename(homeDir)}-peers`, p.name)
744
+ )
745
+ );
746
+ if (missingPeers.length)
747
+ out(`peers : ${missingPeers.map((p) => p.name).join(', ')} configured but not cloned`);
740
748
  }
741
749
  const onPath =
742
750
  sh('where', ['amr']).code === 0 || sh('which', ['amr']).code === 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucid-mem",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "lucid-mem (amr) - autonomous git-backed agent memory (Agent Memory Repo spec)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,159 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import {
6
+ run,
7
+ tmpdir,
8
+ initRepo,
9
+ git,
10
+ read,
11
+ } from './helpers.mjs';
12
+
13
+ const { DatabaseSync } = await import('node:sqlite').catch(() => ({}));
14
+ const hasSqlite = !!DatabaseSync;
15
+
16
+ function setup() {
17
+ const amrHome = tmpdir();
18
+ const fakeHome = tmpdir();
19
+ const r = run(['ensure'], { amrHome, fakeHome });
20
+ assert.equal(r.status, 0, r.stderr);
21
+ return { amrHome, fakeHome };
22
+ }
23
+
24
+ function initPeerRepo(dir) {
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ git(dir, ['init', '-b', 'main']);
27
+ fs.writeFileSync(
28
+ path.join(dir, 'MEMORY.md'),
29
+ '# Memory\n\n- teammate global fact: prefers tabs over spaces [added: 2026-01-01]\n'
30
+ );
31
+ git(dir, [
32
+ '-c', 'user.email=t@t.dev', '-c', 'user.name=t',
33
+ 'add', '-A',
34
+ ]);
35
+ git(dir, [
36
+ '-c', 'user.email=t@t.dev', '-c', 'user.name=t',
37
+ 'commit', '-m', 'mem',
38
+ ]);
39
+ return dir;
40
+ }
41
+
42
+ test('peer add clones and lists; remove detaches', () => {
43
+ const { amrHome, fakeHome } = setup();
44
+ const peerSrc = initPeerRepo(path.join(tmpdir(), 'teammate-mem'));
45
+ let r = run(['peer', 'add', peerSrc], { amrHome, fakeHome });
46
+ assert.equal(r.status, 0, r.stderr);
47
+ assert.match(r.stdout, /peer teammate-mem/);
48
+ assert.ok(
49
+ fs.existsSync(
50
+ path.join(path.dirname(amrHome), `${path.basename(amrHome)}-peers`, 'teammate-mem', 'MEMORY.md')
51
+ )
52
+ );
53
+ r = run(['peer'], { amrHome, fakeHome });
54
+ assert.match(r.stdout, /teammate-mem.*cloned/);
55
+ r = run(['peer', 'remove', 'teammate-mem'], { amrHome, fakeHome });
56
+ assert.equal(r.status, 0, r.stderr);
57
+ r = run(['peer'], { amrHome, fakeHome });
58
+ assert.match(r.stdout, /no peers configured/);
59
+ });
60
+
61
+ test('context composes peer global + matching project memory', () => {
62
+ const { amrHome, fakeHome } = setup();
63
+ // Seed a project in OUR repo so the slug exists.
64
+ const repo = initRepo(path.join(tmpdir(), 'projx'));
65
+ run(['add', 'our project note', '--project'], {
66
+ amrHome, fakeHome, cwd: repo,
67
+ });
68
+ const slug = fs
69
+ .readdirSync(path.join(amrHome, 'projects'))
70
+ .find((f) => f.endsWith('.md'))
71
+ .replace(/\.md$/, '');
72
+ // Peer carries its own memory file for the same slug.
73
+ const peerSrc = initPeerRepo(path.join(tmpdir(), 'workmate'));
74
+ fs.mkdirSync(path.join(peerSrc, 'projects'));
75
+ fs.writeFileSync(
76
+ path.join(peerSrc, 'projects', `${slug}.md`),
77
+ `# Project: projx\n\n- teammate project note: api uses v2 endpoints [added: 2026-01-01]\n`
78
+ );
79
+ git(peerSrc, [
80
+ '-c', 'user.email=t@t.dev', '-c', 'user.name=t',
81
+ 'add', '-A',
82
+ ]);
83
+ git(peerSrc, [
84
+ '-c', 'user.email=t@t.dev', '-c', 'user.name=t',
85
+ 'commit', '-m', 'add proj',
86
+ ]);
87
+ const r = run(['peer', 'add', peerSrc], { amrHome, fakeHome });
88
+ assert.equal(r.status, 0, r.stderr);
89
+ const ctx = run(['context', repo], { amrHome, fakeHome });
90
+ assert.equal(ctx.status, 0, ctx.stderr);
91
+ assert.match(ctx.stdout, /## Peer: workmate \(read-only\)/);
92
+ assert.match(ctx.stdout, /prefers tabs over spaces/);
93
+ assert.match(ctx.stdout, /### workmate memory for this project/);
94
+ assert.match(ctx.stdout, /api uses v2 endpoints/);
95
+ });
96
+
97
+ test('context flags configured-but-missing peer clones', () => {
98
+ const { amrHome, fakeHome } = setup();
99
+ const peerSrc = initPeerRepo(path.join(tmpdir(), 'gonemate'));
100
+ run(['peer', 'add', peerSrc], { amrHome, fakeHome });
101
+ fs.rmSync(
102
+ path.join(path.dirname(amrHome), `${path.basename(amrHome)}-peers`, 'gonemate'),
103
+ { recursive: true, force: true }
104
+ );
105
+ const ctx = run(['context'], { amrHome, fakeHome });
106
+ assert.match(ctx.stdout, /configured peer\(s\) not cloned: gonemate/);
107
+ });
108
+
109
+ test('harvest extracts opencode sqlite user messages', { skip: !hasSqlite }, () => {
110
+ const { amrHome, fakeHome } = setup();
111
+ const dir = path.join(fakeHome, '.local', 'share', 'opencode');
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ const db = new DatabaseSync(path.join(dir, 'opencode.db'));
114
+ db.exec(
115
+ 'create table message(id text, session_id text, time_created integer, data text);' +
116
+ 'create table part(id text, message_id text, session_id text, time_created integer, data text);'
117
+ );
118
+ db.prepare('insert into message values (?,?,?,?)').run(
119
+ 'm1', 's1', Date.now(),
120
+ JSON.stringify({ role: 'user' })
121
+ );
122
+ db.prepare('insert into part values (?,?,?,?,?)').run(
123
+ 'p1', 'm1', 's1', Date.now(),
124
+ JSON.stringify({ type: 'text', text: 'please fix the flaky login test in CI' })
125
+ );
126
+ db.close();
127
+ const r = run(['harvest', '--days', '7', '--extract'], {
128
+ amrHome, fakeHome,
129
+ });
130
+ assert.equal(r.status, 0, r.stderr);
131
+ assert.match(r.stdout, /\[opencode\].*flaky login test/);
132
+ });
133
+
134
+ test('harvest extracts antigravity protobuf user prompts', { skip: !hasSqlite }, () => {
135
+ const { amrHome, fakeHome } = setup();
136
+ const dir = path.join(fakeHome, '.gemini', 'antigravity', 'conversations');
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const db = new DatabaseSync(path.join(dir, 'conv1.db'));
139
+ db.exec('create table steps(idx integer, step_type integer, step_payload blob)');
140
+ // Protobuf-ish bytes: tag + uuid junk + the user string as a printable run.
141
+ const payload = Buffer.concat([
142
+ Buffer.from([0x08, 0x0e, 0x32, 0x03]),
143
+ Buffer.from('b$85b47905-028a-4ca9-bd54-0a36d1d712fa\x00'),
144
+ Buffer.from('why does the build cache never invalidate on rename'),
145
+ Buffer.from([0x00, 0x01, 0x02]),
146
+ ]);
147
+ db.prepare('insert into steps values (?,?,?)').run(0, 14, payload);
148
+ // A tool step should be ignored.
149
+ db.prepare('insert into steps values (?,?,?)').run(
150
+ 1, 8, Buffer.from('{"AbsolutePath":"d:/x/y.md","EndLine":100,"toolAction":"Listing dir contents here"}')
151
+ );
152
+ db.close();
153
+ const r = run(['harvest', '--days', '7', '--extract'], {
154
+ amrHome, fakeHome,
155
+ });
156
+ assert.equal(r.status, 0, r.stderr);
157
+ assert.match(r.stdout, /\[antigravity\].*build cache never invalidate/);
158
+ assert.doesNotMatch(r.stdout, /Listing dir contents/);
159
+ });