lucid-mem 0.2.1 → 0.3.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/lib/cli.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import {
2
5
  cmdAdd,
3
6
  cmdContext,
@@ -14,7 +17,7 @@ import {
14
17
  import { cmdDiscover } from './discover.mjs';
15
18
  import { cmdDream } from './dream.mjs';
16
19
  import { cmdHarvest } from './harvest.mjs';
17
- import { cmdDoctor, cmdInstall } from './install.mjs';
20
+ import { cmdDoctor, cmdInstall, cmdUninstall } from './install.mjs';
18
21
  import { cmdServe } from './serve.mjs';
19
22
 
20
23
  const USAGE = `amr: agent memory CLI (lucid-mem)
@@ -37,6 +40,7 @@ usage: amr <command> [args] [flags]
37
40
  harvest [--days N] [--json] [--extract] [--max N] [--min C] list transcripts; --extract streams normalized user messages
38
41
  discover [--days N] [--min N] [--json] rank repos by transcript mentions; shows memory coverage + suggested links
39
42
  install [--host a,b|all] wire hooks + rules + MCP into detected agent hosts
43
+ uninstall [--host a,b|all] remove amr hooks + rules + MCP entries
40
44
  serve stdio MCP server (memory_context/add/search/update)
41
45
 
42
46
  flags:
@@ -125,10 +129,19 @@ export function main(argv) {
125
129
  return cmdDiscover(flags);
126
130
  case 'install':
127
131
  return cmdInstall(flags);
132
+ case 'uninstall':
133
+ return cmdUninstall(flags);
128
134
  case 'version':
129
135
  case '--version':
130
- case '-v':
131
- return process.stdout.write('amr 0.1.0\n');
136
+ case '-v': {
137
+ const pkg = JSON.parse(
138
+ fs.readFileSync(
139
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
140
+ 'utf8'
141
+ )
142
+ );
143
+ return process.stdout.write(`amr ${pkg.version}\n`);
144
+ }
132
145
  case 'help':
133
146
  case '--help':
134
147
  case '-h':
package/lib/core.mjs CHANGED
@@ -24,8 +24,19 @@ export function sh(cmd, args, opts = {}) {
24
24
 
25
25
  export const git = (repo, args, opts) => sh('git', ['-C', repo, ...args], opts);
26
26
 
27
- const samePath = (a, b) =>
28
- path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
27
+ // Canonicalize before comparing: git reports the long form of paths
28
+ // while env vars may carry 8.3 aliases (RUNNER~1) or symlinks.
29
+ const canon = (p) => {
30
+ const r = path.resolve(p);
31
+ // .native expands 8.3 aliases and symlinks; plain realpathSync keeps
32
+ // 8.3 names as-is on Windows (verified).
33
+ try {
34
+ return fs.realpathSync.native(r);
35
+ } catch {
36
+ return r;
37
+ }
38
+ };
39
+ const samePath = (a, b) => canon(a).toLowerCase() === canon(b).toLowerCase();
29
40
 
30
41
  export function isMemoryRepo(dir) {
31
42
  if (!fs.existsSync(path.join(dir, 'MEMORY.md'))) return false;
package/lib/install.mjs CHANGED
@@ -540,6 +540,165 @@ export function cmdInstall(flags) {
540
540
  out('verify with: amr doctor');
541
541
  }
542
542
 
543
+ // ---------- uninstall ----------
544
+
545
+ const isAmrCmd = (c) => typeof c === 'string' && c.includes('amr.mjs');
546
+
547
+ function unmergeHooksJson(file) {
548
+ if (!fs.existsSync(file)) return false;
549
+ let cfg;
550
+ try {
551
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
552
+ } catch {
553
+ return false;
554
+ }
555
+ if (!cfg.hooks) return false;
556
+ let changed = false;
557
+ for (const [ev, list] of Object.entries(cfg.hooks)) {
558
+ const kept = list.filter((e) => !isAmrCmd(e?.command));
559
+ if (kept.length !== list.length) changed = true;
560
+ if (kept.length) cfg.hooks[ev] = kept;
561
+ else delete cfg.hooks[ev];
562
+ }
563
+ if (!Object.keys(cfg.hooks).length) delete cfg.hooks;
564
+ if (changed) {
565
+ backup(file);
566
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
567
+ }
568
+ return changed;
569
+ }
570
+
571
+ function unmergeClaudeSettings(file) {
572
+ if (!fs.existsSync(file)) return false;
573
+ let cfg;
574
+ try {
575
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
576
+ } catch {
577
+ return false;
578
+ }
579
+ if (!cfg.hooks) return false;
580
+ let changed = false;
581
+ for (const [ev, list] of Object.entries(cfg.hooks)) {
582
+ const kept = list
583
+ .map((e) => ({
584
+ ...e,
585
+ hooks: (e?.hooks || []).filter((h) => !isAmrCmd(h?.command)),
586
+ }))
587
+ .filter((e) => (e.hooks || []).length || !Array.isArray(e?.hooks));
588
+ if (JSON.stringify(kept) !== JSON.stringify(list)) changed = true;
589
+ if (kept.length) cfg.hooks[ev] = kept;
590
+ else delete cfg.hooks[ev];
591
+ }
592
+ if (!Object.keys(cfg.hooks).length) delete cfg.hooks;
593
+ if (changed) {
594
+ backup(file);
595
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
596
+ }
597
+ return changed;
598
+ }
599
+
600
+ function unmergeMcpJson(file) {
601
+ if (!fs.existsSync(file)) return false;
602
+ let cfg;
603
+ try {
604
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
605
+ } catch {
606
+ return false;
607
+ }
608
+ if (!cfg.mcpServers?.['agent-memory']) return false;
609
+ delete cfg.mcpServers['agent-memory'];
610
+ if (!Object.keys(cfg.mcpServers).length) delete cfg.mcpServers;
611
+ backup(file);
612
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
613
+ return true;
614
+ }
615
+
616
+ function unmergeMcpToml(file) {
617
+ if (!fs.existsSync(file)) return false;
618
+ const text = fs.readFileSync(file, 'utf8');
619
+ const next = text.replace(
620
+ /\n*\[mcp_servers\.agent-memory\]\n(?:[^[]*)?/,
621
+ '\n'
622
+ );
623
+ if (next === text) return false;
624
+ backup(file);
625
+ fs.writeFileSync(file, next);
626
+ return true;
627
+ }
628
+
629
+ function unmergeMcpOpencode(file) {
630
+ if (!fs.existsSync(file)) return false;
631
+ const text = fs.readFileSync(file, 'utf8');
632
+ let cfg;
633
+ try {
634
+ cfg = JSON.parse(text);
635
+ } catch {
636
+ return false; // .jsonc we never managed to write anyway
637
+ }
638
+ if (!cfg.mcp?.['agent-memory']) return false;
639
+ delete cfg.mcp['agent-memory'];
640
+ if (!Object.keys(cfg.mcp).length) delete cfg.mcp;
641
+ backup(file);
642
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
643
+ return true;
644
+ }
645
+
646
+ function unstampRules(file) {
647
+ if (!fs.existsSync(file)) return 'absent';
648
+ const text = fs.readFileSync(file, 'utf8');
649
+ if (!text.includes(MARK_BEGIN)) return 'absent';
650
+ const re = new RegExp(`\\n*${MARK_BEGIN}[\\s\\S]*?${MARK_END}\\n?`, 'm');
651
+ const next = text.replace(re, '\n').replace(/\n{3,}/g, '\n\n');
652
+ // If only whitespace or a lone yaml frontmatter husk remains (e.g.
653
+ // a wrapper file we created), the file was entirely ours — delete it.
654
+ if (!next.replace(/^---\s*\n[\s\S]*?\n---/, '').trim()) {
655
+ fs.rmSync(file);
656
+ return 'removed (file deleted)';
657
+ }
658
+ backup(file);
659
+ fs.writeFileSync(file, next);
660
+ return 'removed';
661
+ }
662
+
663
+ export function cmdUninstall(flags) {
664
+ const requested = flags.host
665
+ ? String(flags.host).split(',').map((s) => s.trim())
666
+ : null;
667
+ const names = Object.keys(HOSTS).filter((n) =>
668
+ requested ? requested.includes('all') || requested.includes(n) : HOSTS[n].detect()
669
+ );
670
+ if (!names.length) {
671
+ out('no known agent hosts detected; nothing to remove');
672
+ return;
673
+ }
674
+ for (const name of names) {
675
+ const h = HOSTS[name];
676
+ const did = [];
677
+ try {
678
+ if (h.hooksJson && unmergeHooksJson(h.hooksJson()))
679
+ did.push(`hooks removed from ${h.hooksJson()}`);
680
+ if (h.settingsJson && unmergeClaudeSettings(h.settingsJson()))
681
+ did.push(`hooks removed from ${h.settingsJson()}`);
682
+ if (h.rules) {
683
+ for (const r of h.rules()) {
684
+ const res = unstampRules(r.path);
685
+ if (res !== 'absent') did.push(`rules ${res}: ${r.path}`);
686
+ }
687
+ }
688
+ if (h.mcpJson && unmergeMcpJson(h.mcpJson()))
689
+ did.push(`mcp entry removed from ${h.mcpJson()}`);
690
+ if (h.mcpToml && unmergeMcpToml(h.mcpToml()))
691
+ did.push(`mcp entry removed from ${h.mcpToml()}`);
692
+ if (h.mcpOpencode && unmergeMcpOpencode(h.mcpOpencode()))
693
+ did.push(`mcp entry removed from ${h.mcpOpencode()}`);
694
+ } catch (e) {
695
+ err(`${name}: ${e.message}`);
696
+ continue;
697
+ }
698
+ out(`${name}: ${did.length ? did.join('; ') : 'nothing wired'}`);
699
+ }
700
+ }
701
+
543
702
  function hostWired(name) {
544
703
  const h = HOSTS[name];
545
704
  if (!h.detect()) return 'not installed';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucid-mem",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "lucid-mem (amr) - autonomous git-backed agent memory (Agent Memory Repo spec)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,5 +18,8 @@
18
18
  "agent",
19
19
  "memory",
20
20
  "cli"
21
- ]
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test"
24
+ }
22
25
  }
@@ -0,0 +1,97 @@
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, initRepo, read } from './helpers.mjs';
6
+
7
+ function seed(home, repo, text) {
8
+ const r = run(['add', text, '--project'], { amrHome: home, cwd: repo });
9
+ assert.equal(r.status, 0, r.stderr);
10
+ }
11
+
12
+ test('context in a non-git dir shows global memory only', () => {
13
+ const home = tmpdir();
14
+ run(['ensure'], { amrHome: home });
15
+ run(['add', 'prefers tabs'], { amrHome: home });
16
+ const r = run(['context'], { amrHome: home, cwd: tmpdir() });
17
+ assert.equal(r.status, 0);
18
+ assert.match(r.stdout, /prefers tabs/);
19
+ });
20
+
21
+ test('context in a repo shows its project memory', () => {
22
+ const home = tmpdir();
23
+ const repo = initRepo(path.join(tmpdir(), 'app'), 'https://github.com/acme/app.git');
24
+ run(['ensure'], { amrHome: home });
25
+ seed(home, repo, 'app uses redis cache');
26
+ const r = run(['context'], { amrHome: home, cwd: repo });
27
+ assert.equal(r.status, 0);
28
+ assert.match(r.stdout, /app uses redis cache/);
29
+ });
30
+
31
+ test('link attaches a sibling project transitively; private blocks transitive only', () => {
32
+ const home = tmpdir();
33
+ const base = tmpdir();
34
+ const repoA = initRepo(path.join(base, 'a'), 'https://github.com/acme/a.git');
35
+ const repoB = initRepo(path.join(base, 'b'), 'https://github.com/acme/b.git');
36
+ const repoC = initRepo(path.join(base, 'c'), 'https://github.com/acme/c.git');
37
+ run(['ensure'], { amrHome: home });
38
+ seed(home, repoA, 'a uses sqlite');
39
+ seed(home, repoB, 'b uses kafka');
40
+ seed(home, repoC, 'c is a leaf');
41
+
42
+ // b links to a
43
+ const link = run(['link', 'a'], { amrHome: home, cwd: repoB });
44
+ assert.equal(link.status, 0);
45
+ const inB = run(['context'], { amrHome: home, cwd: repoB });
46
+ assert.match(inB.stdout, /a uses sqlite/);
47
+
48
+ // c links to b; a is private -> transitive refusal in c
49
+ run(['link', 'b'], { amrHome: home, cwd: repoC });
50
+ run(['policy', 'a', 'private', 'true'], { amrHome: home, cwd: repoA });
51
+ const inC = run(['context'], { amrHome: home, cwd: repoC });
52
+ 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'));
55
+
56
+ // explicit --also still attaches the private project
57
+ const explicit = run(['context', '--also', 'a'], { amrHome: home, cwd: repoC });
58
+ assert.match(explicit.stdout, /a uses sqlite/);
59
+ });
60
+
61
+ test('unresolved link names surface a marker', () => {
62
+ const home = tmpdir();
63
+ const repo = initRepo(path.join(tmpdir(), 'solo'), 'https://github.com/acme/solo.git');
64
+ run(['ensure'], { amrHome: home });
65
+ seed(home, repo, 'solo entry');
66
+ run(['link', 'ghost-project'], { amrHome: home, cwd: repo });
67
+ const r = run(['context'], { amrHome: home, cwd: repo });
68
+ assert.match(r.stdout, /unresolved \(no memory file\): ghost-project/);
69
+ });
70
+
71
+ test('unlink removes the auto-attach', () => {
72
+ const home = tmpdir();
73
+ const base = tmpdir();
74
+ const repoA = initRepo(path.join(base, 'x1'), 'https://github.com/acme/x1.git');
75
+ const repoB = initRepo(path.join(base, 'y1'), 'https://github.com/acme/y1.git');
76
+ run(['ensure'], { amrHome: home });
77
+ seed(home, repoA, 'x1 fact');
78
+ seed(home, repoB, 'y1 fact');
79
+ run(['link', 'x1'], { amrHome: home, cwd: repoB });
80
+ assert.match(run(['context'], { amrHome: home, cwd: repoB }).stdout, /x1 fact/);
81
+ run(['unlink', 'x1'], { amrHome: home, cwd: repoB });
82
+ assert.ok(!run(['context'], { amrHome: home, cwd: repoB }).stdout.includes('x1 fact'));
83
+ });
84
+
85
+ test('noglobal policy suppresses global memory for that repo', () => {
86
+ const home = tmpdir();
87
+ const repo = initRepo(path.join(tmpdir(), 'iso'), 'https://github.com/acme/iso.git');
88
+ run(['ensure'], { amrHome: home });
89
+ run(['add', 'global pref'], { amrHome: home });
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'));
97
+ });
@@ -0,0 +1,117 @@
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, initRepo, read, entries } from './helpers.mjs';
6
+
7
+ test('ensure creates repo, MEMORY.md with index, idempotent', () => {
8
+ const home = tmpdir();
9
+ const r1 = run(['ensure'], { amrHome: home });
10
+ assert.equal(r1.status, 0, r1.stderr);
11
+ const mem = read(path.join(home, 'MEMORY.md'));
12
+ assert.match(mem, /# Memory/);
13
+ assert.match(mem, /## Index/);
14
+ assert.ok(fs.existsSync(path.join(home, '.git')));
15
+ const r2 = run(['ensure'], { amrHome: home });
16
+ assert.equal(r2.status, 0);
17
+ });
18
+
19
+ test('ensure refuses a non-empty non-repo dir', () => {
20
+ const home = tmpdir();
21
+ fs.writeFileSync(path.join(home, 'junk.txt'), 'x');
22
+ const r = run(['ensure'], { amrHome: home });
23
+ assert.notEqual(r.status, 0);
24
+ });
25
+
26
+ test('add appends a dated bullet to MEMORY.md', () => {
27
+ const home = tmpdir();
28
+ run(['ensure'], { amrHome: home });
29
+ const r = run(['add', 'prefers bullet points'], { amrHome: home });
30
+ assert.equal(r.status, 0, r.stderr);
31
+ const lines = entries(path.join(home, 'MEMORY.md'));
32
+ assert.ok(lines.some((l) => l.includes('prefers bullet points')));
33
+ assert.match(lines[lines.length - 1], /\[added: \d{4}-\d{2}-\d{2}/);
34
+ });
35
+
36
+ test('add refuses secret-shaped text; --allow-secret overrides', () => {
37
+ const home = tmpdir();
38
+ run(['ensure'], { amrHome: home });
39
+ const r = run(['add', 'api key is ghp_abcdefghijklmnop1234567890'], { amrHome: home });
40
+ assert.notEqual(r.status, 0);
41
+ assert.ok(!(read(path.join(home, 'MEMORY.md')) || '').includes('ghp_'));
42
+ const r2 = run(['add', 'api key is ghp_abcdefghijklmnop1234567890', '--allow-secret'], {
43
+ amrHome: home,
44
+ });
45
+ assert.equal(r2.status, 0);
46
+ });
47
+
48
+ test('add skips near-duplicates', () => {
49
+ const home = tmpdir();
50
+ run(['ensure'], { amrHome: home });
51
+ run(['add', 'uses dark mode'], { amrHome: home });
52
+ const r = run(['add', 'uses dark mode'], { amrHome: home });
53
+ assert.equal(r.status, 0);
54
+ assert.equal(
55
+ entries(path.join(home, 'MEMORY.md')).filter((l) => l.includes('dark mode')).length,
56
+ 1
57
+ );
58
+ });
59
+
60
+ test('add --file writes a topic file and indexes it', () => {
61
+ const home = tmpdir();
62
+ run(['ensure'], { amrHome: home });
63
+ const r = run(['add', 'prefers pnpm', '--file', 'tooling'], { amrHome: home });
64
+ assert.equal(r.status, 0);
65
+ assert.ok(read(path.join(home, 'tooling.md')).includes('prefers pnpm'));
66
+ assert.match(read(path.join(home, 'MEMORY.md')), /\[\[tooling\]\]/);
67
+ });
68
+
69
+ test('update replaces an entry and keeps metadata', () => {
70
+ const home = tmpdir();
71
+ run(['ensure'], { amrHome: home });
72
+ run(['add', 'port is 3000'], { amrHome: home });
73
+ const r = run(['update', 'port is', 'port is 8080'], { amrHome: home });
74
+ assert.equal(r.status, 0, r.stderr);
75
+ const mem = read(path.join(home, 'MEMORY.md'));
76
+ assert.match(mem, /port is 8080/);
77
+ assert.match(mem, /updated: \d{4}-\d{2}-\d{2}/);
78
+ assert.ok(!mem.includes('port is 3000'));
79
+ });
80
+
81
+ test('update errors on zero and multiple matches', () => {
82
+ const home = tmpdir();
83
+ run(['ensure'], { amrHome: home });
84
+ run(['add', 'alpha entry'], { amrHome: home });
85
+ run(['add', 'alpha two'], { amrHome: home });
86
+ const none = run(['update', 'no-such-text', 'x'], { amrHome: home });
87
+ assert.notEqual(none.status, 0);
88
+ const many = run(['update', 'alpha', 'x'], { amrHome: home });
89
+ assert.notEqual(many.status, 0);
90
+ });
91
+
92
+ test('search finds entries; index rebuilds', () => {
93
+ const home = tmpdir();
94
+ run(['ensure'], { amrHome: home });
95
+ run(['add', 'kokoro for tts'], { amrHome: home });
96
+ const r = run(['search', 'kokoro'], { amrHome: home });
97
+ assert.equal(r.status, 0);
98
+ assert.match(r.stdout, /kokoro/);
99
+ const ri = run(['index'], { amrHome: home });
100
+ assert.equal(ri.status, 0);
101
+ });
102
+
103
+ test('add --project routes to projects/<slug>.md', () => {
104
+ const home = tmpdir();
105
+ const repo = initRepo(path.join(tmpdir(), 'myproj'), 'https://github.com/acme/myproj.git');
106
+ run(['ensure'], { amrHome: home });
107
+ const r = run(['add', 'uses pnpm workspaces', '--project'], {
108
+ amrHome: home,
109
+ cwd: repo,
110
+ });
111
+ assert.equal(r.status, 0, r.stderr);
112
+ const projDir = path.join(home, 'projects');
113
+ const files = fs.readdirSync(projDir);
114
+ assert.equal(files.length, 1);
115
+ assert.match(files[0], /^myproj-[0-9a-f]{6}\.md$/);
116
+ assert.ok(read(path.join(projDir, files[0])).includes('pnpm workspaces'));
117
+ });
@@ -0,0 +1,55 @@
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, read, entries } from './helpers.mjs';
6
+
7
+ function seeded() {
8
+ const home = tmpdir();
9
+ run(['ensure'], { amrHome: home });
10
+ return home;
11
+ }
12
+
13
+ test('dream --json returns a health report', () => {
14
+ const home = seeded();
15
+ run(['add', 'one fact'], { amrHome: home });
16
+ const r = run(['dream', '--json'], { amrHome: home });
17
+ assert.equal(r.status, 0, r.stderr);
18
+ const j = JSON.parse(r.stdout);
19
+ assert.equal(typeof j.stats.entries, 'number');
20
+ assert.ok(Array.isArray(j.exactDups));
21
+ });
22
+
23
+ test('dream --fix dedupes repeated entries', () => {
24
+ const home = seeded();
25
+ const mem = path.join(home, 'MEMORY.md');
26
+ // bypass near-dup skip by writing raw duplicates
27
+ fs.appendFileSync(mem, '- duplicate line [added: 2026-01-01]\n');
28
+ fs.appendFileSync(mem, '- duplicate line [added: 2026-01-02]\n');
29
+ const r = run(['dream', '--fix'], { amrHome: home });
30
+ assert.equal(r.status, 0, r.stderr);
31
+ const count = entries(mem).filter((l) => l.includes('duplicate line')).length;
32
+ assert.equal(count, 1);
33
+ });
34
+
35
+ test('dream --compact --keep archives overflow entries', () => {
36
+ const home = seeded();
37
+ for (let i = 0; i < 5; i++) run(['add', `entry number ${i}`], { amrHome: home });
38
+ const r = run(['dream', '--fix', '--compact', '--keep', '2'], { amrHome: home });
39
+ assert.equal(r.status, 0, r.stderr);
40
+ assert.ok(entries(path.join(home, 'MEMORY.md')).length <= 2 + 1); // +index tolerance
41
+ const arch = fs.readdirSync(path.join(home, 'archive'));
42
+ assert.ok(arch.length > 0);
43
+ });
44
+
45
+ test('dream --if-stale no-ops right after a dream', () => {
46
+ const home = seeded();
47
+ run(['add', 'stale check'], { amrHome: home });
48
+ run(['dream', '--fix'], { amrHome: home });
49
+ const r = run(['dream', '--if-stale', '30', '--json'], { amrHome: home });
50
+ assert.equal(r.status, 0, r.stderr);
51
+ // second run should not rewrite the tree
52
+ const mem1 = read(path.join(home, 'MEMORY.md'));
53
+ run(['dream', '--if-stale', '30', '--fix'], { amrHome: home });
54
+ assert.equal(read(path.join(home, 'MEMORY.md')), mem1);
55
+ });
@@ -0,0 +1,86 @@
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, read } from './helpers.mjs';
6
+
7
+ test('harvest --extract pulls user messages from transcript fixtures', () => {
8
+ const home = tmpdir();
9
+ run(['ensure'], { amrHome: home });
10
+ const fakeHome = tmpdir();
11
+ const sessDir = path.join(fakeHome, '.claude', 'projects', 'myrepo');
12
+ fs.mkdirSync(sessDir, { recursive: true });
13
+ fs.writeFileSync(
14
+ path.join(sessDir, 'session-1.jsonl'),
15
+ [
16
+ JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'harvest this durable sentence please' }] } }),
17
+ JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>injected noise</system-reminder>' }] } }),
18
+ JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'assistant reply not user' }] }),
19
+ ].join('\n')
20
+ );
21
+ const r = run(['harvest', '--days', '7', '--extract'], { amrHome: home, fakeHome });
22
+ assert.equal(r.status, 0, r.stderr);
23
+ assert.match(r.stdout, /harvest this durable sentence/);
24
+ assert.ok(!r.stdout.includes('injected noise'));
25
+ assert.ok(!r.stdout.includes('assistant reply'));
26
+ });
27
+
28
+ test('install --host bogus errors and lists valid hosts', () => {
29
+ const home = tmpdir();
30
+ const r = run(['install', '--host', 'bogus'], { amrHome: home, fakeHome: tmpdir() });
31
+ assert.notEqual(r.status, 0);
32
+ assert.match(r.stdout + r.stderr, /bogus/);
33
+ });
34
+
35
+ test('uninstall removes only amr wiring and preserves user config', () => {
36
+ const home = tmpdir();
37
+ const fakeHome = tmpdir();
38
+ run(['ensure'], { amrHome: home });
39
+ // codex with pre-existing user config
40
+ const codexDir = path.join(fakeHome, '.codex');
41
+ fs.mkdirSync(codexDir, { recursive: true });
42
+ fs.writeFileSync(path.join(codexDir, 'config.toml'), 'model = "o4-mini"\n');
43
+ // cursor with a foreign hook that must survive
44
+ const cursorDir = path.join(fakeHome, '.cursor');
45
+ fs.mkdirSync(cursorDir, { recursive: true });
46
+ const cursorHooks = path.join(cursorDir, 'hooks.json');
47
+ fs.writeFileSync(
48
+ cursorHooks,
49
+ JSON.stringify({
50
+ version: 1,
51
+ hooks: { sessionStart: [{ command: 'echo user-hook' }] },
52
+ })
53
+ );
54
+
55
+ run(['install', '--host', 'codex,cursor'], { amrHome: home, fakeHome });
56
+ assert.match(read(path.join(codexDir, 'config.toml')), /mcp_servers\.agent-memory/);
57
+ assert.match(read(cursorHooks), /amr\.mjs/);
58
+
59
+ const r = run(['uninstall', '--host', 'codex,cursor'], { amrHome: home, fakeHome });
60
+ assert.equal(r.status, 0, r.stderr);
61
+
62
+ // ours removed
63
+ assert.ok(!fs.existsSync(path.join(codexDir, 'AGENTS.md'))); // file was entirely ours
64
+ const toml = read(path.join(codexDir, 'config.toml'));
65
+ assert.ok(!toml.includes('mcp_servers.agent-memory'));
66
+ const hooks = JSON.parse(read(cursorHooks));
67
+ // theirs preserved
68
+ assert.ok(toml.includes('o4-mini'));
69
+ assert.ok(hooks.hooks.sessionStart.some((h) => h.command === 'echo user-hook'));
70
+ assert.ok(!JSON.stringify(hooks).includes('amr.mjs'));
71
+ });
72
+
73
+ test('install --host codex writes rules + mcp into a fake home, idempotent', () => {
74
+ const home = tmpdir();
75
+ const fakeHome = tmpdir();
76
+ run(['ensure'], { amrHome: home });
77
+ const r1 = run(['install', '--host', 'codex'], { amrHome: home, fakeHome });
78
+ assert.equal(r1.status, 0, r1.stderr);
79
+ const agents = read(path.join(fakeHome, '.codex', 'AGENTS.md')) || '';
80
+ assert.match(agents, /agent-memory/);
81
+ const toml = read(path.join(fakeHome, '.codex', 'config.toml')) || '';
82
+ assert.match(toml, /mcp_servers\.agent-memory/);
83
+ const r2 = run(['install', '--host', 'codex'], { amrHome: home, fakeHome });
84
+ assert.equal(r2.status, 0);
85
+ assert.match(r2.stdout + r2.stderr, /unchanged|already/);
86
+ });
@@ -0,0 +1,74 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const CLI = path.resolve(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ '..',
10
+ 'amr.mjs'
11
+ );
12
+
13
+ export function tmpdir(prefix = 'amrtest-') {
14
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
15
+ }
16
+
17
+ // Run the real CLI as a subprocess. Every test gets an isolated
18
+ // AMR_HOME; fakeHome additionally redirects HOME/USERPROFILE for
19
+ // adapters and harvest sources that resolve paths off the home dir.
20
+ export function run(args, { cwd, amrHome, fakeHome, env } = {}) {
21
+ const e = {
22
+ ...process.env,
23
+ AMR_HOME: amrHome,
24
+ // Redirect every home-ish env var so harvest/adapters can't touch
25
+ // the real user profile during tests (APPDATA leaks otherwise).
26
+ ...(fakeHome
27
+ ? {
28
+ HOME: fakeHome,
29
+ USERPROFILE: fakeHome,
30
+ APPDATA: path.join(fakeHome, 'AppData', 'Roaming'),
31
+ LOCALAPPDATA: path.join(fakeHome, 'AppData', 'Local'),
32
+ HOMEDRIVE: path.parse(fakeHome).root.replace(/[\\/]+$/, ''),
33
+ HOMEPATH: fakeHome.slice(path.parse(fakeHome).root.length),
34
+ XDG_CONFIG_HOME: path.join(fakeHome, '.config'),
35
+ }
36
+ : {}),
37
+ ...env,
38
+ };
39
+ const r = spawnSync(process.execPath, [CLI, ...args], {
40
+ encoding: 'utf8',
41
+ cwd,
42
+ env: e,
43
+ timeout: 30_000,
44
+ });
45
+ return { status: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
46
+ }
47
+
48
+ export function git(dir, args) {
49
+ const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
50
+ if (r.status !== 0)
51
+ throw new Error(`git ${args[0]} failed: ${r.stderr || r.stdout}`);
52
+ return r.stdout.trim();
53
+ }
54
+
55
+ export function initRepo(dir, remoteUrl) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ git(dir, ['init', '-b', 'main']);
58
+ if (remoteUrl) git(dir, ['remote', 'add', 'origin', remoteUrl]);
59
+ git(dir, [
60
+ '-c', 'user.email=test@lucid-mem.dev',
61
+ '-c', 'user.name=amr-test',
62
+ 'commit', '--allow-empty', '-m', 'init',
63
+ ]);
64
+ return dir;
65
+ }
66
+
67
+ export function read(file) {
68
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
69
+ }
70
+
71
+ export function entries(file) {
72
+ const t = read(file) || '';
73
+ return t.split('\n').filter((l) => l.startsWith('- '));
74
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { tmpdir, run, CLI } from './helpers.mjs';
5
+
6
+ // serve is a stdio JSON-RPC server: newline-delimited requests.
7
+ function rpc(home, messages, cwd) {
8
+ const input = messages.map((m) => JSON.stringify(m)).join('\n') + '\n';
9
+ const r = spawnSync(process.execPath, [CLI, 'serve'],
10
+ { input, encoding: 'utf8', env: { ...process.env, AMR_HOME: home }, cwd, timeout: 15_000 }
11
+ );
12
+ return (r.stdout || '')
13
+ .split('\n')
14
+ .filter(Boolean)
15
+ .map((l) => {
16
+ try { return JSON.parse(l); } catch { return null; }
17
+ })
18
+ .filter(Boolean);
19
+ }
20
+
21
+ test('serve answers tools/list with the memory tools', () => {
22
+ const home = tmpdir();
23
+ run(['ensure'], { amrHome: home });
24
+ const out = rpc(home, [{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }]);
25
+ const resp = out.find((m) => m.id === 1);
26
+ assert.ok(resp, 'no response to tools/list');
27
+ const names = resp.result.tools.map((t) => t.name);
28
+ for (const t of ['memory_context', 'memory_add', 'memory_search', 'memory_update'])
29
+ assert.ok(names.includes(t), `missing tool ${t}`);
30
+ });
31
+
32
+ test('serve memory_context returns the digest as content text', () => {
33
+ const home = tmpdir();
34
+ run(['ensure'], { amrHome: home });
35
+ run(['add', 'mcp round-trip fact'], { amrHome: home });
36
+ const out = rpc(home, [
37
+ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'memory_context', arguments: {} } },
38
+ ]);
39
+ const resp = out.find((m) => m.id === 2);
40
+ assert.ok(resp?.result?.content?.[0]?.text?.includes('mcp round-trip fact'));
41
+ });