lucid-mem 0.5.0 → 0.6.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
@@ -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. 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
+ 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
@@ -18,6 +18,7 @@ import {
18
18
  import { cmdDiscover } from './discover.mjs';
19
19
  import { cmdDream } from './dream.mjs';
20
20
  import { cmdHarvest } from './harvest.mjs';
21
+ import { cmdCoverage } from './coverage.mjs';
21
22
  import { cmdDoctor, cmdInstall, cmdUninstall } from './install.mjs';
22
23
  import { cmdServe } from './serve.mjs';
23
24
 
@@ -41,6 +42,7 @@ usage: amr <command> [args] [flags]
41
42
  dream [--days N] [--fix] [--compact] [--keep N] [--json] memory health; --fix cleans up, --compact archives overflow; --spin plays the animation
42
43
  harvest [--days N] [--json] [--extract] [--max N] [--min C] list transcripts; --extract streams normalized user messages
43
44
  discover [--days N] [--min N] [--json] rank repos by transcript mentions; shows memory coverage + suggested links
45
+ coverage [--days N] [--json] [--no-log] save-rate metric: durable transcript signals vs memory repo
44
46
  install [--host a,b|all] wire hooks + rules + MCP into detected agent hosts
45
47
  uninstall [--host a,b|all] remove amr hooks + rules + MCP entries
46
48
  serve stdio MCP server (memory_context/add/search/update)
@@ -131,6 +133,8 @@ export function main(argv) {
131
133
  return cmdHarvest(flags);
132
134
  case 'discover':
133
135
  return cmdDiscover(flags);
136
+ case 'coverage':
137
+ return cmdCoverage(flags);
134
138
  case 'install':
135
139
  return cmdInstall(flags);
136
140
  case 'uninstall':
package/lib/commands.mjs CHANGED
@@ -91,9 +91,9 @@ export function cmdContext(flags) {
91
91
 
92
92
  // Attach list: the current project, then its config links
93
93
  // (transitive, deduped, max 8 total) and any --also names.
94
- // private blocks reachability from repos that never linked it:
95
- // direct links (siblings) and explicit --also attach fine; it only
96
- // refuses when reached transitively through another project's links.
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.
97
97
  const bySlug = new Map(listProjects(home).map((p) => [p.slug, p]));
98
98
  const attach = [];
99
99
  const seen = new Set();
@@ -103,17 +103,16 @@ export function cmdContext(flags) {
103
103
  attach.push({ slug, name: bySlug.get(slug)?.name || slug, linked });
104
104
  };
105
105
  if (proj) push(proj.slug, false);
106
- const queue = (entry?.links || []).map((k) => ({ k, via: 'entry' }));
106
+ const queue = (entry?.links || []).map((k) => ({ k }));
107
107
  if (flags.also)
108
108
  queue.push(
109
109
  ...String(flags.also)
110
110
  .split(',')
111
- .map((k) => ({ k, via: 'also' }))
111
+ .map((k) => ({ k }))
112
112
  );
113
- const refused = [];
114
113
  const unknown = [];
115
114
  while (queue.length && attach.length < 8) {
116
- const { k: raw, via } = queue.shift();
115
+ const { k: raw } = queue.shift();
117
116
  const key = String(raw).trim().toLowerCase();
118
117
  if (!key) continue;
119
118
  const slug = resolveProjectSlug(home, key);
@@ -123,13 +122,8 @@ export function cmdContext(flags) {
123
122
  }
124
123
  if (seen.has(slug)) continue;
125
124
  const e = projectEntry(cfg, { slug, name: key });
126
- if (e?.private && via === 'transitive') {
127
- refused.push(key);
128
- continue;
129
- }
130
125
  push(slug, true);
131
- if (e?.links)
132
- queue.push(...e.links.map((l) => ({ k: l, via: 'transitive' })));
126
+ if (e?.links) queue.push(...e.links.map((l) => ({ k: l })));
133
127
  }
134
128
 
135
129
  const lines = [`<agent-memory repo="${home}">`];
@@ -165,11 +159,6 @@ export function cmdContext(flags) {
165
159
  lines.push('', '## Recent project memory', recent.join('\n'));
166
160
  }
167
161
  if (indexLinks.length) lines.push('', '## Index', indexLinks.join('\n'));
168
- if (refused.length)
169
- lines.push(
170
- '',
171
- `<!-- ${refused.length} linked project(s) omitted by private policy: ${refused.join(', ')} -->`
172
- );
173
162
  if (unknown.length)
174
163
  lines.push(
175
164
  '',
@@ -0,0 +1,181 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { commit, ensureRepo, numFlag, withLock } from './core.mjs';
4
+ import { collectSessions, extractMessages } from './harvest.mjs';
5
+
6
+ const out = (s = '') => process.stdout.write(s + '\n');
7
+
8
+ // What "should have been saved" looks like in a user message: durable
9
+ // preferences, decisions, confirmations, and root-cause findings.
10
+ // STRONG markers alone qualify; WEAK markers only in short messages
11
+ // (long dumps happen to contain them by accident).
12
+ const STRONG =
13
+ /\b(prefer|always|never|remember|note that|decided|instead of|rather than|default to|don['’]?t use|do not use|we use|sticking with|confirmed|turns out|workaround|the fix was|root cause|deprecated)\b/i;
14
+ const WEAK = /\b(shipped|released|pinned|going with)\b/i;
15
+
16
+ // Non-user prose that rides the user role in transcripts: tool errors,
17
+ // context summaries, file/code dumps, listings, test output, diffs,
18
+ // annotations, agent-monologue steps (antigravity mixes them into the
19
+ // user slot).
20
+ const DUMP =
21
+ /^(error:|context summary|#|```|\{|\[|\/|\d+\s|\d+[:|]|[-\w./]+ total \d+|total \d+ |diff |--- |@@ |index |[MDAR] \S+\.\w+|response annotations|base directory)/i;
22
+ const heavyMarkup = (t) =>
23
+ (t.match(/[{}"|#*<>_=[\]\\`]/g) || []).length / t.length > 0.12;
24
+ const FILE_REFS =
25
+ /\b[\w.-]+\.(?:go|tsx?|mjs|jsx?|cs|json|ya?ml|py|rs|cpp|c|h|png|jpe?g|dll|exe|log|toml|sql|ps1|sh)\b/g;
26
+ // Source-code density: several of () ; {} // => == ++ --- is a paste
27
+ // of code, not a user statement.
28
+ const isCodey = (t) =>
29
+ (t.match(/[{}();]|\/\/|=>|={2,}|[+-]{3,}/g) || []).length >= 4;
30
+ const isDump = (t) =>
31
+ DUMP.test(t) ||
32
+ heavyMarkup(t) ||
33
+ isCodey(t) ||
34
+ /[drwx-]{10}/.test(t) ||
35
+ /(\d+ (files?|tests?) (passed|failed))|passed \(\d+\)|RUN v\d|✓/.test(t) ||
36
+ /^(the user (is|asked|wants)|we (need|should|can|must|will)\b)/i.test(t) ||
37
+ (t.match(FILE_REFS) || []).length >= 3;
38
+ const isDurable = (t) =>
39
+ !isDump(t) &&
40
+ (STRONG.test(t) || (t.length < 200 && WEAK.test(t)));
41
+
42
+ const STOP = new Set(
43
+ 'the a an and or but with this that from into your you for are was were have has been not all can will would could should about when what which there their they them then than also just like over under more some any each other such only very make made does done here need want keep lets'.split(
44
+ ' '
45
+ )
46
+ );
47
+
48
+ // Most distinctive terms first: code tokens and long words carry the
49
+ // signal that a memory entry would share.
50
+ const terms = (text) =>
51
+ [
52
+ ...new Set(
53
+ text
54
+ .toLowerCase()
55
+ .replace(/[^\p{L}\p{N}_.\/-]+/gu, ' ')
56
+ .split(/\s+/)
57
+ .filter((w) => w.length >= 4 && !STOP.has(w))
58
+ ),
59
+ ]
60
+ .sort((a, b) => b.length - a.length)
61
+ .slice(0, 12);
62
+
63
+ const normWords = (t) =>
64
+ t
65
+ .toLowerCase()
66
+ .replace(/[^\p{L}\p{N}_.\/-]+/gu, ' ')
67
+ .split(/\s+/)
68
+ .filter(Boolean);
69
+
70
+ const corpusText = (home) => {
71
+ const parts = [];
72
+ (function walk(dir) {
73
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
74
+ if (e.name === '.git') continue;
75
+ const p = path.join(dir, e.name);
76
+ if (e.isDirectory()) walk(p);
77
+ else if (e.name.endsWith('.md'))
78
+ parts.push(normWords(fs.readFileSync(p, 'utf8')).join(' '));
79
+ }
80
+ })(home);
81
+ return ` ${parts.join(' ')} `;
82
+ };
83
+
84
+ // A durable signal counts as covered when a 4-word run of its content
85
+ // appears verbatim in the corpus (near-verbatim save), or when ≥35% of
86
+ // its distinctive terms appear (paraphrased save). Term hits are
87
+ // prefix-aware at 6 chars so prefer matches prefers/preference.
88
+ const covered = (text, corpus) => {
89
+ const content = normWords(text).filter(
90
+ (w) => w.length >= 4 && !STOP.has(w)
91
+ );
92
+ for (let i = 0; i + 4 <= content.length; i++)
93
+ if (corpus.includes(` ${content.slice(i, i + 4).join(' ')} `))
94
+ return true;
95
+ const t = terms(text);
96
+ if (!t.length) return false;
97
+ const hit = (w) =>
98
+ corpus.includes(` ${w} `) ||
99
+ corpus.includes(` ${w.slice(0, Math.min(6, w.length))}`);
100
+ const hits = t.filter(hit).length;
101
+ return hits / t.length >= 0.35;
102
+ };
103
+
104
+ export function cmdCoverage(flags) {
105
+ const home = ensureRepo();
106
+ const days = numFlag(flags.days ?? '7', 7);
107
+ const since = flags.since
108
+ ? new Date(flags.since).getTime()
109
+ : Date.now() - days * 864e5;
110
+ const max = numFlag(flags.max || '400', 400);
111
+
112
+ const found = collectSessions(since);
113
+ const msgs = extractMessages(found, { max, since });
114
+ const corpus = corpusText(home);
115
+
116
+ const seen = new Set();
117
+ const durable = [];
118
+ for (const m of msgs) {
119
+ const key = m.text.toLowerCase().replace(/\s+/g, ' ').trim();
120
+ if (seen.has(key) || !isDurable(m.text)) continue;
121
+ seen.add(key);
122
+ durable.push({ ...m, covered: covered(m.text, corpus) });
123
+ }
124
+
125
+ const hits = durable.filter((d) => d.covered).length;
126
+ const missed = durable.filter((d) => !d.covered);
127
+ const rate = durable.length ? Math.round((hits / durable.length) * 100) : null;
128
+
129
+ if (flags.json)
130
+ return out(
131
+ JSON.stringify(
132
+ {
133
+ days,
134
+ transcripts: found.length,
135
+ messages: msgs.length,
136
+ durable: durable.length,
137
+ covered: hits,
138
+ rate,
139
+ missed: missed.map((m) => ({
140
+ host: m.host,
141
+ date: m.mtime.slice(0, 10),
142
+ text: m.text.slice(0, 300),
143
+ })),
144
+ },
145
+ null,
146
+ 2
147
+ )
148
+ );
149
+
150
+ if (!durable.length)
151
+ return out(`no durable signals found in ${found.length} transcript(s), ${days}d`);
152
+ out(
153
+ `coverage (${days}d): ${hits}/${durable.length} durable signals covered — save rate ${rate}%`
154
+ );
155
+ if (missed.length) {
156
+ out('missed:');
157
+ for (const m of missed.slice(0, 20))
158
+ out(` [${m.host}] ${m.mtime.slice(0, 10)} ${m.text.slice(0, 140)}`);
159
+ if (missed.length > 20) out(` … +${missed.length - 20} more`);
160
+ }
161
+ out(
162
+ 'note: user-stated signals only; facts the agent learned silently are not observable from transcripts'
163
+ );
164
+
165
+ // Trend line: append this run so the metric has history.
166
+ if (!flags['no-log']) {
167
+ const log = path.join(home, 'dreams', 'coverage.jsonl');
168
+ const entry = {
169
+ ts: new Date().toISOString(),
170
+ days,
171
+ durable: durable.length,
172
+ covered: hits,
173
+ rate,
174
+ };
175
+ withLock(home, () => {
176
+ fs.mkdirSync(path.dirname(log), { recursive: true });
177
+ fs.appendFileSync(log, JSON.stringify(entry) + '\n');
178
+ commit(home, ['dreams/coverage.jsonl'], `Coverage ${rate}% (${hits}/${durable.length})`);
179
+ });
180
+ }
181
+ }
package/lib/install.mjs CHANGED
@@ -737,6 +737,19 @@ export function cmdDoctor() {
737
737
  }
738
738
  if (dead.length)
739
739
  out(`config : ${dead.length} unresolvable project key(s): ${dead.join(', ')}`);
740
+ try {
741
+ const cov = fs
742
+ .readFileSync(path.join(homeDir, 'dreams', 'coverage.jsonl'), 'utf8')
743
+ .trim()
744
+ .split('\n')
745
+ .filter(Boolean)
746
+ .map((l) => JSON.parse(l));
747
+ const last = cov[cov.length - 1];
748
+ if (last?.rate != null)
749
+ out(
750
+ `save rate : ${last.rate}% (${last.covered}/${last.durable} durable signals, ${last.days}d, ${last.ts.slice(0, 10)})`
751
+ );
752
+ } catch {}
740
753
  const missingPeers = (cfg.extraRepos || []).filter(
741
754
  (p) =>
742
755
  !fs.existsSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucid-mem",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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 });
@@ -0,0 +1,71 @@
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 { run, tmpdir } from './helpers.mjs';
6
+
7
+ function setup() {
8
+ const amrHome = tmpdir();
9
+ const fakeHome = tmpdir();
10
+ assert.equal(run(['ensure'], { amrHome, fakeHome }).status, 0);
11
+ return { amrHome, fakeHome };
12
+ }
13
+
14
+ function claudeJsonl(fakeHome, lines) {
15
+ const dir = path.join(fakeHome, '.claude', 'projects', 'projx');
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ const file = path.join(dir, 'sess.jsonl');
18
+ fs.writeFileSync(
19
+ file,
20
+ lines
21
+ .map((text) =>
22
+ JSON.stringify({ type: 'user', message: { content: text } })
23
+ )
24
+ .join('\n')
25
+ );
26
+ }
27
+
28
+ test('coverage reports save rate and lists missed durable signals', () => {
29
+ const { amrHome, fakeHome } = setup();
30
+ run(['add', 'prefers tabs over spaces'], { amrHome, fakeHome });
31
+ claudeJsonl(fakeHome, [
32
+ // saved: near-verbatim in corpus
33
+ 'I prefer tabs over spaces everywhere, remember that',
34
+ // durable but never saved
35
+ 'we decided the cache TTL should always be 300 seconds',
36
+ // a code dump that merely contains a marker word — excluded
37
+ 'const x = () => { if (a) { return b; } }; // never skip this check',
38
+ // ordinary chatter — not durable
39
+ 'looks good, ship it',
40
+ ]);
41
+ const r = run(['coverage', '--days', '7', '--no-log'], {
42
+ amrHome,
43
+ fakeHome,
44
+ });
45
+ assert.equal(r.status, 0, r.stderr);
46
+ assert.match(r.stdout, /1\/2 durable signals covered — save rate 50%/);
47
+ assert.match(r.stdout, /cache TTL should always be 300/);
48
+ assert.ok(!r.stdout.includes('never skip this check'));
49
+ });
50
+
51
+ test('coverage logs a trend line by default; --no-log skips it', () => {
52
+ const { amrHome, fakeHome } = setup();
53
+ claudeJsonl(fakeHome, ['we decided the db migration runs weekly']);
54
+ const log = path.join(amrHome, 'dreams', 'coverage.jsonl');
55
+ run(['coverage', '--no-log'], { amrHome, fakeHome });
56
+ assert.ok(!fs.existsSync(log));
57
+ const r = run(['coverage'], { amrHome, fakeHome });
58
+ assert.equal(r.status, 0, r.stderr);
59
+ const entry = JSON.parse(readLines(log).at(-1));
60
+ assert.equal(entry.durable, 1);
61
+ assert.equal(entry.covered, 0);
62
+ assert.equal(entry.rate, 0);
63
+ });
64
+
65
+ function readLines(file) {
66
+ return fs
67
+ .readFileSync(file, 'utf8')
68
+ .trim()
69
+ .split('\n')
70
+ .filter(Boolean);
71
+ }