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 +4 -0
- package/lib/coverage.mjs +181 -0
- package/lib/install.mjs +13 -0
- package/package.json +1 -1
- package/test/coverage.test.mjs +71 -0
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/coverage.mjs
ADDED
|
@@ -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
|
@@ -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
|
+
}
|