lucid-mem 0.4.0 → 0.5.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 +1 -1
- package/lib/cli.mjs +1 -1
- package/lib/commands.mjs +44 -29
- package/package.json +1 -1
- package/test/context.test.mjs +33 -11
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Memory lives in `~/agent-memory` — a real git repo you can read, diff, and poi
|
|
|
34
34
|
|
|
35
35
|
## Multi-repo memory
|
|
36
36
|
|
|
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. `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` and `noglobal` policies keep a project out of the pooled digest (links still reach private projects — your link graph is your own curation).
|
|
38
38
|
|
|
39
39
|
## MCP
|
|
40
40
|
|
package/lib/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const USAGE = `amr: agent memory CLI (lucid-mem)
|
|
|
26
26
|
usage: amr <command> [args] [flags]
|
|
27
27
|
|
|
28
28
|
ensure create/verify the memory repo ($AMR_HOME, default ~/agent-memory)
|
|
29
|
-
context [dir]
|
|
29
|
+
context [dir] session digest: project-scoped inside a known repo; global + pooled projects outside
|
|
30
30
|
add "text" save a memory entry
|
|
31
31
|
update "match" "text" replace an existing entry in place
|
|
32
32
|
search "term" grep all memory files
|
package/lib/commands.mjs
CHANGED
|
@@ -77,37 +77,42 @@ export function cmdContext(flags) {
|
|
|
77
77
|
];
|
|
78
78
|
const cfg = loadConfig(home);
|
|
79
79
|
const entry = proj ? projectEntry(cfg, proj) : null;
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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);
|
|
84
90
|
const indexLinks = cap(bullets(idxSplit[1] || ''), 12);
|
|
85
91
|
|
|
86
92
|
// Attach list: the current project, then its config links
|
|
87
|
-
// (transitive, deduped, max
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
93
|
+
// (transitive, deduped, max 8 total) and any --also names.
|
|
94
|
+
// Links are the owner's curation — private projects attach fine
|
|
95
|
+
// through them. `private` only keeps a project out of the pooled
|
|
96
|
+
// outside digest, the one unlinked exposure path.
|
|
91
97
|
const bySlug = new Map(listProjects(home).map((p) => [p.slug, p]));
|
|
92
98
|
const attach = [];
|
|
93
99
|
const seen = new Set();
|
|
94
100
|
const push = (slug, linked) => {
|
|
95
|
-
if (seen.has(slug) || attach.length >=
|
|
101
|
+
if (seen.has(slug) || attach.length >= 8) return;
|
|
96
102
|
seen.add(slug);
|
|
97
103
|
attach.push({ slug, name: bySlug.get(slug)?.name || slug, linked });
|
|
98
104
|
};
|
|
99
105
|
if (proj) push(proj.slug, false);
|
|
100
|
-
const queue = (entry?.links || []).map((k) => ({ k
|
|
106
|
+
const queue = (entry?.links || []).map((k) => ({ k }));
|
|
101
107
|
if (flags.also)
|
|
102
108
|
queue.push(
|
|
103
109
|
...String(flags.also)
|
|
104
110
|
.split(',')
|
|
105
|
-
.map((k) => ({ k
|
|
111
|
+
.map((k) => ({ k }))
|
|
106
112
|
);
|
|
107
|
-
const refused = [];
|
|
108
113
|
const unknown = [];
|
|
109
|
-
while (queue.length && attach.length <
|
|
110
|
-
const { k: raw
|
|
114
|
+
while (queue.length && attach.length < 8) {
|
|
115
|
+
const { k: raw } = queue.shift();
|
|
111
116
|
const key = String(raw).trim().toLowerCase();
|
|
112
117
|
if (!key) continue;
|
|
113
118
|
const slug = resolveProjectSlug(home, key);
|
|
@@ -117,13 +122,8 @@ export function cmdContext(flags) {
|
|
|
117
122
|
}
|
|
118
123
|
if (seen.has(slug)) continue;
|
|
119
124
|
const e = projectEntry(cfg, { slug, name: key });
|
|
120
|
-
if (e?.private && via === 'transitive') {
|
|
121
|
-
refused.push(key);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
125
|
push(slug, true);
|
|
125
|
-
if (e?.links)
|
|
126
|
-
queue.push(...e.links.map((l) => ({ k: l, via: 'transitive' })));
|
|
126
|
+
if (e?.links) queue.push(...e.links.map((l) => ({ k: l })));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
const lines = [`<agent-memory repo="${home}">`];
|
|
@@ -131,19 +131,34 @@ export function cmdContext(flags) {
|
|
|
131
131
|
for (const a of attach) {
|
|
132
132
|
const pf = path.join(home, 'projects', `${a.slug}.md`);
|
|
133
133
|
if (!fs.existsSync(pf)) continue;
|
|
134
|
-
const body = cap(
|
|
135
|
-
bullets(fs.readFileSync(pf, 'utf8')),
|
|
136
|
-
a.linked ? 12 : 25
|
|
137
|
-
).join('\n');
|
|
134
|
+
const body = cap(bullets(fs.readFileSync(pf, 'utf8')), 25).join('\n');
|
|
138
135
|
lines.push('', `## Project: ${a.name}${a.linked ? ' (linked)' : ''}`);
|
|
139
136
|
if (body) lines.push(body);
|
|
140
137
|
}
|
|
138
|
+
// Outside-mode digest: the latest 25 project entries pooled across
|
|
139
|
+
// every project file, newest first by added: date. private projects
|
|
140
|
+
// never contribute (must not leak into foreign sessions); noglobal
|
|
141
|
+
// projects opt out of the pooled view the same way.
|
|
142
|
+
if (!inProject) {
|
|
143
|
+
const pool = [];
|
|
144
|
+
for (const p of listProjects(home)) {
|
|
145
|
+
const e = projectEntry(cfg, p);
|
|
146
|
+
if (e?.private || e?.noglobal) continue;
|
|
147
|
+
const pf = path.join(home, 'projects', `${p.slug}.md`);
|
|
148
|
+
for (const b of bullets(fs.readFileSync(pf, 'utf8'))) {
|
|
149
|
+
const d = (b.match(/added: (\d{4}-\d{2}-\d{2})/) || [])[1] || '';
|
|
150
|
+
pool.push({
|
|
151
|
+
d,
|
|
152
|
+
b: /project:/.test(b) ? b : b.replace(/^- /, `- (${p.name}) `),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
pool.sort((a, b) => b.d.localeCompare(a.d));
|
|
157
|
+
const recent = cap(pool.map((x) => x.b), 25);
|
|
158
|
+
if (recent.length)
|
|
159
|
+
lines.push('', '## Recent project memory', recent.join('\n'));
|
|
160
|
+
}
|
|
141
161
|
if (indexLinks.length) lines.push('', '## Index', indexLinks.join('\n'));
|
|
142
|
-
if (refused.length)
|
|
143
|
-
lines.push(
|
|
144
|
-
'',
|
|
145
|
-
`<!-- ${refused.length} linked project(s) omitted by private policy: ${refused.join(', ')} -->`
|
|
146
|
-
);
|
|
147
162
|
if (unknown.length)
|
|
148
163
|
lines.push(
|
|
149
164
|
'',
|
package/package.json
CHANGED
package/test/context.test.mjs
CHANGED
|
@@ -28,7 +28,7 @@ test('context in a repo shows its project memory', () => {
|
|
|
28
28
|
assert.match(r.stdout, /app uses redis cache/);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test('link attaches
|
|
31
|
+
test('link attaches siblings and private transitively (links are owner curation)', () => {
|
|
32
32
|
const home = tmpdir();
|
|
33
33
|
const base = tmpdir();
|
|
34
34
|
const repoA = initRepo(path.join(base, 'a'), 'https://github.com/acme/a.git');
|
|
@@ -45,13 +45,13 @@ test('link attaches a sibling project transitively; private blocks transitive on
|
|
|
45
45
|
const inB = run(['context'], { amrHome: home, cwd: repoB });
|
|
46
46
|
assert.match(inB.stdout, /a uses sqlite/);
|
|
47
47
|
|
|
48
|
-
// c links to b; a is private ->
|
|
48
|
+
// c links to b; a is private -> still attaches transitively, since
|
|
49
|
+
// the link chain is the owner's own curation
|
|
49
50
|
run(['link', 'b'], { amrHome: home, cwd: repoC });
|
|
50
51
|
run(['policy', 'a', 'private', 'true'], { amrHome: home, cwd: repoA });
|
|
51
52
|
const inC = run(['context'], { amrHome: home, cwd: repoC });
|
|
52
53
|
assert.match(inC.stdout, /b uses kafka/);
|
|
53
|
-
assert.match(inC.stdout, /
|
|
54
|
-
assert.ok(!inC.stdout.includes('a uses sqlite'));
|
|
54
|
+
assert.match(inC.stdout, /a uses sqlite/);
|
|
55
55
|
|
|
56
56
|
// explicit --also still attaches the private project
|
|
57
57
|
const explicit = run(['context', '--also', 'a'], { amrHome: home, cwd: repoC });
|
|
@@ -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
|
});
|