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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.1.0",
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.96",
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 have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep). Do NOT attempt to use any Telegram, MCP, or messaging tools.
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 4-stage task
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
- const { isValidTimezone } = await import("../storage/cron-store.js");
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
- await new Promise(() => {}); // never yields
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
- const { validateCronExpression } = await import("../storage/cron-store.js");
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
+ });