Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ jobs:
- name: Lint
run: npm run lint

- name: Check for .only/.skip in tests
- name: Check for focused/skipped vitest blocks
# Narrow the regex to vitest's focus/skip markers — `it.only`,
# `describe.only`, `test.only` (and the `.skip` variants). A bare
# `.skip(` matches legitimate method calls (e.g. our progress-tracker
# `StepTracker.skip()`) so we can't use the loose pattern.
run: |
if grep -rn '\.only\s*(' --include='*.test.ts' src/__tests__/ || \
grep -rn '\.skip\s*(' --include='*.test.ts' src/__tests__/; then
echo "::error::Found .only() or .skip() in test files — remove before merging"
if grep -rnE '\b(it|describe|test)\.(only|skip)\s*\(' --include='*.test.ts' src/__tests__/ || \
grep -rnE '\b(it|describe|test)\.(only|skip)\.each\s*\(' --include='*.test.ts' src/__tests__/; then
echo "::error::Found focused/skipped vitest blocks — remove before merging"
exit 1
fi

Expand Down
119 changes: 119 additions & 0 deletions .github/workflows/plugin-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Plugin Smoke

# Functional smoke tests for the internal self-healing plugins.
# Each plugin gets an isolated job so a Docker/Playwright failure doesn't
# hide a mempalace regression (and vice versa). Triggers: PRs touching
# plugin code/config/workflow/scripts, pushes to main with the same paths,
# nightly cron to catch upstream breakage, and manual dispatch.

on:
pull_request:
paths:
- "src/plugins/**"
- "src/util/config.ts"
- ".github/workflows/plugin-smoke.yml"
- "scripts/smoke-*.mjs"
- "scripts/lib/**"
- "package.json"
- "package-lock.json"
push:
branches: [main]
paths:
- "src/plugins/**"
- "src/util/config.ts"
- ".github/workflows/plugin-smoke.yml"
- "scripts/smoke-*.mjs"
- "scripts/lib/**"
- "package.json"
- "package-lock.json"
schedule:
Comment thread
This conversation was marked as resolved.
# 05:30 UTC — catches upstream PyPI / ghcr / playwright CDN breakage
# before the first people of the day (Paweł, Dylan) see it in Talon.
- cron: "30 5 * * *"
workflow_dispatch:

concurrency:
group: plugin-smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
# ── MemPalace: Linux/macOS/Windows × Python 3.11/3.12 ──────────────
mempalace:
name: mempalace · ${{ matrix.os }} · py${{ matrix.python }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- name: Pinned target
shell: bash
run: grep MEMPALACE_TARGET src/plugins/mempalace/heal.ts
- name: npm ci
run: npm ci
- name: Create venv + install pinned mempalace
shell: bash
run: node scripts/smoke-mempalace.mjs install
- name: MCP responsiveness
shell: bash
run: node scripts/smoke-mempalace.mjs smoke

# ── GitHub MCP: Ubuntu only (Docker) ───────────────────────────────
github:
name: github · ubuntu-latest
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Pinned image
shell: bash
run: grep GITHUB_MCP_IMAGE src/plugins/github/heal.ts
- name: npm ci
run: npm ci
- name: Pull pinned image
run: node scripts/smoke-github.mjs pull
- name: MCP responsiveness
run: node scripts/smoke-github.mjs smoke

# ── Playwright MCP: Linux/macOS/Windows × chromium ──────────────────
playwright:
name: playwright · ${{ matrix.os }} · ${{ matrix.browser }}
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
browser: [chromium]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Pinned @playwright/mcp
shell: bash
run: grep PLAYWRIGHT_MCP_VERSION src/plugins/playwright/heal.ts
- name: npm ci
run: npm ci
- name: Verify version + install browser
shell: bash
env:
PW_SMOKE_BROWSER: ${{ matrix.browser }}
run: node scripts/smoke-playwright.mjs install
- name: MCP responsiveness
shell: bash
env:
PW_SMOKE_BROWSER: ${{ matrix.browser }}
run: node scripts/smoke-playwright.mjs smoke
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ src/__tests__/integration/stub-claude/fake-claude
src/__tests__/integration/stub-claude/fake-claude.exe
src/__tests__/integration/stub-claude/fake-claude.cjs
src/__tests__/integration/stub-claude/sea-prep.blob

# Plugin lifecycle scratch dirs
.playwright-mcp/
.smoke-mempalace-venv/
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ WORKDIR /app
RUN npm install -g @anthropic-ai/claude-code

# Install production dependencies using the lockfile for reproducibility.
# scripts/ must be present before npm ci because package.json has a
# "postinstall" hook (scripts/prune-native-sdk.mjs) that runs automatically.
COPY package.json package-lock.json ./
COPY scripts/ scripts/
RUN npm ci --omit=dev

# The Claude Agent SDK ships native `claude` binaries for both linux-x64-musl
Expand Down
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ index.ts Composition root

## Built-in Plugins

Built-in plugins are **self-healing**: enable them and Talon takes care of installation, version pinning, and freshness checks at startup. No opt-in flags, no manual bootstrap. Every upstream version we ship against is pinned in source and exercised by the CI smoke matrix (Linux/macOS/Windows).

### GitHub

GitHub API access via the official GitHub MCP server. Gives the agent access to repositories, issues, PRs, code search, and more.
GitHub API access via the official GitHub MCP server.

**Requirements:** Docker installed and running.

Expand All @@ -98,38 +100,36 @@ GitHub API access via the official GitHub MCP server. Gives the agent access to
}
```

The token is optional --- defaults to the output of `gh auth token` if the GitHub CLI is authenticated.
On startup Talon pulls the Talon-pinned `ghcr.io/github/github-mcp-server` tag (see `GITHUB_MCP_IMAGE` in `src/plugins/github/heal.ts`). The token is optional — falls back to `gh auth token` output.

### MemPalace

Structured long-term memory with vector search. The agent can store, search, and retrieve memories semantically. Integrates with Dream mode for automatic memory consolidation and personal diary entries.

**Requirements:** Python 3.10+ with the `mempalace` package.

```bash
# Set up a Python environment
python -m venv ~/.talon/mempalace-venv
~/.talon/mempalace-venv/bin/pip install mempalace # Unix
# or: ~/.talon/mempalace-venv/Scripts/pip install mempalace # Windows
```
**Requirements:** Python 3.10+ on PATH (as `python3` on POSIX, `python` on Windows).

```json
{
"mempalace": {
"enabled": true,
"palacePath": "~/.talon/workspace/palace",
"pythonPath": "~/.talon/mempalace-venv/bin/python"
"enabled": true
}
}
```

Both paths are optional --- defaults to `~/.talon/workspace/palace/` and the venv Python respectively.
That's it. On first start Talon creates `~/.talon/mempalace-venv`, pip installs the pinned `mempalace` release (see `MEMPALACE_TARGET` in `src/plugins/mempalace/heal.ts`), and verifies the MCP submodule imports. Subsequent starts re-verify the version and realign to the pin if it drifted.

Optional config:

- `palacePath` — override the default palace directory (`~/.talon/workspace/palace/`).
- `pythonPath` — point at your own Python interpreter. **Supplying this switches Talon to verify-only mode** — we'll probe the installed version but never mutate your environment.
- `entityLanguages` — BCP 47 codes for non-English entity detection.
- `verbose` — enable mempalace's diagnostic diaries.

### Playwright

Headless browser automation via the Playwright MCP server. The agent can browse websites, take screenshots, generate PDFs, fill forms, and scrape content.
Headless browser automation via the Playwright MCP server.

**Requirements:** None --- `@playwright/mcp` is bundled with Talon.
**Requirements:** None.

```json
{
Expand All @@ -141,7 +141,9 @@ Headless browser automation via the Playwright MCP server. The agent can browse
}
```

Supported browsers: `chromium` (default), `chrome`, `firefox`, `webkit`, `msedge`.
On startup Talon verifies the pinned `@playwright/mcp` version (see `PLAYWRIGHT_MCP_VERSION` in `src/plugins/playwright/heal.ts`) and downloads the configured browser binary if missing. Supported browsers: `chromium` (default), `chrome`, `firefox`, `webkit`, `msedge`.

For a remote browser (bring-your-own-CDP, anti-detect browsers, etc.) set `endpoint` or `endpointFile` — Talon skips the local browser install entirely.

### Brave Search

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"tsconfig.json"
],
"scripts": {
"postinstall": "node scripts/prune-native-sdk.mjs",
"start": "tsx src/index.ts",
"cli": "tsx src/cli.ts",
"setup": "tsx src/cli.ts setup",
Expand Down
136 changes: 136 additions & 0 deletions scripts/lib/mcp-stdio-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Minimal MCP stdio client shared by the plugin smoke scripts.
*
* Implements just enough JSON-RPC to exercise the three things we need for
* smoke testing any MCP server:
* - initialize + notifications/initialized handshake
* - tools/list to assert the expected toolset is present
* - tools/call for a representative side-effect-free invocation
*
* Not for production use. Real Talon talks to MCP via the Claude Agent SDK.
*/

export class StdioMcpClient {
constructor(child, { name = "mcp", onStderrLine } = {}) {
this.name = name;
this.child = child;
this.buffer = "";
this.pending = new Map();
this.nextId = 1;
child.stdout.setEncoding("utf-8");
child.stdout.on("data", (chunk) => this.#onData(chunk));
child.stderr.setEncoding("utf-8");
child.stderr.on("data", (chunk) => {
if (onStderrLine) {
for (const line of String(chunk).split(/\r?\n/)) {
if (line.trim()) onStderrLine(line);
}
} else {
process.stderr.write(`[${name}-stderr] ${chunk}`);
}
});
child.on("exit", (code, signal) => {
const err = new Error(`${name} exited code=${code} signal=${signal}`);
for (const { reject } of this.pending.values()) reject(err);
this.pending.clear();
});
}

#onData(chunk) {
this.buffer += chunk;
let nl;
while ((nl = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
// Some servers (github-mcp) print non-JSON banner lines — ignore.
continue;
}
if (msg.id !== undefined && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
else resolve(msg.result);
}
}
}

request(method, params, timeoutMs = 30_000) {
const id = this.nextId++;
const payload = { jsonrpc: "2.0", id, method, params };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
});
}

notify(method, params) {
this.child.stdin.write(
`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`,
);
}

async handshake(timeoutMs = 30_000) {
const init = await this.request(
"initialize",
{
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "talon-smoke", version: "0.0.0" },
},
timeoutMs,
);
if (!init?.protocolVersion) {
throw new Error(`bad initialize response: ${JSON.stringify(init)}`);
}
this.notify("notifications/initialized", {});
return init.protocolVersion;
}

async listTools(timeoutMs = 15_000) {
const result = await this.request("tools/list", {}, timeoutMs);
if (!Array.isArray(result?.tools)) {
throw new Error(`bad tools/list response: ${JSON.stringify(result)}`);
}
return result.tools.map((t) => t.name);
}

async callTool(name, args = {}, timeoutMs = 60_000) {
const result = await this.request(
"tools/call",
{ name, arguments: args },
timeoutMs,
);
return result;
}

async close() {
try {
this.child.stdin.end();
} catch {
/* already closed */
}
await new Promise((resolve) => {
if (this.child.exitCode !== null) return resolve();
this.child.on("exit", () => resolve());
});
}
}

Loading
Loading