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 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 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` 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] 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
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
- // noglobal: this repo's sessions omit the global section.
81
- const globalLines = entry?.noglobal
82
- ? []
83
- : 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);
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 5 total) and any --also names.
88
- // private blocks reachability from repos that never linked it:
89
- // direct links (siblings) and explicit --also attach fine; it only
90
- // refuses when reached transitively through another project's links.
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 >= 5) return;
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, via: 'entry' }));
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, via: 'also' }))
111
+ .map((k) => ({ k }))
106
112
  );
107
- const refused = [];
108
113
  const unknown = [];
109
- while (queue.length && attach.length < 5) {
110
- const { k: raw, via } = queue.shift();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucid-mem",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "lucid-mem (amr) - autonomous git-backed agent memory (Agent Memory Repo spec)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 a sibling project transitively; private blocks transitive only', () => {
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 -> transitive refusal in c
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, /omitted by private policy/);
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('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
  });