lucid-mem 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +46 -0
- package/lib/cli.mjs +16 -3
- package/lib/install.mjs +159 -0
- package/package.json +6 -3
- package/test/context.test.mjs +97 -0
- package/test/core.test.mjs +117 -0
- package/test/dream.test.mjs +55 -0
- package/test/harvest-install.test.mjs +86 -0
- package/test/helpers.mjs +74 -0
- package/test/serve.test.mjs +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentMemoryRepo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# lucid-mem
|
|
2
|
+
|
|
3
|
+
Autonomous, git-backed memory for AI coding agents. `amr` keeps what your agents learn across sessions and across repositories: it injects memory at session start, lets agents save durable facts while they work, syncs through git, and maintains itself with scheduled dreaming — no manual invocation.
|
|
4
|
+
|
|
5
|
+
Implements the [Agent Memory Repo spec](https://github.com/AgentMemoryRepo/agentmemoryrepo). Full docs and per-host details live in the [project repo](https://github.com/tonythethompson/lucid-mem).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm i -g lucid-mem # gives you `amr` (and `lucid` as an alias)
|
|
11
|
+
amr ensure # create the memory repo (default ~/agent-memory, $AMR_HOME to override)
|
|
12
|
+
amr install # wire rules + hooks + MCP into detected agent hosts
|
|
13
|
+
amr doctor # verify everything
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then start a session in a wired host — Cursor, Claude Code, Windsurf, Devin, Copilot, Codex, Cline, Muse, Claude Desktop, Antigravity, OpenCode, CommandCode, or Freebuff — and memory is already in context.
|
|
17
|
+
|
|
18
|
+
## Daily use
|
|
19
|
+
|
|
20
|
+
Mostly you don't touch it; agents do. Useful by hand:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
amr context # what a session here would see
|
|
24
|
+
amr add "I prefer concise summaries" # global memory
|
|
25
|
+
amr add "uses Kokoro-82M for TTS" --project # this repo's memory
|
|
26
|
+
amr search "tts"
|
|
27
|
+
amr link other-repo # auto-attach another repo's memory to sessions here
|
|
28
|
+
amr discover # which hot repos still lack memory
|
|
29
|
+
amr dream --fix --compact # dedupe + archive overflow
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Memory lives in `~/agent-memory` — a real git repo you can read, diff, and point at a private remote (`amr remote set <url>`) for sync and backup.
|
|
33
|
+
|
|
34
|
+
## Multi-repo memory
|
|
35
|
+
|
|
36
|
+
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
|
+
|
|
38
|
+
## MCP
|
|
39
|
+
|
|
40
|
+
`amr serve` is a stdio MCP server (`memory_context`, `memory_add`, `memory_search`, `memory_update`) — `amr install` registers it with hosts that support MCP.
|
|
41
|
+
|
|
42
|
+
## Links
|
|
43
|
+
|
|
44
|
+
- Repo: https://github.com/tonythethompson/lucid-mem
|
|
45
|
+
- Spec: https://github.com/AgentMemoryRepo/agentmemoryrepo
|
|
46
|
+
- License: MIT
|
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
|
-
|
|
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/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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lucid-mem",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "lucid-mem (amr)
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "lucid-mem (amr) - autonomous git-backed agent memory (Agent Memory Repo spec)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"amr": "./amr.mjs",
|
|
@@ -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
|
+
});
|
package/test/helpers.mjs
ADDED
|
@@ -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
|
+
});
|