talon-agent 1.8.1 → 1.9.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/README.md +50 -37
- package/package.json +1 -2
- package/prompts/mempalace.md +3 -0
- package/src/__tests__/mcp-launcher-functional.test.ts +334 -0
- package/src/__tests__/mcp-launcher.test.ts +139 -0
- package/src/__tests__/mempalace-plugin.test.ts +56 -1
- package/src/__tests__/plugin.test.ts +8 -0
- package/src/backend/claude-sdk/options.ts +5 -4
- package/src/core/dream.ts +1 -1
- package/src/core/plugin.ts +13 -7
- package/src/plugins/mempalace/index.ts +34 -10
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +2 -7
- package/src/util/mcp-launcher.mjs +72 -0
- package/src/util/mcp-launcher.ts +58 -0
package/README.md
CHANGED
|
@@ -12,15 +12,15 @@ Multi-platform agentic AI harness powered by Claude. Runs on **Telegram**, **Tea
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
|
|
|
16
|
-
|
|
17
|
-
| **Multi-frontend**
|
|
18
|
-
| **Claude Agent SDK**
|
|
19
|
-
| **MCP tools**
|
|
20
|
-
| **Plugins**
|
|
21
|
-
| **Background agents** | Heartbeat (periodic maintenance) and Dream (memory consolidation + diary)
|
|
22
|
-
| **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard
|
|
23
|
-
| **Model registry**
|
|
15
|
+
| | |
|
|
16
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| **Multi-frontend** | Telegram (Grammy + GramJS userbot), Microsoft Teams (Bot Framework), Terminal with live tool visibility |
|
|
18
|
+
| **Claude Agent SDK** | Streaming responses, extended thinking, adaptive effort, 1M token context, dynamic model discovery |
|
|
19
|
+
| **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, stickers, file system, admin controls |
|
|
20
|
+
| **Plugins** | Hot-reloadable plugin system. Built-in: GitHub, MemPalace, Playwright, Brave Search |
|
|
21
|
+
| **Background agents** | Heartbeat (periodic maintenance) and Dream (memory consolidation + diary) |
|
|
22
|
+
| **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
|
|
23
|
+
| **Model registry** | Models discovered from the SDK at startup --- new models appear in all pickers automatically |
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -39,8 +39,10 @@ npx talon chat # terminal chat mode
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
**Prerequisites:**
|
|
42
|
+
|
|
42
43
|
- [Node.js 22+](https://nodejs.org/)
|
|
43
44
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` CLI on PATH)
|
|
45
|
+
- Talon runs from a normal source or package install; standalone compiled binaries are not supported.
|
|
44
46
|
|
|
45
47
|
---
|
|
46
48
|
|
|
@@ -161,9 +163,7 @@ Plugins add MCP tools and gateway actions without modifying core code. SOLID int
|
|
|
161
163
|
|
|
162
164
|
```json
|
|
163
165
|
{
|
|
164
|
-
"plugins": [
|
|
165
|
-
{ "path": "/path/to/my-plugin", "config": { "apiKey": "..." } }
|
|
166
|
-
]
|
|
166
|
+
"plugins": [{ "path": "/path/to/my-plugin", "config": { "apiKey": "..." } }]
|
|
167
167
|
}
|
|
168
168
|
```
|
|
169
169
|
|
|
@@ -172,12 +172,24 @@ export default {
|
|
|
172
172
|
name: "my-plugin",
|
|
173
173
|
version: "1.0.0",
|
|
174
174
|
mcpServerPath: resolve(import.meta.dirname, "tools.ts"),
|
|
175
|
-
validateConfig(config) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
validateConfig(config) {
|
|
176
|
+
/* return errors or undefined */
|
|
177
|
+
},
|
|
178
|
+
getEnvVars(config) {
|
|
179
|
+
return { MY_KEY: config.apiKey };
|
|
180
|
+
},
|
|
181
|
+
handleAction(body, chatId) {
|
|
182
|
+
/* gateway action handler */
|
|
183
|
+
},
|
|
184
|
+
getSystemPromptAddition(config) {
|
|
185
|
+
return "## My Plugin\n...";
|
|
186
|
+
},
|
|
187
|
+
init(config) {
|
|
188
|
+
/* one-time setup */
|
|
189
|
+
},
|
|
190
|
+
destroy() {
|
|
191
|
+
/* cleanup */
|
|
192
|
+
},
|
|
181
193
|
};
|
|
182
194
|
```
|
|
183
195
|
|
|
@@ -204,25 +216,25 @@ talon doctor Validate environment and dependencies
|
|
|
204
216
|
|
|
205
217
|
Config file: `~/.talon/config.json`
|
|
206
218
|
|
|
207
|
-
| Field
|
|
208
|
-
|
|
209
|
-
| `frontend`
|
|
210
|
-
| `backend`
|
|
211
|
-
| `botToken`
|
|
212
|
-
| `model`
|
|
213
|
-
| `concurrency`
|
|
214
|
-
| `pulse`
|
|
215
|
-
| `heartbeat`
|
|
216
|
-
| `heartbeatIntervalMinutes` | `60`
|
|
217
|
-
| `braveApiKey`
|
|
218
|
-
| `timezone`
|
|
219
|
-
| `plugins`
|
|
220
|
-
| `adminUserId`
|
|
221
|
-
| `allowedUsers`
|
|
222
|
-
| `apiId` / `apiHash`
|
|
223
|
-
| `github`
|
|
224
|
-
| `mempalace`
|
|
225
|
-
| `playwright`
|
|
219
|
+
| Field | Default | Description |
|
|
220
|
+
| -------------------------- | ------------ | ------------------------------------------------------------------- |
|
|
221
|
+
| `frontend` | `"telegram"` | `"telegram"`, `"terminal"`, `"teams"`, or an array |
|
|
222
|
+
| `backend` | `"claude"` | `"claude"` or `"opencode"` |
|
|
223
|
+
| `botToken` | --- | Telegram bot token |
|
|
224
|
+
| `model` | `"default"` | Default Claude model. Legacy `claude-*` aliases are still accepted. |
|
|
225
|
+
| `concurrency` | `1` | Max concurrent AI queries (1--20) |
|
|
226
|
+
| `pulse` | `true` | Periodic group engagement |
|
|
227
|
+
| `heartbeat` | `false` | Background maintenance agent |
|
|
228
|
+
| `heartbeatIntervalMinutes` | `60` | Heartbeat interval |
|
|
229
|
+
| `braveApiKey` | --- | Brave Search API key |
|
|
230
|
+
| `timezone` | --- | IANA timezone (e.g. `"Europe/London"`) |
|
|
231
|
+
| `plugins` | `[]` | External plugin packages |
|
|
232
|
+
| `adminUserId` | --- | Telegram user ID for `/admin` commands |
|
|
233
|
+
| `allowedUsers` | --- | Whitelist of Telegram user IDs |
|
|
234
|
+
| `apiId` / `apiHash` | --- | Telegram API credentials for full message history |
|
|
235
|
+
| `github` | --- | GitHub plugin config (see above) |
|
|
236
|
+
| `mempalace` | --- | MemPalace plugin config (see above) |
|
|
237
|
+
| `playwright` | --- | Playwright plugin config (see above) |
|
|
226
238
|
|
|
227
239
|
---
|
|
228
240
|
|
|
@@ -241,6 +253,7 @@ Commands: `/model`, `/effort`, `/reset`, `/status`, `/help`
|
|
|
241
253
|
## Production
|
|
242
254
|
|
|
243
255
|
**Docker:**
|
|
256
|
+
|
|
244
257
|
```bash
|
|
245
258
|
docker compose up -d
|
|
246
259
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"tsconfig.json"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build:binary": "bun build --compile --minify src/index.ts --outfile talon-bun",
|
|
40
39
|
"start": "tsx src/index.ts",
|
|
41
40
|
"cli": "tsx src/cli.ts",
|
|
42
41
|
"setup": "tsx src/cli.ts setup",
|
package/prompts/mempalace.md
CHANGED
|
@@ -53,5 +53,8 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
53
53
|
- The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
|
|
54
54
|
- Use `mempalace_check_duplicate` before storing new content to avoid clutter.
|
|
55
55
|
- Diary entries accumulate across sessions. Write them to build continuity of self.
|
|
56
|
+
- Entity detection runs per-language; results include `created_at` timestamps you can surface when the user asks "when did I last…".
|
|
56
57
|
|
|
57
58
|
### Palace location: `{{palacePath}}`
|
|
59
|
+
|
|
60
|
+
### Entity-detection languages: `{{entityLanguages}}`
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional tests for the MCP launcher supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Each test targets a distinct failure mode that would produce an orphaned
|
|
5
|
+
* MCP subprocess in production. All spawn real Node subprocesses and tear
|
|
6
|
+
* them down in afterEach — no mocks, no shortcuts. If these pass on Linux
|
|
7
|
+
* and macOS, the "launcher-wrapped spawns never orphan" claim holds.
|
|
8
|
+
*
|
|
9
|
+
* Cases kept:
|
|
10
|
+
* 1. SIGKILL of parent at scale (headline bug from PR #67).
|
|
11
|
+
* 2. Graceful stdin-close shutdown (normal Talon exit).
|
|
12
|
+
* 3. Stubborn child that ignores SIGTERM (validates SIGKILL fallback).
|
|
13
|
+
* 4. Supervised child exits on its own (launcher doesn't hang).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join, resolve, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
24
|
+
const LAUNCHER_MODULE = pathToFileURL(
|
|
25
|
+
resolve(REPO_ROOT, "src/util/mcp-launcher.ts"),
|
|
26
|
+
).href;
|
|
27
|
+
const TSX_IMPORT = pathToFileURL(
|
|
28
|
+
resolve(REPO_ROOT, "node_modules/tsx/dist/esm/index.mjs"),
|
|
29
|
+
).href;
|
|
30
|
+
const FUNCTIONAL_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function pidAlive(pid: number): boolean {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
40
|
+
if (code === "ESRCH") return false;
|
|
41
|
+
// EPERM: exists but unreachable. Count as alive.
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitForPidGone(
|
|
47
|
+
pid: number,
|
|
48
|
+
timeoutMs: number,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (!pidAlive(pid)) return true;
|
|
53
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
54
|
+
}
|
|
55
|
+
return !pidAlive(pid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function assertAllGone(pids: number[], timeoutMs: number): Promise<void> {
|
|
59
|
+
const stuck: number[] = [];
|
|
60
|
+
for (const pid of pids) {
|
|
61
|
+
if (!(await waitForPidGone(pid, timeoutMs))) stuck.push(pid);
|
|
62
|
+
}
|
|
63
|
+
if (stuck.length > 0) {
|
|
64
|
+
// Clean up the leak so it doesn't poison sibling tests.
|
|
65
|
+
for (const pid of stuck) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, "SIGKILL");
|
|
68
|
+
} catch {
|
|
69
|
+
/* ok */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`orphaned pids after teardown: ${stuck.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read the first stdout line matching `match`, with a timeout. */
|
|
77
|
+
async function readMarker(
|
|
78
|
+
child: ChildProcess,
|
|
79
|
+
match: RegExp,
|
|
80
|
+
timeoutMs: number,
|
|
81
|
+
label: string,
|
|
82
|
+
): Promise<RegExpMatchArray> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(
|
|
85
|
+
() => reject(new Error(`timeout waiting for ${label}`)),
|
|
86
|
+
timeoutMs,
|
|
87
|
+
);
|
|
88
|
+
let buf = "";
|
|
89
|
+
const onData = (d: Buffer) => {
|
|
90
|
+
buf += d.toString();
|
|
91
|
+
const m = buf.match(match);
|
|
92
|
+
if (m) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
child.stdout!.off("data", onData);
|
|
95
|
+
resolve(m);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
child.stdout!.on("data", onData);
|
|
99
|
+
child.once("exit", (code) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(new Error(`process exited before ${label} (code=${code})`));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type IdlerOpts = {
|
|
107
|
+
name?: string;
|
|
108
|
+
/** If false, the idler keeps running even after its stdin closes. */
|
|
109
|
+
exitOnStdinClose?: boolean;
|
|
110
|
+
/** If true, the idler ignores SIGTERM (forces SIGKILL cleanup path). */
|
|
111
|
+
ignoreSigterm?: boolean;
|
|
112
|
+
/** If set, the idler exits on its own this many ms after starting. */
|
|
113
|
+
selfExitAfterMs?: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function writeIdler(dir: string, opts: IdlerOpts = {}): string {
|
|
117
|
+
const path = join(dir, opts.name ?? "idler.mjs");
|
|
118
|
+
const exitOnStdin = opts.exitOnStdinClose !== false;
|
|
119
|
+
const ignoreTerm = opts.ignoreSigterm === true;
|
|
120
|
+
const selfExit = opts.selfExitAfterMs;
|
|
121
|
+
writeFileSync(
|
|
122
|
+
path,
|
|
123
|
+
`
|
|
124
|
+
process.stderr.write("IDLER_PID=" + process.pid + "\\n");
|
|
125
|
+
${exitOnStdin ? 'process.stdin.on("end", () => process.exit(0));' : ""}
|
|
126
|
+
process.on("SIGTERM", () => { ${
|
|
127
|
+
ignoreTerm ? "/* stubborn: ignore */" : "process.exit(0);"
|
|
128
|
+
} });
|
|
129
|
+
process.on("SIGINT", () => process.exit(0));
|
|
130
|
+
process.stdin.resume();
|
|
131
|
+
${selfExit !== undefined ? `setTimeout(() => process.exit(0), ${selfExit});` : ""}
|
|
132
|
+
setInterval(() => {}, 1 << 30);
|
|
133
|
+
`,
|
|
134
|
+
);
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type HarnessResult = { harness: ChildProcess; pids: number[] };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spawn a harness that uses the real `wrapMcpServer()` to supervise
|
|
142
|
+
* `count` idlers. Resolves once every idler has reported its PID, returning
|
|
143
|
+
* [launcher PIDs..., idler PIDs...] (length = count * 2).
|
|
144
|
+
*/
|
|
145
|
+
async function spawnHarness(opts: {
|
|
146
|
+
workDir: string;
|
|
147
|
+
count: number;
|
|
148
|
+
idlerPath: string;
|
|
149
|
+
}): Promise<HarnessResult> {
|
|
150
|
+
const { workDir, count, idlerPath } = opts;
|
|
151
|
+
const harnessPath = join(workDir, "harness.mjs");
|
|
152
|
+
writeFileSync(
|
|
153
|
+
harnessPath,
|
|
154
|
+
`
|
|
155
|
+
import { spawn } from "node:child_process";
|
|
156
|
+
import { wrapMcpServer } from ${JSON.stringify(LAUNCHER_MODULE)};
|
|
157
|
+
|
|
158
|
+
const launchers = [];
|
|
159
|
+
const idlers = [];
|
|
160
|
+
const TARGET = ${count};
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < TARGET; i++) {
|
|
163
|
+
const cfg = wrapMcpServer({
|
|
164
|
+
command: "node",
|
|
165
|
+
args: [${JSON.stringify(idlerPath)}],
|
|
166
|
+
env: {},
|
|
167
|
+
});
|
|
168
|
+
const c = spawn(cfg.command, cfg.args, {
|
|
169
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
170
|
+
env: { ...process.env, ...cfg.env },
|
|
171
|
+
});
|
|
172
|
+
launchers.push(c);
|
|
173
|
+
|
|
174
|
+
let buf = "";
|
|
175
|
+
c.stderr.on("data", (d) => {
|
|
176
|
+
buf += d.toString();
|
|
177
|
+
const m = buf.match(/IDLER_PID=(\\d+)/);
|
|
178
|
+
if (m) {
|
|
179
|
+
idlers.push(parseInt(m[1], 10));
|
|
180
|
+
buf = buf.replace(/IDLER_PID=\\d+\\n?/, "");
|
|
181
|
+
if (idlers.length === TARGET) {
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
"PIDS=" + launchers.map((c) => c.pid).concat(idlers).join(",") + "\\n",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
process.stdin.on("end", () => process.exit(0));
|
|
191
|
+
process.stdin.resume();
|
|
192
|
+
`,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const harness = spawn("node", ["--import", TSX_IMPORT, harnessPath], {
|
|
196
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
197
|
+
env: { ...process.env, HOME: workDir },
|
|
198
|
+
});
|
|
199
|
+
const marker = await readMarker(
|
|
200
|
+
harness,
|
|
201
|
+
/PIDS=([\d,]+)/,
|
|
202
|
+
15_000,
|
|
203
|
+
"harness PID marker",
|
|
204
|
+
);
|
|
205
|
+
const pids = marker[1].split(",").map((s) => parseInt(s, 10));
|
|
206
|
+
if (pids.length !== count * 2) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`harness reported ${pids.length} pids, expected ${count * 2}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { harness, pids };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe("launcher functional: no orphaned MCP processes", () => {
|
|
217
|
+
let workDir: string;
|
|
218
|
+
const cleanup: Array<() => void> = [];
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
workDir = mkdtempSync(join(tmpdir(), "talon-launcher-fn-"));
|
|
222
|
+
process.env.HOME = workDir; // paths.ts reads homedir() → this
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
for (const fn of cleanup.splice(0)) {
|
|
227
|
+
try {
|
|
228
|
+
fn();
|
|
229
|
+
} catch {
|
|
230
|
+
/* ok */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
235
|
+
} catch {
|
|
236
|
+
/* ok */
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
function track(child: ChildProcess): void {
|
|
241
|
+
cleanup.push(() => {
|
|
242
|
+
if (!child.killed) {
|
|
243
|
+
try {
|
|
244
|
+
child.kill("SIGKILL");
|
|
245
|
+
} catch {
|
|
246
|
+
/* ok */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it(
|
|
253
|
+
"SIGKILL of parent cleans up every descendant (10 wrapped children)",
|
|
254
|
+
async () => {
|
|
255
|
+
const idler = writeIdler(workDir);
|
|
256
|
+
const { harness, pids } = await spawnHarness({
|
|
257
|
+
workDir,
|
|
258
|
+
count: 10,
|
|
259
|
+
idlerPath: idler,
|
|
260
|
+
});
|
|
261
|
+
track(harness);
|
|
262
|
+
expect(pids).toHaveLength(20); // 10 launchers + 10 idlers
|
|
263
|
+
|
|
264
|
+
harness.kill("SIGKILL");
|
|
265
|
+
await assertAllGone(pids, 5_000);
|
|
266
|
+
},
|
|
267
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
it(
|
|
271
|
+
"graceful shutdown (stdin close) cleans up every descendant",
|
|
272
|
+
async () => {
|
|
273
|
+
const idler = writeIdler(workDir);
|
|
274
|
+
const { harness, pids } = await spawnHarness({
|
|
275
|
+
workDir,
|
|
276
|
+
count: 3,
|
|
277
|
+
idlerPath: idler,
|
|
278
|
+
});
|
|
279
|
+
track(harness);
|
|
280
|
+
|
|
281
|
+
harness.stdin!.end();
|
|
282
|
+
const exitCode = await new Promise<number | null>((r) =>
|
|
283
|
+
harness.on("exit", (c) => r(c)),
|
|
284
|
+
);
|
|
285
|
+
expect(exitCode).toBe(0);
|
|
286
|
+
await assertAllGone(pids, 5_000);
|
|
287
|
+
},
|
|
288
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
it(
|
|
292
|
+
"SIGKILLs stubborn children that ignore SIGTERM",
|
|
293
|
+
async () => {
|
|
294
|
+
const stubborn = writeIdler(workDir, {
|
|
295
|
+
name: "stubborn.mjs",
|
|
296
|
+
ignoreSigterm: true,
|
|
297
|
+
exitOnStdinClose: false,
|
|
298
|
+
});
|
|
299
|
+
const { harness, pids } = await spawnHarness({
|
|
300
|
+
workDir,
|
|
301
|
+
count: 2,
|
|
302
|
+
idlerPath: stubborn,
|
|
303
|
+
});
|
|
304
|
+
track(harness);
|
|
305
|
+
|
|
306
|
+
harness.kill("SIGKILL");
|
|
307
|
+
// Launcher: SIGTERM → 1s grace → SIGKILL. Give 4s headroom.
|
|
308
|
+
await assertAllGone(pids, 4_000);
|
|
309
|
+
},
|
|
310
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
it(
|
|
314
|
+
"launcher exits when its supervised child exits on its own",
|
|
315
|
+
async () => {
|
|
316
|
+
const oneShot = writeIdler(workDir, {
|
|
317
|
+
name: "one-shot.mjs",
|
|
318
|
+
selfExitAfterMs: 200,
|
|
319
|
+
});
|
|
320
|
+
const { harness, pids } = await spawnHarness({
|
|
321
|
+
workDir,
|
|
322
|
+
count: 1,
|
|
323
|
+
idlerPath: oneShot,
|
|
324
|
+
});
|
|
325
|
+
track(harness);
|
|
326
|
+
|
|
327
|
+
// Both launcher (pids[0]) and idler (pids[1]) must be gone within seconds
|
|
328
|
+
// even though the harness itself is still running.
|
|
329
|
+
await assertAllGone(pids, 5_000);
|
|
330
|
+
expect(pidAlive(harness.pid!)).toBe(true);
|
|
331
|
+
},
|
|
332
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
333
|
+
);
|
|
334
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launcher tests.
|
|
3
|
+
*
|
|
4
|
+
* Unit-level: wrapMcpServer + ensureLauncher path resolution.
|
|
5
|
+
* Integration: spawn the real launcher.mjs, drive it with a dummy child,
|
|
6
|
+
* close its stdin, verify it terminates the child and exits cleanly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { resolve, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
16
|
+
const CHECKED_IN_LAUNCHER = resolve(REPO_ROOT, "src/util/mcp-launcher.mjs");
|
|
17
|
+
|
|
18
|
+
describe("mcp-launcher", () => {
|
|
19
|
+
it("wrapMcpServer rewrites command/args to go through node launcher", async () => {
|
|
20
|
+
const { wrapMcpServer } = await freshLauncher();
|
|
21
|
+
const wrapped = wrapMcpServer({
|
|
22
|
+
command: "/usr/bin/python",
|
|
23
|
+
args: ["-m", "mempalace.mcp_server"],
|
|
24
|
+
env: { FOO: "bar" },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(wrapped.command).toBe("node");
|
|
28
|
+
expect(wrapped.args[0]).toMatch(/mcp-launcher\.mjs$/);
|
|
29
|
+
expect(wrapped.args.slice(1)).toEqual([
|
|
30
|
+
"/usr/bin/python",
|
|
31
|
+
"-m",
|
|
32
|
+
"mempalace.mcp_server",
|
|
33
|
+
]);
|
|
34
|
+
expect(wrapped.env).toEqual({ FOO: "bar" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("ensureLauncher returns the checked-in launcher script", async () => {
|
|
38
|
+
const { ensureLauncher } = await freshLauncher();
|
|
39
|
+
const path = ensureLauncher();
|
|
40
|
+
|
|
41
|
+
expect(path).toBe(CHECKED_IN_LAUNCHER);
|
|
42
|
+
expect(existsSync(path)).toBe(true);
|
|
43
|
+
const content = readFileSync(path, "utf-8");
|
|
44
|
+
expect(content).toContain("Supervises an MCP stdio child");
|
|
45
|
+
expect(content).toContain("spawn(cmd, args");
|
|
46
|
+
expect(content).toContain('process.stdin.on("end"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── Integration: real launcher + real child ────────────────────────────
|
|
50
|
+
|
|
51
|
+
it("terminates the supervised child when parent stdin closes", async () => {
|
|
52
|
+
const { ensureLauncher } = await freshLauncher();
|
|
53
|
+
const launcherPath = ensureLauncher();
|
|
54
|
+
|
|
55
|
+
// Dummy child: a Node one-liner that keeps stdin open forever and prints
|
|
56
|
+
// a ready marker so the parent can detect it has spawned. It will only
|
|
57
|
+
// exit when its stdin closes (which happens when the launcher dies).
|
|
58
|
+
const childScript = `
|
|
59
|
+
process.stdout.write("READY\\n");
|
|
60
|
+
process.stdin.on("data", (d) => process.stdout.write(d));
|
|
61
|
+
process.stdin.on("end", () => process.exit(0));
|
|
62
|
+
process.stdin.resume();
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const launcher = spawn("node", [launcherPath, "node", "-e", childScript], {
|
|
66
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Wait for READY to confirm stdio proxy is up
|
|
70
|
+
await new Promise<void>((resolve, reject) => {
|
|
71
|
+
const timeout = setTimeout(
|
|
72
|
+
() => reject(new Error("timeout waiting for READY")),
|
|
73
|
+
5000,
|
|
74
|
+
);
|
|
75
|
+
launcher.stdout!.on("data", (chunk) => {
|
|
76
|
+
if (chunk.toString().includes("READY")) {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
launcher.on("exit", () => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
reject(new Error("launcher exited before READY"));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Close our write-end → launcher sees stdin EOF → it SIGTERMs the child
|
|
88
|
+
// → launcher exits.
|
|
89
|
+
launcher.stdin!.end();
|
|
90
|
+
|
|
91
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
92
|
+
launcher.on("exit", (code) => resolve(code));
|
|
93
|
+
});
|
|
94
|
+
expect(exitCode).toBe(0);
|
|
95
|
+
}, 10_000);
|
|
96
|
+
|
|
97
|
+
it("proxies stdin→stdout verbatim while the child is alive", async () => {
|
|
98
|
+
const { ensureLauncher } = await freshLauncher();
|
|
99
|
+
const launcherPath = ensureLauncher();
|
|
100
|
+
|
|
101
|
+
// Echo-style child.
|
|
102
|
+
const childScript = `
|
|
103
|
+
process.stdin.on("data", (d) => process.stdout.write(d));
|
|
104
|
+
process.stdin.resume();
|
|
105
|
+
`;
|
|
106
|
+
const launcher = spawn("node", [launcherPath, "node", "-e", childScript], {
|
|
107
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const output: Buffer[] = [];
|
|
111
|
+
launcher.stdout!.on("data", (c) => output.push(c));
|
|
112
|
+
launcher.stdin!.write("hello\n");
|
|
113
|
+
|
|
114
|
+
await new Promise<void>((resolve, reject) => {
|
|
115
|
+
const timeout = setTimeout(() => reject(new Error("echo timeout")), 5000);
|
|
116
|
+
const check = setInterval(() => {
|
|
117
|
+
if (Buffer.concat(output).toString().includes("hello")) {
|
|
118
|
+
clearInterval(check);
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
resolve();
|
|
121
|
+
}
|
|
122
|
+
}, 50);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
launcher.stdin!.end();
|
|
126
|
+
await new Promise((r) => launcher.on("exit", r));
|
|
127
|
+
expect(Buffer.concat(output).toString()).toContain("hello");
|
|
128
|
+
}, 10_000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Import the launcher module fresh so mocks and module state never leak
|
|
133
|
+
* between tests.
|
|
134
|
+
*/
|
|
135
|
+
async function freshLauncher() {
|
|
136
|
+
const { vi } = await import("vitest");
|
|
137
|
+
vi.resetModules();
|
|
138
|
+
return import("../util/mcp-launcher.js");
|
|
139
|
+
}
|
|
@@ -15,6 +15,7 @@ mempalace_diary_write mempalace_diary_read mempalace_delete_drawer
|
|
|
15
15
|
Protocol
|
|
16
16
|
|
|
17
17
|
### Palace location: \`{{palacePath}}\`
|
|
18
|
+
### Entity-detection languages: \`{{entityLanguages}}\`
|
|
18
19
|
`;
|
|
19
20
|
|
|
20
21
|
describe("mempalace plugin", () => {
|
|
@@ -54,7 +55,7 @@ describe("mempalace plugin", () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
expect(plugin.name).toBe("mempalace");
|
|
57
|
-
expect(plugin.version).toBe("1.
|
|
58
|
+
expect(plugin.version).toBe("1.1.0");
|
|
58
59
|
expect(plugin.mcpServer).toEqual({
|
|
59
60
|
command: "/venv/bin/python",
|
|
60
61
|
args: ["-m", "mempalace.mcp_server", "--palace", "/data/palace"],
|
|
@@ -224,6 +225,33 @@ describe("mempalace plugin", () => {
|
|
|
224
225
|
});
|
|
225
226
|
});
|
|
226
227
|
|
|
228
|
+
it("getEnvVars includes MEMPALACE_ENTITY_LANGUAGES and MEMPAL_VERBOSE when configured", async () => {
|
|
229
|
+
vi.doMock("node:fs", () => ({
|
|
230
|
+
existsSync: vi.fn(() => true),
|
|
231
|
+
mkdirSync: vi.fn(),
|
|
232
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
233
|
+
}));
|
|
234
|
+
vi.doMock("node:child_process", () => ({
|
|
235
|
+
execFileSync: vi.fn(),
|
|
236
|
+
execFile: vi.fn(),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const { createMempalacePlugin } =
|
|
240
|
+
await import("../plugins/mempalace/index.js");
|
|
241
|
+
const plugin = createMempalacePlugin({
|
|
242
|
+
pythonPath: "/venv/bin/python",
|
|
243
|
+
palacePath: "/data/palace",
|
|
244
|
+
entityLanguages: ["en", "ja", "fr"],
|
|
245
|
+
verbose: true,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(plugin.getEnvVars!({})).toEqual({
|
|
249
|
+
MEMPALACE_PALACE_PATH: "/data/palace",
|
|
250
|
+
MEMPALACE_ENTITY_LANGUAGES: "en,ja,fr",
|
|
251
|
+
MEMPAL_VERBOSE: "1",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
227
255
|
it("getSystemPromptAddition loads from .md file and interpolates palacePath", async () => {
|
|
228
256
|
vi.doMock("node:fs", () => ({
|
|
229
257
|
existsSync: vi.fn(() => true),
|
|
@@ -256,8 +284,35 @@ describe("mempalace plugin", () => {
|
|
|
256
284
|
expect(addition).toContain("mempalace_delete_drawer");
|
|
257
285
|
expect(addition).toContain("Protocol");
|
|
258
286
|
expect(addition).toContain("/custom/palace");
|
|
287
|
+
// Default entity-language interpolation
|
|
288
|
+
expect(addition).toContain("en (default)");
|
|
259
289
|
// Verify interpolation happened — no raw placeholder
|
|
260
290
|
expect(addition).not.toContain("{{palacePath}}");
|
|
291
|
+
expect(addition).not.toContain("{{entityLanguages}}");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("getSystemPromptAddition interpolates configured entity languages", async () => {
|
|
295
|
+
vi.doMock("node:fs", () => ({
|
|
296
|
+
existsSync: vi.fn(() => true),
|
|
297
|
+
mkdirSync: vi.fn(),
|
|
298
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
299
|
+
}));
|
|
300
|
+
vi.doMock("node:child_process", () => ({
|
|
301
|
+
execFileSync: vi.fn(),
|
|
302
|
+
execFile: vi.fn(),
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
const { createMempalacePlugin } =
|
|
306
|
+
await import("../plugins/mempalace/index.js");
|
|
307
|
+
const plugin = createMempalacePlugin({
|
|
308
|
+
pythonPath: "/venv/bin/python",
|
|
309
|
+
palacePath: "/custom/palace",
|
|
310
|
+
entityLanguages: ["en", "ja"],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const addition = plugin.getSystemPromptAddition!({});
|
|
314
|
+
expect(addition).toContain("en, ja");
|
|
315
|
+
expect(addition).not.toContain("{{entityLanguages}}");
|
|
261
316
|
});
|
|
262
317
|
|
|
263
318
|
it("getSystemPromptAddition returns fallback when .md file is missing", async () => {
|
|
@@ -8,6 +8,14 @@ vi.mock("../util/log.js", () => ({
|
|
|
8
8
|
logDebug: vi.fn(),
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
|
+
// Identity-wrap so existing assertions can match the raw MCP server shape
|
|
12
|
+
// without the launcher prefix. Dedicated wrap behavior is covered in
|
|
13
|
+
// mcp-launcher.test.ts.
|
|
14
|
+
vi.mock("../util/mcp-launcher.js", () => ({
|
|
15
|
+
wrapMcpServer: <T>(server: T) => server,
|
|
16
|
+
ensureLauncher: () => "/tmp/fake-launcher.mjs",
|
|
17
|
+
}));
|
|
18
|
+
|
|
11
19
|
describe("plugin system", () => {
|
|
12
20
|
beforeEach(() => {
|
|
13
21
|
vi.resetModules();
|
|
@@ -11,6 +11,7 @@ import { getSession } from "../../storage/sessions.js";
|
|
|
11
11
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
12
12
|
import { getPluginMcpServers } from "../../core/plugin.js";
|
|
13
13
|
import { resolveModelId } from "../../core/models.js";
|
|
14
|
+
import { wrapMcpServer } from "../../util/mcp-launcher.js";
|
|
14
15
|
import { getConfig, getBridgePort } from "./state.js";
|
|
15
16
|
import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
|
|
16
17
|
|
|
@@ -64,26 +65,26 @@ export function buildMcpServers(
|
|
|
64
65
|
TALON_CHAT_ID: chatId,
|
|
65
66
|
TALON_FRONTEND: frontend,
|
|
66
67
|
};
|
|
67
|
-
servers[serverName] = {
|
|
68
|
+
servers[serverName] = wrapMcpServer({
|
|
68
69
|
command: process.platform === "win32" ? "npx" : "node",
|
|
69
70
|
args:
|
|
70
71
|
process.platform === "win32"
|
|
71
72
|
? ["tsx", mcpServerPath]
|
|
72
73
|
: ["--import", tsxImport, mcpServerPath],
|
|
73
74
|
env: mcpEnv,
|
|
74
|
-
};
|
|
75
|
+
});
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
// Brave Search MCP server (if configured)
|
|
78
79
|
if (config.braveApiKey) {
|
|
79
|
-
servers["brave-search"] = {
|
|
80
|
+
servers["brave-search"] = wrapMcpServer({
|
|
80
81
|
command: resolve(
|
|
81
82
|
import.meta.dirname ?? ".",
|
|
82
83
|
"../../../node_modules/.bin/brave-search-mcp-server",
|
|
83
84
|
),
|
|
84
85
|
args: [],
|
|
85
86
|
env: { BRAVE_API_KEY: config.braveApiKey },
|
|
86
|
-
};
|
|
87
|
+
});
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
return servers;
|
package/src/core/dream.ts
CHANGED
|
@@ -149,7 +149,7 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
|
|
|
149
149
|
Run this command using the Bash tool:
|
|
150
150
|
|
|
151
151
|
\`\`\`bash
|
|
152
|
-
'${configRef.mempalace.pythonPath.replace(/'/g, "'\\''")}' -m mempalace mine '${dirs.dailyMemory.replace(/'/g, "'\\''")}' --palace '${configRef.mempalace.palacePath.replace(/'/g, "'\\''")}' --mode convos --wing daily-notes
|
|
152
|
+
'${configRef.mempalace.pythonPath.replace(/'/g, "'\\''")}' -m mempalace mine '${dirs.dailyMemory.replace(/'/g, "'\\''")}' --palace '${configRef.mempalace.palacePath.replace(/'/g, "'\\''")}' --mode convos --wing daily-notes --agent talon
|
|
153
153
|
\`\`\`
|
|
154
154
|
|
|
155
155
|
Then write a personal diary entry. This is YOUR journal — not a status report. Reflect on:
|
package/src/core/plugin.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { resolve } from "node:path";
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
19
|
import { log, logError, logWarn } from "../util/log.js";
|
|
20
|
+
import { wrapMcpServer } from "../util/mcp-launcher.js";
|
|
20
21
|
import type { ActionResult } from "./types.js";
|
|
21
22
|
import type { TalonConfig } from "../util/config.js";
|
|
22
23
|
|
|
@@ -524,7 +525,12 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
|
|
|
524
525
|
const { dirs, files: pf } = await import("../util/paths.js");
|
|
525
526
|
const pythonPath = mempalace.pythonPath ?? pf.mempalacePython;
|
|
526
527
|
const palacePath = mempalace.palacePath ?? dirs.palace;
|
|
527
|
-
const mp = createMempalacePlugin({
|
|
528
|
+
const mp = createMempalacePlugin({
|
|
529
|
+
pythonPath,
|
|
530
|
+
palacePath,
|
|
531
|
+
entityLanguages: mempalace.entityLanguages,
|
|
532
|
+
verbose: mempalace.verbose,
|
|
533
|
+
});
|
|
528
534
|
const mpConfig = mempalace as unknown as Record<string, unknown>;
|
|
529
535
|
const loaded = registerPlugin(mp, mpConfig);
|
|
530
536
|
if (loaded) {
|
|
@@ -738,32 +744,32 @@ export function getPluginMcpServers(
|
|
|
738
744
|
|
|
739
745
|
if (plugin.mcpServer) {
|
|
740
746
|
// Custom command/args (Python, Go, etc.) — no tsx wrapper
|
|
741
|
-
servers[`${plugin.name}-tools`] = {
|
|
747
|
+
servers[`${plugin.name}-tools`] = wrapMcpServer({
|
|
742
748
|
command: plugin.mcpServer.command,
|
|
743
749
|
args: [...plugin.mcpServer.args],
|
|
744
750
|
env: baseEnv,
|
|
745
|
-
};
|
|
751
|
+
});
|
|
746
752
|
} else if (plugin.mcpServerPath) {
|
|
747
753
|
// Existing Node/tsx pattern
|
|
748
|
-
servers[`${plugin.name}-tools`] = {
|
|
754
|
+
servers[`${plugin.name}-tools`] = wrapMcpServer({
|
|
749
755
|
command: process.platform === "win32" ? "npx" : "node",
|
|
750
756
|
args:
|
|
751
757
|
process.platform === "win32"
|
|
752
758
|
? ["tsx", plugin.mcpServerPath]
|
|
753
759
|
: ["--import", tsxPath, plugin.mcpServerPath],
|
|
754
760
|
env: baseEnv,
|
|
755
|
-
};
|
|
761
|
+
});
|
|
756
762
|
}
|
|
757
763
|
}
|
|
758
764
|
|
|
759
765
|
// Include standalone MCP server entries from config
|
|
760
766
|
for (const entry of registry.mcpEntries) {
|
|
761
767
|
if (only !== undefined && !only.includes(entry.name)) continue;
|
|
762
|
-
servers[`${entry.name}-tools`] = {
|
|
768
|
+
servers[`${entry.name}-tools`] = wrapMcpServer({
|
|
763
769
|
command: entry.command,
|
|
764
770
|
args: [...(entry.args ?? [])],
|
|
765
771
|
env: buildBridgeEnv(bridgeUrl, chatId, entry.env),
|
|
766
|
-
};
|
|
772
|
+
});
|
|
767
773
|
}
|
|
768
774
|
|
|
769
775
|
return servers;
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* Configuration in ~/.talon/config.json:
|
|
8
8
|
* "mempalace": {
|
|
9
9
|
* "enabled": true,
|
|
10
|
-
* "palacePath": "/path/to/palace",
|
|
11
|
-
* "pythonPath": "/path/to/python"
|
|
10
|
+
* "palacePath": "/path/to/palace", // optional, defaults to ~/.talon/workspace/palace/
|
|
11
|
+
* "pythonPath": "/path/to/python", // optional, defaults to mempalace venv python (bin/python on Unix, Scripts/python.exe on Windows)
|
|
12
|
+
* "entityLanguages": ["en", "ja"], // optional, BCP 47 codes (mempalace >= 3.3)
|
|
13
|
+
* "verbose": false // optional, enables MEMPAL_VERBOSE diagnostics
|
|
12
14
|
* }
|
|
13
15
|
*/
|
|
14
16
|
|
|
@@ -32,14 +34,28 @@ const PROMPT_PATH = resolve(dirs.prompts, "mempalace.md");
|
|
|
32
34
|
export function createMempalacePlugin(config: {
|
|
33
35
|
pythonPath: string;
|
|
34
36
|
palacePath: string;
|
|
37
|
+
/** BCP 47 codes passed via MEMPALACE_ENTITY_LANGUAGES (mempalace >= 3.3). */
|
|
38
|
+
entityLanguages?: readonly string[];
|
|
39
|
+
/** When true, sets MEMPAL_VERBOSE=1 so the MCP server logs diagnostic diaries. */
|
|
40
|
+
verbose?: boolean;
|
|
35
41
|
}): TalonPlugin {
|
|
36
|
-
const { pythonPath, palacePath } = config;
|
|
42
|
+
const { pythonPath, palacePath, entityLanguages, verbose } = config;
|
|
43
|
+
|
|
44
|
+
const envVars: Record<string, string> = {
|
|
45
|
+
MEMPALACE_PALACE_PATH: palacePath,
|
|
46
|
+
};
|
|
47
|
+
if (entityLanguages && entityLanguages.length > 0) {
|
|
48
|
+
envVars.MEMPALACE_ENTITY_LANGUAGES = entityLanguages.join(",");
|
|
49
|
+
}
|
|
50
|
+
if (verbose) {
|
|
51
|
+
envVars.MEMPAL_VERBOSE = "1";
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
return {
|
|
39
55
|
name: "mempalace",
|
|
40
56
|
description:
|
|
41
57
|
"Memory palace — structured long-term memory with vector search",
|
|
42
|
-
version: "1.
|
|
58
|
+
version: "1.1.0",
|
|
43
59
|
|
|
44
60
|
mcpServer: {
|
|
45
61
|
command: pythonPath,
|
|
@@ -122,25 +138,33 @@ export function createMempalacePlugin(config: {
|
|
|
122
138
|
);
|
|
123
139
|
}
|
|
124
140
|
|
|
125
|
-
|
|
141
|
+
const langSuffix =
|
|
142
|
+
entityLanguages && entityLanguages.length > 0
|
|
143
|
+
? ` (languages: ${entityLanguages.join(",")})`
|
|
144
|
+
: "";
|
|
145
|
+
log("mempalace", `Ready (palace: ${palacePath})${langSuffix}`);
|
|
126
146
|
},
|
|
127
147
|
|
|
128
148
|
getEnvVars() {
|
|
129
|
-
return {
|
|
130
|
-
MEMPALACE_PALACE_PATH: palacePath,
|
|
131
|
-
};
|
|
149
|
+
return { ...envVars };
|
|
132
150
|
},
|
|
133
151
|
|
|
134
152
|
getSystemPromptAddition() {
|
|
153
|
+
const languagesLine =
|
|
154
|
+
entityLanguages && entityLanguages.length > 0
|
|
155
|
+
? entityLanguages.join(", ")
|
|
156
|
+
: "en (default)";
|
|
135
157
|
try {
|
|
136
158
|
const template = readFileSync(PROMPT_PATH, "utf-8");
|
|
137
|
-
return template
|
|
159
|
+
return template
|
|
160
|
+
.replace(/\{\{palacePath\}\}/g, palacePath)
|
|
161
|
+
.replace(/\{\{entityLanguages\}\}/g, languagesLine);
|
|
138
162
|
} catch (err) {
|
|
139
163
|
logWarn(
|
|
140
164
|
"mempalace",
|
|
141
165
|
`Failed to load prompt from ${PROMPT_PATH}: ${err instanceof Error ? err.message : err}`,
|
|
142
166
|
);
|
|
143
|
-
return `## MemPalace — Long-term Memory\n\nPalace location: \`${palacePath}
|
|
167
|
+
return `## MemPalace — Long-term Memory\n\nPalace location: \`${palacePath}\`\nEntity languages: ${languagesLine}`;
|
|
144
168
|
}
|
|
145
169
|
},
|
|
146
170
|
};
|
package/src/util/config.ts
CHANGED
|
@@ -151,6 +151,14 @@ const configSchema = z.object({
|
|
|
151
151
|
palacePath: z.string().min(1).optional(),
|
|
152
152
|
/** Python binary path (default: ~/.talon/mempalace-venv/bin/python) */
|
|
153
153
|
pythonPath: z.string().min(1).optional(),
|
|
154
|
+
/**
|
|
155
|
+
* BCP 47 language codes for entity detection (mempalace >= 3.3).
|
|
156
|
+
* Supported: en, es, fr, de, ja, ko, zh-CN, zh-TW, pt-br, ru, it, hi, id.
|
|
157
|
+
* Sets MEMPALACE_ENTITY_LANGUAGES for the MCP server.
|
|
158
|
+
*/
|
|
159
|
+
entityLanguages: z.array(z.string().min(2)).nonempty().optional(),
|
|
160
|
+
/** Enable mempalace diagnostic diaries (sets MEMPAL_VERBOSE=1). */
|
|
161
|
+
verbose: z.boolean().optional(),
|
|
154
162
|
})
|
|
155
163
|
.optional(),
|
|
156
164
|
|
package/src/util/log.ts
CHANGED
|
@@ -73,11 +73,6 @@ try {
|
|
|
73
73
|
/* ignore */
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// Detect if running as a bun compiled binary (pino-pretty can't be bundled).
|
|
77
|
-
// import.meta.path is Bun-specific — undefined in Node.js/Vitest, so guard with ?.
|
|
78
|
-
const isBunBinary =
|
|
79
|
-
(import.meta as { path?: string }).path?.startsWith("/$bunfs/") ?? false;
|
|
80
|
-
|
|
81
76
|
// Suppress console output for terminal frontend (stdout belongs to the REPL)
|
|
82
77
|
let quiet = process.env.TALON_QUIET === "1";
|
|
83
78
|
if (!quiet) {
|
|
@@ -96,8 +91,8 @@ const logger = pino({
|
|
|
96
91
|
level: "trace",
|
|
97
92
|
transport: {
|
|
98
93
|
targets: [
|
|
99
|
-
// Console output (disabled in quiet mode
|
|
100
|
-
...(!quiet
|
|
94
|
+
// Console output (disabled in quiet mode)
|
|
95
|
+
...(!quiet
|
|
101
96
|
? [
|
|
102
97
|
{
|
|
103
98
|
target: "pino-pretty",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supervises an MCP stdio child. Dies with the child when our own stdin
|
|
5
|
+
* pipe closes, which happens whenever the Claude Agent SDK side goes away.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const [, , cmd, ...args] = process.argv;
|
|
10
|
+
if (!cmd) {
|
|
11
|
+
process.stderr.write("mcp-launcher: missing command\n");
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const child = spawn(cmd, args, {
|
|
16
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
17
|
+
env: process.env,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Any pipe end-point can throw EPIPE if the other side closes mid-write.
|
|
21
|
+
// We silence those; the exit and close paths already drive shutdown.
|
|
22
|
+
const swallow = () => {};
|
|
23
|
+
child.stdin.on("error", swallow);
|
|
24
|
+
child.stdout.on("error", swallow);
|
|
25
|
+
child.stderr.on("error", swallow);
|
|
26
|
+
process.stdin.on("error", swallow);
|
|
27
|
+
process.stdout.on("error", swallow);
|
|
28
|
+
process.stderr.on("error", swallow);
|
|
29
|
+
|
|
30
|
+
process.stdin.pipe(child.stdin);
|
|
31
|
+
child.stdout.pipe(process.stdout);
|
|
32
|
+
child.stderr.pipe(process.stderr);
|
|
33
|
+
|
|
34
|
+
let terminating = false;
|
|
35
|
+
|
|
36
|
+
function terminate(exitCode) {
|
|
37
|
+
if (terminating) return;
|
|
38
|
+
terminating = true;
|
|
39
|
+
try {
|
|
40
|
+
child.kill("SIGTERM");
|
|
41
|
+
} catch {}
|
|
42
|
+
const force = setTimeout(() => {
|
|
43
|
+
try {
|
|
44
|
+
child.kill("SIGKILL");
|
|
45
|
+
} catch {}
|
|
46
|
+
}, 1000);
|
|
47
|
+
force.unref?.();
|
|
48
|
+
child.once("exit", () => {
|
|
49
|
+
clearTimeout(force);
|
|
50
|
+
process.exit(exitCode);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stdin.on("end", () => terminate(0));
|
|
55
|
+
process.stdin.on("close", () => terminate(0));
|
|
56
|
+
|
|
57
|
+
child.once("exit", (code, signal) => {
|
|
58
|
+
if (terminating) return;
|
|
59
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
60
|
+
});
|
|
61
|
+
child.once("error", (err) => {
|
|
62
|
+
process.stderr.write(`mcp-launcher: spawn error: ${err.message}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const signals =
|
|
67
|
+
process.platform === "win32"
|
|
68
|
+
? ["SIGTERM", "SIGINT"]
|
|
69
|
+
: ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
70
|
+
for (const sig of signals) {
|
|
71
|
+
process.on(sig, () => terminate(0));
|
|
72
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP supervisor launcher.
|
|
3
|
+
*
|
|
4
|
+
* Every MCP stdio server Talon hands to the Claude Agent SDK is wrapped
|
|
5
|
+
* through a checked-in Node supervisor:
|
|
6
|
+
* `node src/util/mcp-launcher.mjs <real-cmd> [args...]`.
|
|
7
|
+
*
|
|
8
|
+
* The supervisor proxies stdio between the SDK and the real child, and
|
|
9
|
+
* watches its own `process.stdin` for EOF. When the SDK's pipe closes —
|
|
10
|
+
* for any reason, including Talon crashing or being SIGKILLed — the
|
|
11
|
+
* kernel closes our stdin, we SIGTERM the child, then SIGKILL if it
|
|
12
|
+
* hasn't exited within a short grace, then exit. No orphans, no /proc
|
|
13
|
+
* scan, no per-plugin signature list.
|
|
14
|
+
*
|
|
15
|
+
* Talon now requires a normal source or package install with this launcher
|
|
16
|
+
* file present on disk. Standalone bun-compiled binaries are unsupported.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const LAUNCHER_PATH = fileURLToPath(
|
|
23
|
+
new URL("./mcp-launcher.mjs", import.meta.url),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the checked-in launcher script path and verify it exists on disk.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureLauncher(): string {
|
|
30
|
+
if (!existsSync(LAUNCHER_PATH)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`MCP launcher missing at ${LAUNCHER_PATH}. Talon must run from a normal source or package install; bun-compiled binaries are not supported.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return LAUNCHER_PATH;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type StdioServer = {
|
|
39
|
+
command: string;
|
|
40
|
+
args: string[];
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite `{command, args}` so the real command runs under the launcher.
|
|
46
|
+
*
|
|
47
|
+
* Plugin-agnostic: the launcher doesn't know or care what it's supervising.
|
|
48
|
+
* Platform-agnostic: relies on pipe EOF, which POSIX and Windows both
|
|
49
|
+
* deliver when the parent end of a pipe is closed.
|
|
50
|
+
*/
|
|
51
|
+
export function wrapMcpServer<T extends StdioServer>(server: T): T {
|
|
52
|
+
const launcher = ensureLauncher();
|
|
53
|
+
return {
|
|
54
|
+
...server,
|
|
55
|
+
command: "node",
|
|
56
|
+
args: [launcher, server.command, ...server.args],
|
|
57
|
+
};
|
|
58
|
+
}
|