talon-agent 1.1.0 → 1.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/package.json +5 -6
- package/prompts/dream.md +6 -2
- package/prompts/mempalace.md +57 -0
- package/src/__tests__/cron-store-extended.test.ts +1 -1
- package/src/__tests__/dream.test.ts +118 -1
- package/src/__tests__/fuzz.test.ts +1 -1
- package/src/__tests__/gateway-retry.test.ts +0 -4
- package/src/__tests__/handlers.test.ts +0 -4
- package/src/__tests__/heartbeat.test.ts +3 -0
- package/src/__tests__/mempalace-plugin.test.ts +295 -0
- package/src/__tests__/plugin.test.ts +169 -0
- package/src/__tests__/storage-save-errors.test.ts +1 -1
- package/src/__tests__/time.test.ts +1 -1
- package/src/__tests__/watchdog.test.ts +1 -3
- package/src/__tests__/workspace.test.ts +0 -1
- package/src/bootstrap.ts +72 -7
- package/src/core/dream.ts +40 -6
- package/src/core/plugin.ts +103 -16
- package/src/frontend/telegram/handlers.ts +5 -17
- package/src/plugins/mempalace/index.ts +147 -0
- package/src/util/config.ts +11 -0
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,7 +44,6 @@
|
|
|
44
44
|
"test": "vitest run",
|
|
45
45
|
"test:watch": "vitest",
|
|
46
46
|
"test:coverage": "vitest run --coverage",
|
|
47
|
-
"test:mutation": "stryker run",
|
|
48
47
|
"typecheck": "tsc --noEmit",
|
|
49
48
|
"lint": "oxlint src/",
|
|
50
49
|
"knip": "knip",
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
"format:check": "prettier --check src/ prompts/"
|
|
53
52
|
},
|
|
54
53
|
"dependencies": {
|
|
55
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
54
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.97",
|
|
56
55
|
"@clack/prompts": "^1.2.0",
|
|
57
56
|
"@grammyjs/auto-retry": "^2.0.2",
|
|
58
57
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
@@ -74,9 +73,6 @@
|
|
|
74
73
|
"zod": "^4.3.6"
|
|
75
74
|
},
|
|
76
75
|
"devDependencies": {
|
|
77
|
-
"@stryker-mutator/core": "^9.6.0",
|
|
78
|
-
"@stryker-mutator/typescript-checker": "^9.6.0",
|
|
79
|
-
"@stryker-mutator/vitest-runner": "^9.6.0",
|
|
80
76
|
"@types/node": "^25.5.2",
|
|
81
77
|
"@types/write-file-atomic": "^4.0.3",
|
|
82
78
|
"@vitest/coverage-v8": "^4.1.3",
|
|
@@ -86,5 +82,8 @@
|
|
|
86
82
|
"prettier": "^3.8.1",
|
|
87
83
|
"typescript": "^6.0.2",
|
|
88
84
|
"vitest": "^4.1.3"
|
|
85
|
+
},
|
|
86
|
+
"overrides": {
|
|
87
|
+
"@anthropic-ai/sdk": "^0.86.1"
|
|
89
88
|
}
|
|
90
89
|
}
|
package/prompts/dream.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
You are Talon's background memory consolidation agent. Your job is to update the persistent memory file with new information learned from recent interaction logs.
|
|
2
2
|
|
|
3
|
-
You
|
|
3
|
+
You primarily use filesystem tools (Read, Write, Edit, Bash, Glob, Grep). Do NOT attempt to use any Telegram or other messaging tools. MCP tools may be used if required by Stage 5.
|
|
4
4
|
|
|
5
|
-
## Your
|
|
5
|
+
## Your 5-stage task
|
|
6
6
|
|
|
7
7
|
### Stage 1 — Orient
|
|
8
8
|
|
|
@@ -42,4 +42,8 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
|
|
|
42
42
|
- Do NOT remove entries just because they're old — only remove if wrong or superseded
|
|
43
43
|
- Write the updated memory.md back to `{{memoryFile}}`
|
|
44
44
|
|
|
45
|
+
### Stage 5 — Mine to MemPalace & Write Diary (optional)
|
|
46
|
+
|
|
47
|
+
{{mempalaceSection}}
|
|
48
|
+
|
|
45
49
|
When done with memory consolidation, stop. The system handles all dream_state.json updates.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
## MemPalace — Long-term Memory
|
|
2
|
+
|
|
3
|
+
You have access to a local memory palace via MCP tools. The palace stores verbatim conversation history and a temporal knowledge graph — all local, zero cloud, zero API calls.
|
|
4
|
+
|
|
5
|
+
### Architecture
|
|
6
|
+
|
|
7
|
+
- **Wings** = top-level categories (people, projects, topics)
|
|
8
|
+
- **Rooms** = specific subjects within a wing
|
|
9
|
+
- **Drawers** = individual memory chunks (verbatim text)
|
|
10
|
+
- **Knowledge Graph** = entity-relationship facts with temporal validity
|
|
11
|
+
|
|
12
|
+
### Protocol — FOLLOW EVERY SESSION
|
|
13
|
+
|
|
14
|
+
1. **BEFORE RESPONDING** about any person, project, or past event: call `mempalace_search` or `mempalace_kg_query` FIRST. Never guess — verify from the palace.
|
|
15
|
+
2. **IF UNSURE** about a fact (name, age, relationship, preference): query the palace. Wrong is worse than slow.
|
|
16
|
+
3. **WHEN FACTS CHANGE**: Call `mempalace_kg_invalidate` on the old fact, then `mempalace_kg_add` for the new one.
|
|
17
|
+
4. **AFTER LEARNING** something important: store it. Use `mempalace_add_drawer` for rich context, `mempalace_kg_add` for structured facts.
|
|
18
|
+
|
|
19
|
+
### Tools
|
|
20
|
+
|
|
21
|
+
**Search & Browse:**
|
|
22
|
+
|
|
23
|
+
- `mempalace_search` — Semantic search. Use short keywords/questions, not full sentences. Filter by wing/room.
|
|
24
|
+
- `mempalace_check_duplicate` — Check before filing new content (threshold default 0.9, lower to 0.85 to catch near-dupes).
|
|
25
|
+
- `mempalace_status` — Palace overview: total drawers, wings, rooms.
|
|
26
|
+
- `mempalace_list_wings` / `mempalace_list_rooms` — Browse structure.
|
|
27
|
+
- `mempalace_get_taxonomy` — Full wing/room/count tree.
|
|
28
|
+
|
|
29
|
+
**Knowledge Graph (Temporal Facts):**
|
|
30
|
+
|
|
31
|
+
- `mempalace_kg_query` — Query entity relationships. Supports `as_of` date filtering.
|
|
32
|
+
- `mempalace_kg_add` — Add fact: subject -> predicate -> object. Optional `valid_from`.
|
|
33
|
+
- `mempalace_kg_invalidate` — Mark a fact as no longer true.
|
|
34
|
+
- `mempalace_kg_timeline` — Chronological story of an entity.
|
|
35
|
+
- `mempalace_kg_stats` — Graph overview: entities, triples, relationship types.
|
|
36
|
+
|
|
37
|
+
**Palace Graph (Cross-Domain Connections):**
|
|
38
|
+
|
|
39
|
+
- `mempalace_traverse` — Walk from a room, find connected ideas across wings.
|
|
40
|
+
- `mempalace_find_tunnels` — Find rooms that bridge two wings.
|
|
41
|
+
- `mempalace_graph_stats` — Graph connectivity overview.
|
|
42
|
+
|
|
43
|
+
**Write:**
|
|
44
|
+
|
|
45
|
+
- `mempalace_add_drawer` — Store verbatim content into a wing/room. Auto-checks duplicates.
|
|
46
|
+
- `mempalace_delete_drawer` — Remove a drawer by ID.
|
|
47
|
+
- `mempalace_diary_write` — Write a session diary entry (agent_name, entry, topic).
|
|
48
|
+
- `mempalace_diary_read` — Read recent diary entries.
|
|
49
|
+
|
|
50
|
+
### Tips
|
|
51
|
+
|
|
52
|
+
- Search is **semantic** (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
|
|
53
|
+
- The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
|
|
54
|
+
- Use `mempalace_check_duplicate` before storing new content to avoid clutter.
|
|
55
|
+
- Diary entries accumulate across sessions. Write them to build continuity of self.
|
|
56
|
+
|
|
57
|
+
### Palace location: `{{palacePath}}`
|
|
@@ -596,7 +596,7 @@ describe("loadCronJobs — invalid timezone stripping", () => {
|
|
|
596
596
|
beforeEach(() => existsSyncMock.mockReset().mockReturnValue(false));
|
|
597
597
|
|
|
598
598
|
it("strips invalid timezone from loaded job", async () => {
|
|
599
|
-
|
|
599
|
+
await import("../storage/cron-store.js");
|
|
600
600
|
const jobWithBadTz: Record<string, unknown> = {
|
|
601
601
|
"tz-bad-id": {
|
|
602
602
|
id: "tz-bad-id",
|
|
@@ -747,6 +747,7 @@ describe("dream error paths", () => {
|
|
|
747
747
|
vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
|
|
748
748
|
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
749
749
|
query: vi.fn(async function* () {
|
|
750
|
+
yield; // satisfy require-yield
|
|
750
751
|
throw new Error("agent crashed unexpectedly");
|
|
751
752
|
}),
|
|
752
753
|
}));
|
|
@@ -811,6 +812,7 @@ describe("dream error paths", () => {
|
|
|
811
812
|
}));
|
|
812
813
|
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
813
814
|
query: vi.fn(async function* () {
|
|
815
|
+
yield; // satisfy require-yield
|
|
814
816
|
await queryPromise; // suspend until we release
|
|
815
817
|
}),
|
|
816
818
|
}));
|
|
@@ -957,7 +959,8 @@ describe("runDreamAgent — timeout arrow fn fires after DREAM_TIMEOUT_MS", () =
|
|
|
957
959
|
// query never resolves — so the 10-minute timeout wins the race
|
|
958
960
|
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
959
961
|
query: vi.fn(async function* () {
|
|
960
|
-
|
|
962
|
+
yield; // satisfy require-yield
|
|
963
|
+
await new Promise(() => {}); // never resolves
|
|
961
964
|
}),
|
|
962
965
|
}));
|
|
963
966
|
|
|
@@ -973,6 +976,120 @@ describe("runDreamAgent — timeout arrow fn fires after DREAM_TIMEOUT_MS", () =
|
|
|
973
976
|
});
|
|
974
977
|
});
|
|
975
978
|
|
|
979
|
+
describe("mempalace section gating in dream prompt", () => {
|
|
980
|
+
beforeEach(() => {
|
|
981
|
+
vi.resetModules();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("includes mempalace mining command and diary instructions when mempalace is configured", async () => {
|
|
985
|
+
vi.doMock("node:fs", () => ({
|
|
986
|
+
existsSync: vi.fn(() => false),
|
|
987
|
+
readFileSync: vi.fn(() => "PROMPT START {{mempalaceSection}} PROMPT END"),
|
|
988
|
+
mkdirSync: vi.fn(),
|
|
989
|
+
appendFileSync: vi.fn(),
|
|
990
|
+
}));
|
|
991
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
|
|
992
|
+
vi.doMock("../util/log.js", () => ({
|
|
993
|
+
log: vi.fn(),
|
|
994
|
+
logError: vi.fn(),
|
|
995
|
+
logWarn: vi.fn(),
|
|
996
|
+
}));
|
|
997
|
+
vi.doMock("../util/paths.js", () => ({
|
|
998
|
+
files: {
|
|
999
|
+
dreamState: "/fake/.talon/data/dream_state.json",
|
|
1000
|
+
memory: "/fake/.talon/workspace/memory/memory.md",
|
|
1001
|
+
log: "/fake/.talon/talon.log",
|
|
1002
|
+
},
|
|
1003
|
+
dirs: {
|
|
1004
|
+
root: "/fake/.talon",
|
|
1005
|
+
logs: "/fake/.talon/workspace/logs",
|
|
1006
|
+
workspace: "/fake/.talon/workspace",
|
|
1007
|
+
data: "/fake/.talon/data",
|
|
1008
|
+
memory: "/fake/.talon/workspace/memory",
|
|
1009
|
+
dailyMemory: "/fake/.talon/workspace/memory/daily",
|
|
1010
|
+
},
|
|
1011
|
+
}));
|
|
1012
|
+
const queryMock = vi.fn(async function* () {});
|
|
1013
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));
|
|
1014
|
+
|
|
1015
|
+
const mod = await import("../core/dream.js");
|
|
1016
|
+
mod.initDream({
|
|
1017
|
+
model: "claude-sonnet-4-6",
|
|
1018
|
+
workspace: "/fake/ws",
|
|
1019
|
+
mempalace: {
|
|
1020
|
+
pythonPath: "/usr/bin/python3",
|
|
1021
|
+
palacePath: "/fake/palace",
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
await mod.forceDream();
|
|
1025
|
+
|
|
1026
|
+
expect(queryMock).toHaveBeenCalled();
|
|
1027
|
+
const callArgs = (queryMock.mock.calls[0] as unknown[])[0] as {
|
|
1028
|
+
prompt: string;
|
|
1029
|
+
};
|
|
1030
|
+
// Mempalace mining command should be in the prompt
|
|
1031
|
+
expect(callArgs.prompt).toContain("-m mempalace mine");
|
|
1032
|
+
// Diary instructions should be in the prompt
|
|
1033
|
+
expect(callArgs.prompt).toContain("mempalace_diary_write");
|
|
1034
|
+
// Should NOT contain the skip message
|
|
1035
|
+
expect(callArgs.prompt).not.toContain(
|
|
1036
|
+
"MemPalace is not configured. Skip this stage.",
|
|
1037
|
+
);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it("includes skip message when mempalace is not configured", async () => {
|
|
1041
|
+
vi.doMock("node:fs", () => ({
|
|
1042
|
+
existsSync: vi.fn(() => false),
|
|
1043
|
+
readFileSync: vi.fn(() => "PROMPT START {{mempalaceSection}} PROMPT END"),
|
|
1044
|
+
mkdirSync: vi.fn(),
|
|
1045
|
+
appendFileSync: vi.fn(),
|
|
1046
|
+
}));
|
|
1047
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
|
|
1048
|
+
vi.doMock("../util/log.js", () => ({
|
|
1049
|
+
log: vi.fn(),
|
|
1050
|
+
logError: vi.fn(),
|
|
1051
|
+
logWarn: vi.fn(),
|
|
1052
|
+
}));
|
|
1053
|
+
vi.doMock("../util/paths.js", () => ({
|
|
1054
|
+
files: {
|
|
1055
|
+
dreamState: "/fake/.talon/data/dream_state.json",
|
|
1056
|
+
memory: "/fake/.talon/workspace/memory/memory.md",
|
|
1057
|
+
log: "/fake/.talon/talon.log",
|
|
1058
|
+
},
|
|
1059
|
+
dirs: {
|
|
1060
|
+
root: "/fake/.talon",
|
|
1061
|
+
logs: "/fake/.talon/workspace/logs",
|
|
1062
|
+
workspace: "/fake/.talon/workspace",
|
|
1063
|
+
data: "/fake/.talon/data",
|
|
1064
|
+
memory: "/fake/.talon/workspace/memory",
|
|
1065
|
+
dailyMemory: "/fake/.talon/workspace/memory/daily",
|
|
1066
|
+
},
|
|
1067
|
+
}));
|
|
1068
|
+
const queryMock = vi.fn(async function* () {});
|
|
1069
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));
|
|
1070
|
+
|
|
1071
|
+
const mod = await import("../core/dream.js");
|
|
1072
|
+
// No mempalace config
|
|
1073
|
+
mod.initDream({
|
|
1074
|
+
model: "claude-sonnet-4-6",
|
|
1075
|
+
workspace: "/fake/ws",
|
|
1076
|
+
});
|
|
1077
|
+
await mod.forceDream();
|
|
1078
|
+
|
|
1079
|
+
expect(queryMock).toHaveBeenCalled();
|
|
1080
|
+
const callArgs = (queryMock.mock.calls[0] as unknown[])[0] as {
|
|
1081
|
+
prompt: string;
|
|
1082
|
+
};
|
|
1083
|
+
// Should contain the skip message
|
|
1084
|
+
expect(callArgs.prompt).toContain(
|
|
1085
|
+
"MemPalace is not configured. Skip this stage.",
|
|
1086
|
+
);
|
|
1087
|
+
// Should NOT contain mempalace mining commands
|
|
1088
|
+
expect(callArgs.prompt).not.toContain("-m mempalace mine");
|
|
1089
|
+
expect(callArgs.prompt).not.toContain("mempalace_diary_write");
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
976
1093
|
describe("maybeStartDream — () => {} catch callback on executeDream rejection", () => {
|
|
977
1094
|
it("fires the catch callback silently when executeDream('auto') rejects", async () => {
|
|
978
1095
|
vi.resetModules();
|
|
@@ -46,7 +46,7 @@ vi.mock("../storage/cron-store.js", () => ({
|
|
|
46
46
|
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
|
47
47
|
|
|
48
48
|
const { classify, TalonError } = await import("../core/errors.js");
|
|
49
|
-
|
|
49
|
+
await import("../storage/cron-store.js");
|
|
50
50
|
const { handleSharedAction } = await import("../core/gateway-actions.js");
|
|
51
51
|
const { resolveModelName } = await import("../storage/chat-settings.js");
|
|
52
52
|
const { Cron } = await import("croner");
|
|
@@ -244,10 +244,8 @@ describe("withRetry", () => {
|
|
|
244
244
|
|
|
245
245
|
describe("exhausting all retries", () => {
|
|
246
246
|
it("throws the last error after 3 total attempts for retryable errors", async () => {
|
|
247
|
-
let calls = 0;
|
|
248
247
|
const networkErr = talonErr("network");
|
|
249
248
|
const fn = vi.fn(async () => {
|
|
250
|
-
calls++;
|
|
251
249
|
throw networkErr;
|
|
252
250
|
});
|
|
253
251
|
|
|
@@ -257,9 +255,7 @@ describe("withRetry", () => {
|
|
|
257
255
|
});
|
|
258
256
|
|
|
259
257
|
it("throws the last error after 3 total attempts for overloaded errors", async () => {
|
|
260
|
-
let calls = 0;
|
|
261
258
|
const fn = vi.fn(async () => {
|
|
262
|
-
calls++;
|
|
263
259
|
throw talonErr("overloaded");
|
|
264
260
|
});
|
|
265
261
|
|
|
@@ -533,8 +533,6 @@ describe("handleTextMessage — integration via mock Context", () => {
|
|
|
533
533
|
});
|
|
534
534
|
|
|
535
535
|
describe("handlePhotoMessage — downloads and enqueues photo", () => {
|
|
536
|
-
let restoreFetch: () => void;
|
|
537
|
-
|
|
538
536
|
beforeEach(() => {
|
|
539
537
|
// Mock bot.api.getFile for file download
|
|
540
538
|
mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/test.jpg" }));
|
|
@@ -547,7 +545,6 @@ describe("handlePhotoMessage — downloads and enqueues photo", () => {
|
|
|
547
545
|
headers: { get: (_name: string) => null },
|
|
548
546
|
arrayBuffer: async () => jpegBuf.buffer,
|
|
549
547
|
}));
|
|
550
|
-
restoreFetch = () => {};
|
|
551
548
|
vi.stubGlobal("fetch", mockFetch);
|
|
552
549
|
|
|
553
550
|
executeMock.mockResolvedValue({
|
|
@@ -795,7 +792,6 @@ describe("rate limiting — isUserRateLimited via handleTextMessage", () => {
|
|
|
795
792
|
}
|
|
796
793
|
|
|
797
794
|
// 16th message should be rate limited (return early without enqueuing)
|
|
798
|
-
const before = executeMock.mock.calls.length;
|
|
799
795
|
await handleTextMessage(makeCtx(15), mockBot, mockConfig);
|
|
800
796
|
|
|
801
797
|
// Wait to confirm no debounce fires for the 16th chat
|
|
@@ -197,6 +197,7 @@ describe("forceHeartbeat", () => {
|
|
|
197
197
|
|
|
198
198
|
// Make agent throw
|
|
199
199
|
queryMock.mockImplementationOnce(async function* () {
|
|
200
|
+
yield; // satisfy require-yield
|
|
200
201
|
throw new Error("Agent exploded");
|
|
201
202
|
});
|
|
202
203
|
|
|
@@ -217,6 +218,7 @@ describe("forceHeartbeat", () => {
|
|
|
217
218
|
existsSyncMock.mockReturnValue(false);
|
|
218
219
|
|
|
219
220
|
queryMock.mockImplementationOnce(async function* () {
|
|
221
|
+
yield; // satisfy require-yield
|
|
220
222
|
throw new Error("Boom");
|
|
221
223
|
});
|
|
222
224
|
|
|
@@ -340,6 +342,7 @@ describe("awaitCurrentRun", () => {
|
|
|
340
342
|
});
|
|
341
343
|
|
|
342
344
|
queryMock.mockImplementationOnce(async function* () {
|
|
345
|
+
yield; // satisfy require-yield
|
|
343
346
|
await agentPromise;
|
|
344
347
|
});
|
|
345
348
|
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/log.js", () => ({
|
|
4
|
+
log: vi.fn(),
|
|
5
|
+
logError: vi.fn(),
|
|
6
|
+
logWarn: vi.fn(),
|
|
7
|
+
logDebug: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const PROMPT_TEMPLATE = `## MemPalace — Long-term Memory
|
|
11
|
+
|
|
12
|
+
mempalace_search mempalace_add_drawer mempalace_kg_query mempalace_kg_invalidate
|
|
13
|
+
mempalace_kg_timeline mempalace_traverse mempalace_find_tunnels
|
|
14
|
+
mempalace_diary_write mempalace_diary_read mempalace_delete_drawer
|
|
15
|
+
Protocol
|
|
16
|
+
|
|
17
|
+
### Palace location: \`{{palacePath}}\`
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
describe("mempalace plugin", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("creates a plugin with correct name and MCP server config", async () => {
|
|
27
|
+
vi.doMock("node:fs", () => ({
|
|
28
|
+
existsSync: vi.fn(() => true),
|
|
29
|
+
mkdirSync: vi.fn(),
|
|
30
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
31
|
+
}));
|
|
32
|
+
vi.doMock("node:child_process", () => ({
|
|
33
|
+
execFileSync: vi.fn(() => "ok"),
|
|
34
|
+
execFile: vi.fn(
|
|
35
|
+
(
|
|
36
|
+
_cmd: string,
|
|
37
|
+
_args: string[],
|
|
38
|
+
_opts: unknown,
|
|
39
|
+
cb: (
|
|
40
|
+
err: Error | null,
|
|
41
|
+
result: { stdout: string; stderr: string },
|
|
42
|
+
) => void,
|
|
43
|
+
) => {
|
|
44
|
+
cb(null, { stdout: "Palace: 42 drawers", stderr: "" });
|
|
45
|
+
},
|
|
46
|
+
),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const { createMempalacePlugin } =
|
|
50
|
+
await import("../plugins/mempalace/index.js");
|
|
51
|
+
const plugin = createMempalacePlugin({
|
|
52
|
+
pythonPath: "/venv/bin/python",
|
|
53
|
+
palacePath: "/data/palace",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(plugin.name).toBe("mempalace");
|
|
57
|
+
expect(plugin.version).toBe("1.0.0");
|
|
58
|
+
expect(plugin.mcpServer).toEqual({
|
|
59
|
+
command: "/venv/bin/python",
|
|
60
|
+
args: ["-m", "mempalace.mcp_server", "--palace", "/data/palace"],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("validateConfig returns error when python binary not found", async () => {
|
|
65
|
+
vi.doMock("node:fs", () => ({
|
|
66
|
+
existsSync: vi.fn(() => false),
|
|
67
|
+
mkdirSync: vi.fn(),
|
|
68
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
69
|
+
}));
|
|
70
|
+
vi.doMock("node:child_process", () => ({
|
|
71
|
+
execFileSync: vi.fn(),
|
|
72
|
+
execFile: vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const { createMempalacePlugin } =
|
|
76
|
+
await import("../plugins/mempalace/index.js");
|
|
77
|
+
const plugin = createMempalacePlugin({
|
|
78
|
+
pythonPath: "/nonexistent/python",
|
|
79
|
+
palacePath: "/data/palace",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const errors = plugin.validateConfig!({});
|
|
83
|
+
expect(errors).toBeDefined();
|
|
84
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
85
|
+
expect(errors![0]).toContain("Python binary not found");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("validateConfig passes when python binary exists and mempalace is importable", async () => {
|
|
89
|
+
vi.doMock("node:fs", () => ({
|
|
90
|
+
existsSync: vi.fn(() => true),
|
|
91
|
+
mkdirSync: vi.fn(),
|
|
92
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
93
|
+
}));
|
|
94
|
+
vi.doMock("node:child_process", () => ({
|
|
95
|
+
execFileSync: vi.fn(() => "ok"),
|
|
96
|
+
execFile: vi.fn(),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { createMempalacePlugin } =
|
|
100
|
+
await import("../plugins/mempalace/index.js");
|
|
101
|
+
const plugin = createMempalacePlugin({
|
|
102
|
+
pythonPath: "/venv/bin/python",
|
|
103
|
+
palacePath: "/data/palace",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const errors = plugin.validateConfig!({});
|
|
107
|
+
expect(errors).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("validateConfig returns error when mempalace is not importable", async () => {
|
|
111
|
+
vi.doMock("node:fs", () => ({
|
|
112
|
+
existsSync: vi.fn(() => true),
|
|
113
|
+
mkdirSync: vi.fn(),
|
|
114
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
115
|
+
}));
|
|
116
|
+
vi.doMock("node:child_process", () => ({
|
|
117
|
+
execFileSync: vi.fn(() => {
|
|
118
|
+
throw new Error("ModuleNotFoundError");
|
|
119
|
+
}),
|
|
120
|
+
execFile: vi.fn(),
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const { createMempalacePlugin } =
|
|
124
|
+
await import("../plugins/mempalace/index.js");
|
|
125
|
+
const plugin = createMempalacePlugin({
|
|
126
|
+
pythonPath: "/venv/bin/python",
|
|
127
|
+
palacePath: "/data/palace",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const errors = plugin.validateConfig!({});
|
|
131
|
+
expect(errors).toBeDefined();
|
|
132
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
133
|
+
expect(errors![0]).toContain("mempalace package not installed");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("init creates palace directory if missing", async () => {
|
|
137
|
+
const mkdirSyncMock = vi.fn();
|
|
138
|
+
vi.doMock("node:fs", () => ({
|
|
139
|
+
existsSync: vi.fn((p: string) =>
|
|
140
|
+
p === "/venv/bin/python" ? true : false,
|
|
141
|
+
),
|
|
142
|
+
mkdirSync: mkdirSyncMock,
|
|
143
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
144
|
+
}));
|
|
145
|
+
vi.doMock("node:child_process", () => ({
|
|
146
|
+
execFileSync: vi.fn(() => "ok"),
|
|
147
|
+
execFile: vi.fn(
|
|
148
|
+
(
|
|
149
|
+
_cmd: string,
|
|
150
|
+
_args: string[],
|
|
151
|
+
_opts: unknown,
|
|
152
|
+
cb: (
|
|
153
|
+
err: Error | null,
|
|
154
|
+
result: { stdout: string; stderr: string },
|
|
155
|
+
) => void,
|
|
156
|
+
) => {
|
|
157
|
+
cb(null, { stdout: "ok", stderr: "" });
|
|
158
|
+
},
|
|
159
|
+
),
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const { createMempalacePlugin } =
|
|
163
|
+
await import("../plugins/mempalace/index.js");
|
|
164
|
+
const plugin = createMempalacePlugin({
|
|
165
|
+
pythonPath: "/venv/bin/python",
|
|
166
|
+
palacePath: "/data/new-palace",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await plugin.init!({});
|
|
170
|
+
expect(mkdirSyncMock).toHaveBeenCalledWith("/data/new-palace", {
|
|
171
|
+
recursive: true,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("validateConfig returns error when python binary exists but mempalace import fails with ENOENT", async () => {
|
|
176
|
+
vi.doMock("node:fs", () => ({
|
|
177
|
+
existsSync: vi.fn(() => true),
|
|
178
|
+
mkdirSync: vi.fn(),
|
|
179
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
180
|
+
}));
|
|
181
|
+
vi.doMock("node:child_process", () => ({
|
|
182
|
+
execFileSync: vi.fn(() => {
|
|
183
|
+
const err = new Error("spawn ENOENT") as Error & { code: string };
|
|
184
|
+
err.code = "ENOENT";
|
|
185
|
+
throw err;
|
|
186
|
+
}),
|
|
187
|
+
execFile: vi.fn(),
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
const { createMempalacePlugin } =
|
|
191
|
+
await import("../plugins/mempalace/index.js");
|
|
192
|
+
const plugin = createMempalacePlugin({
|
|
193
|
+
pythonPath: "/venv/bin/python",
|
|
194
|
+
palacePath: "/data/palace",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const errors = plugin.validateConfig!({});
|
|
198
|
+
expect(errors).toBeDefined();
|
|
199
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
200
|
+
expect(errors![0]).toContain("Cannot execute Python");
|
|
201
|
+
expect(errors![0]).toContain("ENOENT");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("getEnvVars returns MEMPALACE_PALACE_PATH", async () => {
|
|
205
|
+
vi.doMock("node:fs", () => ({
|
|
206
|
+
existsSync: vi.fn(() => true),
|
|
207
|
+
mkdirSync: vi.fn(),
|
|
208
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
209
|
+
}));
|
|
210
|
+
vi.doMock("node:child_process", () => ({
|
|
211
|
+
execFileSync: vi.fn(),
|
|
212
|
+
execFile: vi.fn(),
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
const { createMempalacePlugin } =
|
|
216
|
+
await import("../plugins/mempalace/index.js");
|
|
217
|
+
const plugin = createMempalacePlugin({
|
|
218
|
+
pythonPath: "/venv/bin/python",
|
|
219
|
+
palacePath: "/data/palace",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(plugin.getEnvVars!({})).toEqual({
|
|
223
|
+
MEMPALACE_PALACE_PATH: "/data/palace",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("getSystemPromptAddition loads from .md file and interpolates palacePath", async () => {
|
|
228
|
+
vi.doMock("node:fs", () => ({
|
|
229
|
+
existsSync: vi.fn(() => true),
|
|
230
|
+
mkdirSync: vi.fn(),
|
|
231
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
232
|
+
}));
|
|
233
|
+
vi.doMock("node:child_process", () => ({
|
|
234
|
+
execFileSync: vi.fn(),
|
|
235
|
+
execFile: vi.fn(),
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
const { createMempalacePlugin } =
|
|
239
|
+
await import("../plugins/mempalace/index.js");
|
|
240
|
+
const plugin = createMempalacePlugin({
|
|
241
|
+
pythonPath: "/venv/bin/python",
|
|
242
|
+
palacePath: "/custom/palace",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const addition = plugin.getSystemPromptAddition!({});
|
|
246
|
+
expect(addition).toContain("MemPalace");
|
|
247
|
+
expect(addition).toContain("mempalace_search");
|
|
248
|
+
expect(addition).toContain("mempalace_add_drawer");
|
|
249
|
+
expect(addition).toContain("mempalace_kg_query");
|
|
250
|
+
expect(addition).toContain("mempalace_kg_invalidate");
|
|
251
|
+
expect(addition).toContain("mempalace_kg_timeline");
|
|
252
|
+
expect(addition).toContain("mempalace_traverse");
|
|
253
|
+
expect(addition).toContain("mempalace_find_tunnels");
|
|
254
|
+
expect(addition).toContain("mempalace_diary_write");
|
|
255
|
+
expect(addition).toContain("mempalace_diary_read");
|
|
256
|
+
expect(addition).toContain("mempalace_delete_drawer");
|
|
257
|
+
expect(addition).toContain("Protocol");
|
|
258
|
+
expect(addition).toContain("/custom/palace");
|
|
259
|
+
// Verify interpolation happened — no raw placeholder
|
|
260
|
+
expect(addition).not.toContain("{{palacePath}}");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("getSystemPromptAddition returns fallback when .md file is missing", async () => {
|
|
264
|
+
vi.doMock("node:fs", () => ({
|
|
265
|
+
existsSync: vi.fn(() => true),
|
|
266
|
+
mkdirSync: vi.fn(),
|
|
267
|
+
readFileSync: vi.fn(() => {
|
|
268
|
+
throw new Error("ENOENT: no such file");
|
|
269
|
+
}),
|
|
270
|
+
}));
|
|
271
|
+
vi.doMock("node:child_process", () => ({
|
|
272
|
+
execFileSync: vi.fn(),
|
|
273
|
+
execFile: vi.fn(),
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
const { logWarn } = (await import("../util/log.js")) as unknown as {
|
|
277
|
+
logWarn: ReturnType<typeof vi.fn>;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const { createMempalacePlugin } =
|
|
281
|
+
await import("../plugins/mempalace/index.js");
|
|
282
|
+
const plugin = createMempalacePlugin({
|
|
283
|
+
pythonPath: "/venv/bin/python",
|
|
284
|
+
palacePath: "/data/palace",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const addition = plugin.getSystemPromptAddition!({});
|
|
288
|
+
expect(addition).toContain("MemPalace");
|
|
289
|
+
expect(addition).toContain("/data/palace");
|
|
290
|
+
expect(logWarn).toHaveBeenCalledWith(
|
|
291
|
+
"mempalace",
|
|
292
|
+
expect.stringContaining("Failed to load prompt"),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|