lucid-mem 0.5.1 → 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/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':
@@ -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.1",
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": {
@@ -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
+ }