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 +2 -1
- package/lib/cli.mjs +5 -1
- package/lib/commands.mjs +154 -11
- package/lib/harvest.mjs +144 -4
- package/lib/install.mjs +8 -0
- package/package.json +1 -1
- package/test/context.test.mjs +29 -7
- package/test/peer-dbharvest.test.mjs +159 -0
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
|
|
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]
|
|
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
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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 >=
|
|
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 <
|
|
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(
|
|
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
package/test/context.test.mjs
CHANGED
|
@@ -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('
|
|
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
|
|
92
|
-
assert.match(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
assert.
|
|
96
|
-
|
|
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
|
+
});
|