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 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) and OpenCode (V1 **1.18.29+** and V2) from the same package. The adapters share
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. That makes explicit paths usable with Claude Code, OpenCode,
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 or the last-used V1 chat model. The browser workspace binds only to loopback, uses a per-session token, and shuts down
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- or OpenCode-specific imports.
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
- The OpenCode V1/V2 bindings share tool schemas, core actions, and one command/scan workflow. Zod is the only direct runtime dependency,
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.12",
4
- "description": "Local-first Markdown memory and knowledge graph for Pi and OpenCode: preserve decisions, recall sessions, and explore notes with code.",
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
+ }