pi-weave 0.3.12 → 0.3.14
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 +26 -8
- package/package.json +6 -3
- package/src/claude/cli.ts +13 -0
- package/src/claude/control.ts +84 -0
- package/src/claude/generate.ts +62 -0
- package/src/claude/index.ts +87 -0
- package/src/claude/mcp.ts +105 -0
- package/src/claude/server.ts +154 -0
- package/src/claude/sessions.ts +158 -0
- package/src/core/index.ts +3 -0
- package/src/core/sessionDigest.ts +103 -0
- package/src/core/tools.ts +63 -0
- package/src/core/vault.ts +79 -5
- package/src/core/view/tree.ts +2 -2
- package/src/opencode/commands.ts +4 -71
- package/src/opencode/index.ts +1 -1
- package/src/opencode/v1.ts +1 -1
- package/src/pi/tools/noteTool.ts +10 -22
- package/src/pi/tools/parameters.ts +10 -0
- package/src/pi/tools/repoTool.ts +8 -9
- package/src/web/client/api.ts +4 -0
- package/src/web/client/dist/app.js +48 -48
- package/src/web/client/shell/shell.model.ts +3 -2
- package/src/web/client/tree/Tree.tsx +8 -6
- package/src/web/client/tree/tree.model.ts +22 -7
- package/src/web/server/routes.ts +25 -3
- package/src/opencode/tools.ts +0 -43
package/README.md
CHANGED
|
@@ -57,8 +57,15 @@ pi-weave keeps track of where knowledge came from, while making the whole worksp
|
|
|
57
57
|
|
|
58
58
|
## Install
|
|
59
59
|
|
|
60
|
-
pi-weave supports [Pi](https://github.com/earendil-works/pi)
|
|
61
|
-
the vault, repository index, browser workspace, tools, and skills.
|
|
60
|
+
pi-weave supports [Pi](https://github.com/earendil-works/pi), OpenCode (V1 **1.18.29+** and V2), and Claude Code from the same repository.
|
|
61
|
+
The adapters share the vault, repository index, browser workspace, tools, and skills.
|
|
62
|
+
|
|
63
|
+
Claude Code:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
/plugin marketplace add EranYonai/pi-weave
|
|
67
|
+
/plugin install pi-weave@pi-weave
|
|
68
|
+
```
|
|
62
69
|
|
|
63
70
|
OpenCode V2:
|
|
64
71
|
|
|
@@ -176,13 +183,14 @@ Every change to a note is saved to `<vault>/history/`, whether you, your agent,
|
|
|
176
183
|
## Remember sessions
|
|
177
184
|
|
|
178
185
|
```bash
|
|
179
|
-
/weave-scan sessions # Pi history, or the current OpenCode session
|
|
186
|
+
/weave-scan sessions # Pi history, or the current OpenCode or Claude Code session
|
|
180
187
|
/weave-scan sessions /path/to/history # explicit history root
|
|
181
188
|
```
|
|
182
189
|
|
|
183
190
|
This opt-in scan treats a supplied file—or every bounded text file under a supplied directory—as opaque session material for the active
|
|
184
191
|
model to interpret, then writes generated notes under `~/.okf/notes/sessions/`. In OpenCode, the pathless form reads the current session
|
|
185
|
-
through the public plugin API; it never inspects OpenCode's internal database.
|
|
192
|
+
through the public plugin API; it never inspects OpenCode's internal database. In Claude Code, it reads the current session's transcript
|
|
193
|
+
from `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects/`). That makes explicit paths usable with Claude Code, OpenCode,
|
|
186
194
|
Codex, or exported history trees without requiring their schema or file extension. It skips unchanged files, captures outcomes plus reusable
|
|
187
195
|
technical takeaways, works outside Git repositories, and can be stopped with `/weave-scan-cancel`.
|
|
188
196
|
|
|
@@ -212,6 +220,7 @@ Most people only need natural language and `/weave-view`.
|
|
|
212
220
|
| Command | `/weave-scan` | Add incremental model-written file summaries |
|
|
213
221
|
| Command | `/weave-scan sessions [path]` | Turn changed session-history files into durable memory notes |
|
|
214
222
|
| Command | `/weave-scan-cancel` | Stop a running scan |
|
|
223
|
+
| Command | `/weave-statusline` | Claude Code only: print the setting that shows pi-weave in the status line |
|
|
215
224
|
| Tool | `weave_note` | List, read, add, append, finalize, and search notes |
|
|
216
225
|
| Tool | `weave_repo` | Describe the repository structure for the agent |
|
|
217
226
|
|
|
@@ -221,6 +230,13 @@ pipeline, so the model reports command results in the conversation. `/weave` doe
|
|
|
221
230
|
chat message: send a message after selecting a model, then scan. Generation runs in temporary child sessions with tools denied;
|
|
222
231
|
those sessions are removed on completion or cancellation. V2 uses its direct command and generation APIs.
|
|
223
232
|
|
|
233
|
+
In Claude Code, the commands appear as `/pi-weave:weave-view` and so on (type `/weave` and pick one). The plugin runs a small local MCP
|
|
234
|
+
server for the tools and the workspace, and a SessionStart hook adds the vault status to each session. Scans run in the background: `/weave`
|
|
235
|
+
shows their progress and result. Each summary is one headless `claude -p` run on Haiku with tools and MCP servers disabled, billed to your
|
|
236
|
+
own Claude Code login; set `PI_WEAVE_CLAUDE_MODEL` to use another model. Claude Code asks before each tool call until you allow the
|
|
237
|
+
pi-weave tools; to allow them up front, add `"mcp__plugin_pi-weave_pi-weave"` to `permissions.allow` in your settings. The plugin needs
|
|
238
|
+
`node` (20.13+) on your `PATH`.
|
|
239
|
+
|
|
224
240
|
## Files, privacy, and portability
|
|
225
241
|
|
|
226
242
|
Personal notes are ordinary Markdown files:
|
|
@@ -260,15 +276,16 @@ We probably want OIDC next quarter…
|
|
|
260
276
|
Set `PI_WEAVE_VAULT` to use a different vault location.
|
|
261
277
|
|
|
262
278
|
Reading, writing, searching, and viewing notes are local operations. Deep repository scans and session summaries send bounded input to the
|
|
263
|
-
active Pi/V2 model
|
|
279
|
+
active Pi/V2 model, the last-used V1 chat model, or headless Claude Code. The browser workspace binds only to loopback, uses a per-session token, and shuts down
|
|
264
280
|
with the plugin lifecycle. OpenCode opens it automatically when the terminal can reach the viewer's loopback URL. Otherwise, it shows the
|
|
265
281
|
exact URL for a browser or tunnel.
|
|
266
282
|
|
|
267
|
-
The vault format, repository index, and skills are intentionally harness-agnostic. `src/core` contains no Pi
|
|
283
|
+
The vault format, repository index, and skills are intentionally harness-agnostic. `src/core` contains no Pi-, OpenCode-, or Claude Code-specific imports.
|
|
268
284
|
|
|
269
285
|
## Development
|
|
270
286
|
|
|
271
|
-
|
|
287
|
+
All adapters share one set of tool schemas (`src/core/tools.ts`), the core actions, and one command/scan workflow
|
|
288
|
+
(`src/core/workspaceCommands.ts`). Zod is the only direct runtime dependency,
|
|
272
289
|
required by V1's tool-schema protocol; OpenCode SDKs are development-only.
|
|
273
290
|
|
|
274
291
|
```bash
|
|
@@ -286,7 +303,8 @@ npm run build:web
|
|
|
286
303
|
```
|
|
287
304
|
|
|
288
305
|
Coverage must remain at or above **95%** for lines, branches, functions, and statements. If browser source changes, rebuild and commit
|
|
289
|
-
`src/web/client/dist/app.js`.
|
|
306
|
+
`src/web/client/dist/app.js`. The Claude Code plugin runs from a committed bundle too: after changing anything it imports, run
|
|
307
|
+
`npm run build:claude` and commit `claude/dist/weave.mjs`.
|
|
290
308
|
|
|
291
309
|
Read [AGENTS.md](AGENTS.md) before contributing. Work on a feature branch; do not commit directly to `main`.
|
|
292
310
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Local-first Markdown memory and knowledge graph for Pi and
|
|
3
|
+
"version": "0.3.14",
|
|
4
|
+
"description": "Local-first Markdown memory and knowledge graph for Pi, OpenCode and Claude Code: preserve decisions, recall sessions, and explore notes with code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"pi-package",
|
|
12
12
|
"pi-coding-agent",
|
|
13
13
|
"opencode-plugin",
|
|
14
|
+
"claude-code-plugin",
|
|
14
15
|
"agent-memory",
|
|
15
16
|
"persistent-memory",
|
|
16
17
|
"markdown",
|
|
@@ -59,8 +60,10 @@
|
|
|
59
60
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.web.json",
|
|
60
61
|
"build:web": "node scripts/build-web.mjs",
|
|
61
62
|
"build:web:check": "node scripts/build-web.mjs --check",
|
|
63
|
+
"build:claude": "node scripts/build-claude.mjs",
|
|
64
|
+
"build:claude:check": "node scripts/build-claude.mjs --check",
|
|
62
65
|
"e2e": "vitest run --config vitest.e2e.config.ts",
|
|
63
|
-
"check": "npm run typecheck && npm run build:web:check && npm run coverage",
|
|
66
|
+
"check": "npm run typecheck && npm run build:web:check && npm run build:claude:check && npm run coverage",
|
|
64
67
|
"eval:notepad": "node scripts/eval-notepad-search.mjs",
|
|
65
68
|
"rewrap:md": "node scripts/rewrap-md.mjs",
|
|
66
69
|
"rewrap:md:check": "node scripts/rewrap-md.mjs --check",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* v8 ignore start -- process wiring; the bundle smoke test runs it */
|
|
2
|
+
import { main, packageVersion } from "./index";
|
|
3
|
+
|
|
4
|
+
// The bundle's entry: run `main` against the real process.
|
|
5
|
+
const code = await main(process.argv.slice(2), {
|
|
6
|
+
stdin: process.stdin,
|
|
7
|
+
stdout: process.stdout,
|
|
8
|
+
env: process.env,
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
version: packageVersion(),
|
|
11
|
+
});
|
|
12
|
+
process.exitCode = code;
|
|
13
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { createConnection, createServer, type Server } from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A per-session control socket, so a hook can run a slash command without a
|
|
9
|
+
* model turn. The MCP process owns the browser workspace, so the hook asks
|
|
10
|
+
* that process to run the command. The protocol is one JSON line each way:
|
|
11
|
+
* `{name, args}` in, `{text}` or `{error}` out.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ControlHandler = (name: string, args: string) => Promise<string>;
|
|
15
|
+
|
|
16
|
+
/** Short on purpose: unix socket paths top out near 104 bytes on macOS. */
|
|
17
|
+
export function controlSocketPath(sessionId: string, tmp = tmpdir(), uid = process.getuid?.() ?? 0): string {
|
|
18
|
+
return join(tmp, `pw-${uid}`, `${createHash("sha1").update(sessionId).digest("hex").slice(0, 12)}.sock`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function serveControl(path: string, handler: ControlHandler): Promise<{ close(): Promise<void> }> {
|
|
22
|
+
mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
23
|
+
// A socket left by a crashed session; a live one is never at this path.
|
|
24
|
+
rmSync(path, { force: true });
|
|
25
|
+
const server: Server = createServer((socket) => {
|
|
26
|
+
let line = "";
|
|
27
|
+
socket.setEncoding("utf8");
|
|
28
|
+
socket.on("error", () => {});
|
|
29
|
+
socket.on("data", async (chunk) => {
|
|
30
|
+
line += chunk;
|
|
31
|
+
if (!line.includes("\n")) return;
|
|
32
|
+
let reply: object;
|
|
33
|
+
try {
|
|
34
|
+
const request = JSON.parse(line) as { name?: unknown; args?: unknown };
|
|
35
|
+
reply = { text: await handler(String(request.name), typeof request.args === "string" ? request.args : "") };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
reply = { error: error instanceof Error ? error.message : String(error) };
|
|
38
|
+
}
|
|
39
|
+
socket.end(`${JSON.stringify(reply)}\n`);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
await new Promise<void>((resolve, reject) => {
|
|
43
|
+
server.once("error", reject);
|
|
44
|
+
server.listen(path, resolve);
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
close: () => new Promise((resolve) => {
|
|
48
|
+
server.close(() => {
|
|
49
|
+
rmSync(path, { force: true });
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The command's output, or null when nothing answers (the model can run it). */
|
|
57
|
+
export function callControl(path: string, name: string, args: string, timeoutMs = 15_000): Promise<string | null> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
let data = "";
|
|
60
|
+
const socket = createConnection(path);
|
|
61
|
+
const finish = (value: string | null) => {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
resolve(value);
|
|
64
|
+
};
|
|
65
|
+
let connected = false;
|
|
66
|
+
// Once connected the command is running; falling back to the model would run it twice.
|
|
67
|
+
socket.setTimeout(timeoutMs, () => finish(connected ? "pi-weave: still working; check /weave for progress." : null));
|
|
68
|
+
socket.setEncoding("utf8");
|
|
69
|
+
socket.on("error", () => finish(null));
|
|
70
|
+
socket.on("connect", () => {
|
|
71
|
+
connected = true;
|
|
72
|
+
socket.write(`${JSON.stringify({ name, args })}\n`);
|
|
73
|
+
});
|
|
74
|
+
socket.on("data", (chunk) => { data += chunk; });
|
|
75
|
+
socket.on("end", () => {
|
|
76
|
+
try {
|
|
77
|
+
const reply = JSON.parse(data) as { text?: string; error?: string };
|
|
78
|
+
finish(typeof reply.text === "string" ? reply.text : reply.error ? `pi-weave: ${reply.error}` : null);
|
|
79
|
+
} catch {
|
|
80
|
+
finish(null);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Model calls for the shared scans, through headless Claude Code.
|
|
6
|
+
*
|
|
7
|
+
* Pi and OpenCode hand the scans their own model API. A Claude Code plugin has
|
|
8
|
+
* none, so each summary is one `claude -p` run: no tools, no MCP servers, no
|
|
9
|
+
* saved session, a short system prompt in place of Claude Code's own, and the
|
|
10
|
+
* user's own login and plan. It runs in the temp directory so no project
|
|
11
|
+
* CLAUDE.md or project settings ride along.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Set on the child so this plugin's own hooks stay quiet inside it. */
|
|
15
|
+
export const NESTED_ENV = "PI_WEAVE_NESTED";
|
|
16
|
+
|
|
17
|
+
const SYSTEM_PROMPT = "You write concise summaries for a Markdown knowledge vault. Follow the instructions in the message and answer with the summary only.";
|
|
18
|
+
|
|
19
|
+
export type SpawnFn = typeof nodeSpawn;
|
|
20
|
+
|
|
21
|
+
export interface ClaudeGenerateOptions {
|
|
22
|
+
bin?: string;
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
spawn?: SpawnFn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function claudeArgs(model: string): string[] {
|
|
28
|
+
return [
|
|
29
|
+
"-p",
|
|
30
|
+
"--model", model,
|
|
31
|
+
"--system-prompt", SYSTEM_PROMPT,
|
|
32
|
+
"--tools", "",
|
|
33
|
+
"--strict-mcp-config",
|
|
34
|
+
"--no-session-persistence",
|
|
35
|
+
"--output-format", "text",
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function claudeGenerate(options: ClaudeGenerateOptions = {}) {
|
|
40
|
+
const env = options.env ?? process.env;
|
|
41
|
+
const bin = options.bin ?? "claude";
|
|
42
|
+
const spawn = options.spawn ?? nodeSpawn;
|
|
43
|
+
return (prompt: string, signal: AbortSignal, model: string): Promise<string> => new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(bin, claudeArgs(model), {
|
|
45
|
+
cwd: tmpdir(),
|
|
46
|
+
env: { ...env, [NESTED_ENV]: "1" },
|
|
47
|
+
signal,
|
|
48
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
child.stdout!.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; });
|
|
53
|
+
child.stderr!.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; });
|
|
54
|
+
child.on("error", (error) => reject(signal.aborted ? signal.reason : new Error(`could not run ${bin}: ${error.message}`)));
|
|
55
|
+
child.on("close", (code) => {
|
|
56
|
+
if (code === 0) resolve(stdout);
|
|
57
|
+
else reject(new Error(`${bin} exited with code ${code}${stderr.trim() ? `: ${stderr.trim().slice(-400)}` : ""}`));
|
|
58
|
+
});
|
|
59
|
+
child.stdin!.on("error", () => {});
|
|
60
|
+
child.stdin!.end(prompt);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
import { formatStatusLine, getWorkspaceStatus } from "../core";
|
|
4
|
+
import { NESTED_ENV } from "./generate";
|
|
5
|
+
import { callControl, controlSocketPath, serveControl } from "./control";
|
|
6
|
+
import { serveMcp } from "./mcp";
|
|
7
|
+
import { createClaudeAdapter } from "./server";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Entry point of the Claude Code plugin, bundled to `claude/dist/weave.mjs`.
|
|
11
|
+
*
|
|
12
|
+
* weave.mjs mcp the MCP server (plugin.json → mcpServers)
|
|
13
|
+
* weave.mjs session-start SessionStart hook: the status line, as context
|
|
14
|
+
* weave.mjs prompt-submit UserPromptSubmit hook: runs /weave commands with no model turn
|
|
15
|
+
* weave.mjs statusline opt-in status-line command
|
|
16
|
+
*
|
|
17
|
+
* Hooks and the status line pass a JSON object on stdin; only its working
|
|
18
|
+
* directory is used.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface MainIo {
|
|
22
|
+
stdin: Readable;
|
|
23
|
+
stdout: Writable;
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
cwd: string;
|
|
26
|
+
version: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readJson(stdin: Readable): Promise<Record<string, unknown>> {
|
|
30
|
+
let text = "";
|
|
31
|
+
for await (const chunk of stdin) text += String(chunk);
|
|
32
|
+
try {
|
|
33
|
+
const value: unknown = JSON.parse(text);
|
|
34
|
+
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cwdFrom(input: Record<string, unknown>, fallback: string): string {
|
|
41
|
+
const workspace = input.workspace as Record<string, unknown> | undefined;
|
|
42
|
+
if (typeof workspace?.current_dir === "string") return workspace.current_dir;
|
|
43
|
+
return typeof input.cwd === "string" ? input.cwd : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/weave…` or `/pi-weave:weave…` and its arguments. */
|
|
47
|
+
const PROMPT = /^\/(?:pi-weave:)?(weave(?:-view|-scan-cancel|-scan|-statusline)?)(?:\s+(.*))?$/s;
|
|
48
|
+
|
|
49
|
+
export async function main(argv: readonly string[], io: MainIo): Promise<number> {
|
|
50
|
+
const [mode = "mcp"] = argv;
|
|
51
|
+
if (mode === "mcp") {
|
|
52
|
+
const adapter = createClaudeAdapter({ env: io.env, cwd: io.env.CLAUDE_PROJECT_DIR ?? io.cwd, version: io.version });
|
|
53
|
+
const sessionId = io.env.CLAUDE_CODE_SESSION_ID;
|
|
54
|
+
const control = sessionId ? await serveControl(controlSocketPath(sessionId), adapter.runCommand).catch(() => null) : null;
|
|
55
|
+
await serveMcp(adapter.server, io.stdin, io.stdout);
|
|
56
|
+
await control?.close();
|
|
57
|
+
await adapter.close();
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (mode === "prompt-submit") {
|
|
61
|
+
const input = await readJson(io.stdin);
|
|
62
|
+
const prompt = typeof input.prompt === "string" ? PROMPT.exec(input.prompt.trim()) : null;
|
|
63
|
+
if (!prompt || io.env[NESTED_ENV] || typeof input.session_id !== "string") return 0;
|
|
64
|
+
const text = await callControl(controlSocketPath(input.session_id), prompt[1] as string, (prompt[2] ?? "").trim());
|
|
65
|
+
// Nothing answered: stay silent and the command runs through the model instead.
|
|
66
|
+
if (text !== null) io.stdout.write(JSON.stringify({ continue: false, stopReason: text }));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (mode === "session-start" || mode === "statusline") {
|
|
70
|
+
const input = await readJson(io.stdin);
|
|
71
|
+
// Scans run headless Claude Code; its sessions need no pi-weave context.
|
|
72
|
+
if (mode === "session-start" && io.env[NESTED_ENV]) return 0;
|
|
73
|
+
const line = formatStatusLine(await getWorkspaceStatus(cwdFrom(input, io.cwd)));
|
|
74
|
+
io.stdout.write(mode === "session-start" ? `pi-weave: ${line}\n` : `${line}\n`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
io.stdout.write("usage: weave.mjs [mcp | session-start | prompt-submit | statusline]\n");
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function packageVersion(): string {
|
|
82
|
+
try {
|
|
83
|
+
return (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
84
|
+
} catch {
|
|
85
|
+
return "0.0.0";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A minimal Model Context Protocol server over stdio: newline-delimited
|
|
6
|
+
* JSON-RPC 2.0 with the `tools` capability and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* Hand-rolled on purpose. pi-weave keeps runtime dependencies near zero, and
|
|
9
|
+
* the slice Claude Code needs (initialize, tools/list, tools/call, ping) is a
|
|
10
|
+
* few dozen lines; the official SDK would add more code than the adapter.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface McpToolResult {
|
|
14
|
+
text: string;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface McpTool {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
inputSchema: object;
|
|
22
|
+
call(args: Record<string, unknown>): Promise<McpToolResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface McpServerInfo {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
instructions?: string;
|
|
29
|
+
tools: readonly McpTool[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Newest first; an unknown requested version is answered with the newest. */
|
|
33
|
+
export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const;
|
|
34
|
+
|
|
35
|
+
type Id = string | number | null;
|
|
36
|
+
type Response = { jsonrpc: "2.0"; id: Id; result: unknown } | { jsonrpc: "2.0"; id: Id; error: { code: number; message: string } };
|
|
37
|
+
|
|
38
|
+
function record(value: unknown): Record<string, unknown> {
|
|
39
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const failure = (id: Id, code: number, message: string): Response => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
43
|
+
|
|
44
|
+
/** Answer one parsed message; `null` for a notification, which gets no reply. */
|
|
45
|
+
export async function handleMcpMessage(server: McpServerInfo, raw: unknown): Promise<Response | null> {
|
|
46
|
+
const message = record(raw);
|
|
47
|
+
const id = typeof message.id === "string" || typeof message.id === "number" ? message.id : null;
|
|
48
|
+
if (message.jsonrpc !== "2.0" || typeof message.method !== "string") return failure(id, -32600, "Invalid request");
|
|
49
|
+
if (!("id" in message)) return null;
|
|
50
|
+
const params = record(message.params);
|
|
51
|
+
const result = (value: unknown): Response => ({ jsonrpc: "2.0", id, result: value });
|
|
52
|
+
switch (message.method) {
|
|
53
|
+
case "initialize": {
|
|
54
|
+
const requested = params.protocolVersion;
|
|
55
|
+
const protocolVersion = MCP_PROTOCOL_VERSIONS.find((version) => version === requested) ?? MCP_PROTOCOL_VERSIONS[0];
|
|
56
|
+
return result({
|
|
57
|
+
protocolVersion,
|
|
58
|
+
capabilities: { tools: {} },
|
|
59
|
+
serverInfo: { name: server.name, version: server.version },
|
|
60
|
+
...(server.instructions ? { instructions: server.instructions } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
case "ping":
|
|
64
|
+
return result({});
|
|
65
|
+
case "tools/list":
|
|
66
|
+
return result({ tools: // alwaysLoad keeps the tools out of Claude Code's deferred tool search: a
|
|
67
|
+
// slash command would otherwise spend a model round trip finding them.
|
|
68
|
+
server.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema, _meta: { "anthropic/alwaysLoad": true } })) });
|
|
69
|
+
case "tools/call": {
|
|
70
|
+
const tool = server.tools.find((candidate) => candidate.name === params.name);
|
|
71
|
+
if (!tool) return failure(id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
72
|
+
try {
|
|
73
|
+
const output = await tool.call(record(params.arguments));
|
|
74
|
+
return result({ content: [{ type: "text", text: output.text }], isError: output.isError === true });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A failed tool is a result the model should read, not a protocol error.
|
|
77
|
+
return result({ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return failure(id, -32601, `Method not found: ${message.method}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Serve until `input` closes. Requests are answered as they finish, not in order. */
|
|
86
|
+
export async function serveMcp(server: McpServerInfo, input: Readable, output: Writable): Promise<void> {
|
|
87
|
+
const pending = new Set<Promise<void>>();
|
|
88
|
+
const write = (response: Response | null) => {
|
|
89
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
90
|
+
};
|
|
91
|
+
for await (const line of createInterface({ input, crlfDelay: Infinity })) {
|
|
92
|
+
if (!line.trim()) continue;
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(line);
|
|
96
|
+
} catch {
|
|
97
|
+
write(failure(null, -32700, "Parse error"));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const task = handleMcpMessage(server, parsed).then(write);
|
|
101
|
+
pending.add(task);
|
|
102
|
+
void task.finally(() => pending.delete(task));
|
|
103
|
+
}
|
|
104
|
+
await Promise.all(pending);
|
|
105
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { formatDashboard, getWorkspaceStatus } from "../core";
|
|
3
|
+
import { weaveTools } from "../core/tools";
|
|
4
|
+
import { createWorkspaceCommands } from "../core/workspaceCommands";
|
|
5
|
+
import { browserOpenCommand, WorkspaceServerController } from "../web/server/controller";
|
|
6
|
+
import { claudeGenerate, type SpawnFn } from "./generate";
|
|
7
|
+
import type { McpServerInfo, McpTool } from "./mcp";
|
|
8
|
+
import { claudeConfigDir, findTranscript, readClaudeTranscript, transcriptSessionId } from "./sessions";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The Claude Code adapter: the shared tools and the shared workspace
|
|
12
|
+
* commands, served as MCP tools from one process per Claude Code session.
|
|
13
|
+
*
|
|
14
|
+
* The browser workspace lives in this same process, so the recall trail and
|
|
15
|
+
* the agent's graph spotlight see every `weave_note` call, exactly as in Pi.
|
|
16
|
+
* Claude Code has no turn event for MCP servers, so turns fall back to the
|
|
17
|
+
* trail's time gap, as in OpenCode.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The model scans use unless `PI_WEAVE_CLAUDE_MODEL` names another. */
|
|
21
|
+
export const DEFAULT_SCAN_MODEL = "haiku";
|
|
22
|
+
const MODEL_PREFIX = "claude-code/";
|
|
23
|
+
|
|
24
|
+
export const INSTRUCTIONS = [
|
|
25
|
+
"pi-weave is the user's local Markdown memory vault, shared with Pi and OpenCode.",
|
|
26
|
+
"Save the user's notes, decisions and dictation with weave_note, and search it before answering questions about past work.",
|
|
27
|
+
"Leave CLAUDE.md for project instructions; durable knowledge belongs in the vault.",
|
|
28
|
+
].join(" ");
|
|
29
|
+
|
|
30
|
+
export const COMMANDS = ["weave", "weave-view", "weave-scan", "weave-scan-cancel", "weave-statusline"] as const;
|
|
31
|
+
|
|
32
|
+
export interface ClaudeAdapterOptions {
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
cwd?: string;
|
|
35
|
+
home?: string;
|
|
36
|
+
/** Path of the running script, for the status-line snippet. */
|
|
37
|
+
script?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
spawn?: SpawnFn;
|
|
40
|
+
viewer?: WorkspaceServerController;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statusLineSnippet(script: string): string {
|
|
44
|
+
const command = `node ${JSON.stringify(script)} statusline`;
|
|
45
|
+
return [
|
|
46
|
+
"Add this to ~/.claude/settings.json to show pi-weave in your status line:",
|
|
47
|
+
"",
|
|
48
|
+
JSON.stringify({ statusLine: { type: "command", command } }, null, 2),
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createClaudeAdapter(options: ClaudeAdapterOptions = {}) {
|
|
53
|
+
const env = options.env ?? process.env;
|
|
54
|
+
const cwd = options.cwd ?? env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
55
|
+
const spawn = options.spawn ?? nodeSpawn;
|
|
56
|
+
const viewer = options.viewer ?? new WorkspaceServerController();
|
|
57
|
+
const generate = claudeGenerate({ env, spawn, ...(env.PI_WEAVE_CLAUDE_BIN ? { bin: env.PI_WEAVE_CLAUDE_BIN } : {}) });
|
|
58
|
+
const configDir = claudeConfigDir(env, options.home);
|
|
59
|
+
const transcript = () => findTranscript(cwd, env.CLAUDE_CODE_SESSION_ID, configDir);
|
|
60
|
+
|
|
61
|
+
let collecting: string[] | null = null;
|
|
62
|
+
let scan: { text: string; active: boolean } | null = null;
|
|
63
|
+
const shared = createWorkspaceCommands({
|
|
64
|
+
async getSession() {
|
|
65
|
+
const path = await transcript();
|
|
66
|
+
return {
|
|
67
|
+
id: path ? transcriptSessionId(path) : env.CLAUDE_CODE_SESSION_ID ?? "claude-code",
|
|
68
|
+
cwd,
|
|
69
|
+
model: `${MODEL_PREFIX}${env.PI_WEAVE_CLAUDE_MODEL || DEFAULT_SCAN_MODEL}`,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
async getDigest() {
|
|
73
|
+
const path = await transcript();
|
|
74
|
+
if (!path) throw new Error("no Claude Code transcript found for this project");
|
|
75
|
+
return readClaudeTranscript(path);
|
|
76
|
+
},
|
|
77
|
+
sessionSource: (id) => `claude-code:session:${id}`,
|
|
78
|
+
generate: (_id, prompt, signal, model) => generate(prompt, signal, model.slice(MODEL_PREFIX.length)),
|
|
79
|
+
// While a command runs its output is its tool result; a background scan's
|
|
80
|
+
// final word arrives later and is kept by the status below instead.
|
|
81
|
+
async output(_id, text) {
|
|
82
|
+
collecting?.push(text);
|
|
83
|
+
},
|
|
84
|
+
}, (status) => {
|
|
85
|
+
scan = { text: status.text, active: status.active };
|
|
86
|
+
}, viewer, ({ url, open }) => {
|
|
87
|
+
// Over SSH the browser would open on the wrong machine, if at all.
|
|
88
|
+
if (!open || env.SSH_CONNECTION) return;
|
|
89
|
+
const { command, args } = browserOpenCommand(url);
|
|
90
|
+
try {
|
|
91
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
|
|
92
|
+
} catch {
|
|
93
|
+
// No opener on this machine; the URL is in the tool result.
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
async function runCommand(name: string, args: string): Promise<string> {
|
|
98
|
+
if (name === "weave-statusline") return statusLineSnippet(options.script ?? process.argv[1] ?? "weave.mjs");
|
|
99
|
+
if (name === "weave") {
|
|
100
|
+
const dashboard = formatDashboard(await getWorkspaceStatus(cwd));
|
|
101
|
+
return scan ? `${dashboard}\n\n${scan.active ? "Scan running" : "Last scan"}: ${scan.text}` : dashboard;
|
|
102
|
+
}
|
|
103
|
+
const command = shared.commands.find((candidate) => candidate.name === name);
|
|
104
|
+
if (!command) throw new Error(`Unknown pi-weave command: ${name}`);
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
collecting = lines;
|
|
107
|
+
try {
|
|
108
|
+
await command.execute("claude-code", args);
|
|
109
|
+
} finally {
|
|
110
|
+
collecting = null;
|
|
111
|
+
}
|
|
112
|
+
// The hook shows this line as-is, so keep just the URL.
|
|
113
|
+
const text = lines.join("\n").replace(/^pi-weave: workspace (?:already )?running at (\S+) — .*$/m, "pi-weave: workspace at $1");
|
|
114
|
+
return text || "pi-weave: scan started in the background. Run /weave to see its progress.";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tools: McpTool[] = [
|
|
118
|
+
...weaveTools.map((tool): McpTool => ({
|
|
119
|
+
name: tool.name,
|
|
120
|
+
description: tool.description,
|
|
121
|
+
inputSchema: tool.input,
|
|
122
|
+
async call(args) {
|
|
123
|
+
return { text: (await tool.execute(args, cwd)).text };
|
|
124
|
+
},
|
|
125
|
+
})),
|
|
126
|
+
{
|
|
127
|
+
name: "weave_command",
|
|
128
|
+
description: "Run a pi-weave slash command (/weave, /weave-view, /weave-scan, /weave-scan-cancel, /weave-statusline) and return its output. Only call this when the user ran one of those commands.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
command: { type: "string", enum: [...COMMANDS] },
|
|
133
|
+
args: { type: "string", description: "The command's arguments, exactly as the user typed them" },
|
|
134
|
+
},
|
|
135
|
+
required: ["command"],
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
},
|
|
138
|
+
async call(args) {
|
|
139
|
+
return { text: await runCommand(String(args.command), typeof args.args === "string" ? args.args : "") };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const server: McpServerInfo = { name: "pi-weave", version: options.version ?? "0.0.0", instructions: INSTRUCTIONS, tools };
|
|
145
|
+
return {
|
|
146
|
+
server,
|
|
147
|
+
/** Run a slash command directly, as the hook does when it answers without the model. */
|
|
148
|
+
runCommand,
|
|
149
|
+
async close() {
|
|
150
|
+
await shared.cleanup();
|
|
151
|
+
await viewer.close();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|