lucid-mem 0.3.1 → 0.5.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
  ```
@@ -33,7 +34,7 @@ Memory lives in `~/agent-memory` — a real git repo you can read, diff, and poi
33
34
 
34
35
  ## Multi-repo memory
35
36
 
36
- One memory repo serves every project. `projects/<name>-<hash>.md` files key off the repo's origin remote, so clones share memory and same-named repos don't collide. `amr link` attaches related projects (transitively); `private` policy keeps a project out of transitive auto-attach; `noglobal` skips global memory for a repo.
37
+ One memory repo serves every project. `projects/<name>-<hash>.md` files key off the repo's origin remote, so clones share memory and same-named repos don't collide. Inside a known repo, `amr context` is project-scoped — the project's latest 25 plus linked siblings (up to 8 projects). Outside one, it shows global bullets plus the latest 25 pooled across all projects. `private` policy keeps a project out of transitive auto-attach and the pooled digest; `noglobal` keeps it out of the pooled digest only.
37
38
 
38
39
  ## MCP
39
40
 
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,
@@ -25,7 +26,7 @@ const USAGE = `amr: agent memory CLI (lucid-mem)
25
26
  usage: amr <command> [args] [flags]
26
27
 
27
28
  ensure create/verify the memory repo ($AMR_HOME, default ~/agent-memory)
28
- context [dir] print session memory digest (global + current project, capped; --full for all)
29
+ context [dir] session digest: project-scoped inside a known repo; global + pooled projects outside
29
30
  add "text" save a memory entry
30
31
  update "match" "text" replace an existing entry in place
31
32
  search "term" grep all memory files
@@ -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) {
@@ -66,14 +77,20 @@ export function cmdContext(flags) {
66
77
  ];
67
78
  const cfg = loadConfig(home);
68
79
  const entry = proj ? projectEntry(cfg, proj) : null;
69
- // noglobal: this repo's sessions omit the global section.
70
- const globalLines = entry?.noglobal
71
- ? []
72
- : cap(bullets(idxSplit[0] || ''), 25);
80
+ // Two modes: inside a repo that has a memory file, the digest is
81
+ // project-scoped — this project plus its linked siblings only, no
82
+ // global bullets. Outside any known project (or a repo with no
83
+ // memory yet), it is global bullets plus a pooled digest of the
84
+ // latest entries across all projects.
85
+ const projHasFile =
86
+ !!proj &&
87
+ fs.existsSync(path.join(home, 'projects', `${proj.slug}.md`));
88
+ const inProject = projHasFile;
89
+ const globalLines = inProject ? [] : cap(bullets(idxSplit[0] || ''), 25);
73
90
  const indexLinks = cap(bullets(idxSplit[1] || ''), 12);
74
91
 
75
92
  // Attach list: the current project, then its config links
76
- // (transitive, deduped, max 5 total) and any --also names.
93
+ // (transitive, deduped, max 8 total) and any --also names.
77
94
  // private blocks reachability from repos that never linked it:
78
95
  // direct links (siblings) and explicit --also attach fine; it only
79
96
  // refuses when reached transitively through another project's links.
@@ -81,7 +98,7 @@ export function cmdContext(flags) {
81
98
  const attach = [];
82
99
  const seen = new Set();
83
100
  const push = (slug, linked) => {
84
- if (seen.has(slug) || attach.length >= 5) return;
101
+ if (seen.has(slug) || attach.length >= 8) return;
85
102
  seen.add(slug);
86
103
  attach.push({ slug, name: bySlug.get(slug)?.name || slug, linked });
87
104
  };
@@ -95,7 +112,7 @@ export function cmdContext(flags) {
95
112
  );
96
113
  const refused = [];
97
114
  const unknown = [];
98
- while (queue.length && attach.length < 5) {
115
+ while (queue.length && attach.length < 8) {
99
116
  const { k: raw, via } = queue.shift();
100
117
  const key = String(raw).trim().toLowerCase();
101
118
  if (!key) continue;
@@ -120,13 +137,33 @@ export function cmdContext(flags) {
120
137
  for (const a of attach) {
121
138
  const pf = path.join(home, 'projects', `${a.slug}.md`);
122
139
  if (!fs.existsSync(pf)) continue;
123
- const body = cap(
124
- bullets(fs.readFileSync(pf, 'utf8')),
125
- a.linked ? 12 : 25
126
- ).join('\n');
140
+ const body = cap(bullets(fs.readFileSync(pf, 'utf8')), 25).join('\n');
127
141
  lines.push('', `## Project: ${a.name}${a.linked ? ' (linked)' : ''}`);
128
142
  if (body) lines.push(body);
129
143
  }
144
+ // Outside-mode digest: the latest 25 project entries pooled across
145
+ // every project file, newest first by added: date. private projects
146
+ // never contribute (must not leak into foreign sessions); noglobal
147
+ // projects opt out of the pooled view the same way.
148
+ if (!inProject) {
149
+ const pool = [];
150
+ for (const p of listProjects(home)) {
151
+ const e = projectEntry(cfg, p);
152
+ if (e?.private || e?.noglobal) continue;
153
+ const pf = path.join(home, 'projects', `${p.slug}.md`);
154
+ for (const b of bullets(fs.readFileSync(pf, 'utf8'))) {
155
+ const d = (b.match(/added: (\d{4}-\d{2}-\d{2})/) || [])[1] || '';
156
+ pool.push({
157
+ d,
158
+ b: /project:/.test(b) ? b : b.replace(/^- /, `- (${p.name}) `),
159
+ });
160
+ }
161
+ }
162
+ pool.sort((a, b) => b.d.localeCompare(a.d));
163
+ const recent = cap(pool.map((x) => x.b), 25);
164
+ if (recent.length)
165
+ lines.push('', '## Recent project memory', recent.join('\n'));
166
+ }
130
167
  if (indexLinks.length) lines.push('', '## Index', indexLinks.join('\n'));
131
168
  if (refused.length)
132
169
  lines.push(
@@ -138,6 +175,40 @@ export function cmdContext(flags) {
138
175
  '',
139
176
  `<!-- ${unknown.length} linked project name(s) unresolved (no memory file): ${unknown.join(', ')} -->`
140
177
  );
178
+ // Peer repos: other memory repos attached via `amr peer add`. Each
179
+ // contributes its global digest plus its own copy of the current
180
+ // project's file — the multiplayer case (teammate memory for the
181
+ // same repo). Bounded: max 3 peers, 10 global + 8 project bullets.
182
+ const missingPeers = [];
183
+ for (const p of (cfg.extraRepos || []).slice(0, 3)) {
184
+ const dir = peerDir(home, p.name);
185
+ const pm = path.join(dir, 'MEMORY.md');
186
+ if (!fs.existsSync(pm)) {
187
+ missingPeers.push(p.name);
188
+ continue;
189
+ }
190
+ const g = cap(
191
+ bullets(
192
+ fs.readFileSync(pm, 'utf8').split(/^##\s+Index\s*$/m)[0] || ''
193
+ ),
194
+ 10
195
+ );
196
+ if (g.length)
197
+ lines.push('', `## Peer: ${p.name} (read-only)`, g.join('\n'));
198
+ if (proj) {
199
+ const pf = path.join(dir, 'projects', `${proj.slug}.md`);
200
+ if (fs.existsSync(pf)) {
201
+ const pb = cap(bullets(fs.readFileSync(pf, 'utf8')), 8);
202
+ if (pb.length)
203
+ lines.push(`### ${p.name} memory for this project`, pb.join('\n'));
204
+ }
205
+ }
206
+ }
207
+ if (missingPeers.length)
208
+ lines.push(
209
+ '',
210
+ `<!-- configured peer(s) not cloned: ${missingPeers.join(', ')} — run amr peer -->`
211
+ );
141
212
  // Recall pointer: archive/ holds compacted (oldest) entries —
142
213
  // searchable but never session-injected.
143
214
  const adir = path.join(home, 'archive');
@@ -345,8 +416,80 @@ export function cmdRemote(args) {
345
416
  out(r.stdout || 'no remote configured');
346
417
  }
347
418
 
419
+ export function cmdPeer(args, flags) {
420
+ const home = ensureRepo();
421
+ const cfg = loadConfig(home);
422
+ const peers = (cfg.extraRepos ||= []);
423
+ const [sub, ...rest] = args;
424
+
425
+ if (!sub) {
426
+ if (!peers.length)
427
+ return out('no peers configured — amr peer add <url-or-path>');
428
+ for (const p of peers) {
429
+ const dir = peerDir(home, p.name);
430
+ out(
431
+ `${p.name}: ${p.url} -> ${
432
+ fs.existsSync(dir) ? `${dir} (cloned)` : 'NOT CLONED'
433
+ }`
434
+ );
435
+ }
436
+ return;
437
+ }
438
+
439
+ if (sub === 'add') {
440
+ let url = rest[0];
441
+ if (!url)
442
+ throw new Error('usage: amr peer add <url-or-path> [--name x]');
443
+ // Bare local paths resolve against cwd, not the peer root.
444
+ if (!/^[a-z]+:\/\/|^git@|^ssh:/i.test(url)) url = path.resolve(url);
445
+ const name = normName(flags.name || peerNameOf(url));
446
+ if (peers.some((p) => p.name === name))
447
+ throw new Error(`peer "${name}" already configured`);
448
+ const dir = peerDir(home, name);
449
+ if (!fs.existsSync(dir)) {
450
+ fs.mkdirSync(peerRoot(home), { recursive: true });
451
+ const r = git(peerRoot(home), ['clone', '--depth', '50', '--', url, dir], {
452
+ timeout: 120000,
453
+ });
454
+ if (r.code !== 0)
455
+ throw new Error(`clone failed: ${(r.stderr || '').trim()}`);
456
+ }
457
+ if (!fs.existsSync(path.join(dir, 'MEMORY.md')))
458
+ err('warning: no MEMORY.md in clone — may not be an agent memory repo');
459
+ peers.push({ name, url });
460
+ saveConfig(home, cfg);
461
+ commit(home, ['amr.json'], `Add memory peer: ${name}`);
462
+ out(`peer ${name} -> ${dir}`);
463
+ return;
464
+ }
465
+
466
+ if (sub === 'remove') {
467
+ const name = normName(rest[0] || '');
468
+ const i = peers.findIndex((p) => p.name === name);
469
+ if (i < 0) throw new Error(`unknown peer "${rest[0] || ''}"`);
470
+ peers.splice(i, 1);
471
+ if (!peers.length) delete cfg.extraRepos;
472
+ saveConfig(home, cfg);
473
+ commit(home, ['amr.json'], `Remove memory peer: ${name}`);
474
+ out(`detached peer ${name}; clone left at ${peerDir(home, name)}`);
475
+ return;
476
+ }
477
+
478
+ throw new Error('usage: amr peer [add <url> [--name x] | remove <name>]');
479
+ }
480
+
348
481
  export function cmdSync() {
349
482
  const home = ensureRepo();
483
+ // Refresh read-only peer clones first; a stale peer is a warn, not a
484
+ // blocker for syncing our own memory.
485
+ const cfg = loadConfig(home);
486
+ for (const p of cfg.extraRepos || []) {
487
+ const dir = peerDir(home, p.name);
488
+ if (!fs.existsSync(dir)) continue;
489
+ const r = git(dir, ['pull', '--ff-only'], { timeout: 10000 });
490
+ if (r.code !== 0)
491
+ err(`peer ${p.name} pull failed: ${(r.stderr || '').trim()}`);
492
+ }
350
493
  if (!hasRemote(home)) return out('no remote configured; local only');
351
494
  const hasUpstream =
352
495
  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.5.0",
4
4
  "description": "lucid-mem (amr) - autonomous git-backed agent memory (Agent Memory Repo spec)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,16 +82,38 @@ test('unlink removes the auto-attach', () => {
82
82
  assert.ok(!run(['context'], { amrHome: home, cwd: repoB }).stdout.includes('x1 fact'));
83
83
  });
84
84
 
85
- test('noglobal policy suppresses global memory for that repo', () => {
85
+ test('context in a known project is project-scoped (no global section)', () => {
86
86
  const home = tmpdir();
87
87
  const repo = initRepo(path.join(tmpdir(), 'iso'), 'https://github.com/acme/iso.git');
88
88
  run(['ensure'], { amrHome: home });
89
89
  run(['add', 'global pref'], { amrHome: home });
90
90
  seed(home, repo, 'iso fact');
91
- const before = run(['context'], { amrHome: home, cwd: repo });
92
- assert.match(before.stdout, /global pref/);
93
- run(['policy', 'iso', 'noglobal', 'true'], { amrHome: home, cwd: repo });
94
- const after = run(['context'], { amrHome: home, cwd: repo });
95
- assert.match(after.stdout, /iso fact/);
96
- assert.ok(!after.stdout.includes('global pref'));
91
+ const r = run(['context'], { amrHome: home, cwd: repo });
92
+ assert.match(r.stdout, /iso fact/);
93
+ assert.ok(!r.stdout.includes('global pref'));
94
+ assert.ok(!r.stdout.includes('## Global'));
95
+ assert.ok(!r.stdout.includes('## Recent project memory'));
96
+ });
97
+
98
+ test('outside context pools latest project entries; private and noglobal excluded', () => {
99
+ const home = tmpdir();
100
+ const base = tmpdir();
101
+ const repoA = initRepo(path.join(base, 'pa'), 'https://github.com/acme/pa.git');
102
+ const repoB = initRepo(path.join(base, 'pb'), 'https://github.com/acme/pb.git');
103
+ const repoC = initRepo(path.join(base, 'pc'), 'https://github.com/acme/pc.git');
104
+ const repoD = initRepo(path.join(base, 'pd'), 'https://github.com/acme/pd.git');
105
+ run(['ensure'], { amrHome: home });
106
+ seed(home, repoA, 'pa pooled fact');
107
+ seed(home, repoB, 'pb pooled fact');
108
+ seed(home, repoC, 'pc secret fact');
109
+ seed(home, repoD, 'pd quiet fact');
110
+ run(['policy', 'pc', 'private', 'true'], { amrHome: home, cwd: repoC });
111
+ run(['policy', 'pd', 'noglobal', 'true'], { amrHome: home, cwd: repoD });
112
+ const r = run(['context'], { amrHome: home, cwd: tmpdir() });
113
+ assert.equal(r.status, 0, r.stderr);
114
+ assert.match(r.stdout, /## Recent project memory/);
115
+ assert.match(r.stdout, /pa pooled fact/);
116
+ assert.match(r.stdout, /pb pooled fact/);
117
+ assert.ok(!r.stdout.includes('pc secret fact'));
118
+ assert.ok(!r.stdout.includes('pd quiet fact'));
97
119
  });
@@ -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
+ });