pi-weave 0.3.13 → 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/sessionDigest.ts +103 -0
- package/src/core/tools.ts +63 -0
- 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/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
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { hashContent, type SessionDigest } from "../core";
|
|
5
|
+
import { digestFromMessages } from "../core/sessionDigest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Claude Code's own transcripts, mapped onto the shared session digest.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code writes one JSONL file per session under
|
|
11
|
+
* `<config dir>/projects/<cwd with every non-alphanumeric as "-">/<session id>.jsonl`,
|
|
12
|
+
* where the config dir is `CLAUDE_CONFIG_DIR` or `~/.claude`.
|
|
13
|
+
* Each assistant content block is its own line; lines of one reply share
|
|
14
|
+
* `message.id`. Tool results come back as user lines holding `tool_result`
|
|
15
|
+
* blocks, and slash commands and `!` shell runs are user lines wrapped in
|
|
16
|
+
* tags. Everything else (attachments, titles, bookkeeping) is skipped.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
20
|
+
return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function claudeProjectDir(cwd: string, configDir: string = claudeConfigDir()): string {
|
|
24
|
+
return join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The session's transcript, or this project's most recently written one. */
|
|
28
|
+
export async function findTranscript(cwd: string, sessionId: string | undefined, configDir: string = claudeConfigDir()): Promise<string | null> {
|
|
29
|
+
const dir = claudeProjectDir(cwd, configDir);
|
|
30
|
+
if (sessionId) {
|
|
31
|
+
const path = join(dir, `${sessionId}.jsonl`);
|
|
32
|
+
if (await stat(path).then((info) => info.isFile(), () => false)) return path;
|
|
33
|
+
}
|
|
34
|
+
const names = await readdir(dir).catch(() => [] as string[]);
|
|
35
|
+
let newest: { path: string; mtime: number } | null = null;
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
38
|
+
const path = join(dir, name);
|
|
39
|
+
const info = await stat(path).catch(() => null);
|
|
40
|
+
if (info?.isFile() && (!newest || info.mtimeMs > newest.mtime)) newest = { path, mtime: info.mtimeMs };
|
|
41
|
+
}
|
|
42
|
+
return newest?.path ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function transcriptSessionId(path: string): string {
|
|
46
|
+
return basename(path, ".jsonl");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function record(value: unknown): Record<string, unknown> {
|
|
50
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function blocks(content: unknown): Record<string, unknown>[] {
|
|
54
|
+
return Array.isArray(content) ? content.map(record) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function textOf(content: unknown): string {
|
|
58
|
+
if (typeof content === "string") return content;
|
|
59
|
+
return blocks(content).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text as string).join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tag = (text: string, name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
|
|
63
|
+
|
|
64
|
+
/** A user line as the digest should see it: a prompt, a slash command, a shell run, or nothing. */
|
|
65
|
+
function userMessage(text: string): Record<string, unknown> | null {
|
|
66
|
+
if (!text.trim()) return null;
|
|
67
|
+
if (text.includes("<bash-input>")) return { type: "shell" };
|
|
68
|
+
const command = tag(text, "command-name");
|
|
69
|
+
if (command) return { type: "user", text: [command, tag(text, "command-args")].filter(Boolean).join(" ") };
|
|
70
|
+
// Command echoes and shell output are the harness talking, not the user.
|
|
71
|
+
if (/^\s*<(local-command-|bash-std|command-message)/.test(text)) return null;
|
|
72
|
+
return { type: "user", text };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ClaudeTranscript {
|
|
76
|
+
digest: SessionDigest;
|
|
77
|
+
/** Changes only when the conversation does, so an unchanged session is not re-summarized. */
|
|
78
|
+
hash: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseClaudeTranscript(text: string, fallbackId = ""): ClaudeTranscript {
|
|
82
|
+
const entries: Record<string, unknown>[] = [];
|
|
83
|
+
for (const line of text.split("\n")) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
try {
|
|
86
|
+
entries.push(record(JSON.parse(line)));
|
|
87
|
+
} catch {
|
|
88
|
+
// A line cut off mid-write is expected while the session is live.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const failedTools = new Set<string>();
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
for (const block of blocks(record(entry.message).content)) {
|
|
94
|
+
if (block.type === "tool_result" && block.is_error === true && typeof block.tool_use_id === "string") failedTools.add(block.tool_use_id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let id = "";
|
|
99
|
+
let cwd = "";
|
|
100
|
+
let startedAt = "";
|
|
101
|
+
let endedAt = "";
|
|
102
|
+
let aiTitle: string | null = null;
|
|
103
|
+
let customTitle: string | null = null;
|
|
104
|
+
const messages: Record<string, unknown>[] = [];
|
|
105
|
+
const replies = new Map<string, Record<string, unknown>>();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (typeof entry.aiTitle === "string" && entry.aiTitle.trim()) aiTitle = entry.aiTitle.trim();
|
|
108
|
+
if (typeof entry.customTitle === "string" && entry.customTitle.trim()) customTitle = entry.customTitle.trim();
|
|
109
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
110
|
+
if (entry.isSidechain === true) continue;
|
|
111
|
+
id ||= typeof entry.sessionId === "string" ? entry.sessionId : "";
|
|
112
|
+
cwd ||= typeof entry.cwd === "string" ? entry.cwd : "";
|
|
113
|
+
if (typeof entry.timestamp === "string") {
|
|
114
|
+
startedAt ||= entry.timestamp;
|
|
115
|
+
endedAt = entry.timestamp;
|
|
116
|
+
}
|
|
117
|
+
const message = record(entry.message);
|
|
118
|
+
if (entry.type === "user") {
|
|
119
|
+
if (entry.isMeta === true) continue;
|
|
120
|
+
const text = textOf(message.content);
|
|
121
|
+
if (entry.isCompactSummary === true) {
|
|
122
|
+
messages.push({ type: "compaction", summary: text });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const mapped = userMessage(text);
|
|
126
|
+
if (mapped) messages.push(mapped);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const key = typeof message.id === "string" ? message.id : `line-${messages.length}`;
|
|
130
|
+
let reply = replies.get(key);
|
|
131
|
+
if (!reply) {
|
|
132
|
+
const model = typeof message.model === "string" && message.model !== "<synthetic>" ? { providerID: "anthropic", id: message.model } : {};
|
|
133
|
+
reply = { type: "assistant", model, error: entry.isApiErrorMessage === true, content: [] };
|
|
134
|
+
replies.set(key, reply);
|
|
135
|
+
messages.push(reply);
|
|
136
|
+
}
|
|
137
|
+
const content = reply.content as Record<string, unknown>[];
|
|
138
|
+
for (const block of blocks(message.content)) {
|
|
139
|
+
if (block.type === "text") content.push({ type: "text", text: block.text });
|
|
140
|
+
if (block.type === "tool_use") {
|
|
141
|
+
content.push({ type: "tool", name: block.name, state: { status: failedTools.has(String(block.id)) ? "error" : "completed" } });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const digest = digestFromMessages({
|
|
146
|
+
id: id || fallbackId,
|
|
147
|
+
cwd,
|
|
148
|
+
parentSession: null,
|
|
149
|
+
startedAt,
|
|
150
|
+
endedAt: endedAt || startedAt,
|
|
151
|
+
name: customTitle ?? aiTitle,
|
|
152
|
+
}, messages);
|
|
153
|
+
return { digest, hash: hashContent(JSON.stringify(messages)) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function readClaudeTranscript(path: string): Promise<ClaudeTranscript> {
|
|
157
|
+
return parseClaudeTranscript(await readFile(path, "utf8"), transcriptSessionId(path));
|
|
158
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { SessionDigest } from "./sessions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One harness-neutral way to build a {@link SessionDigest}.
|
|
5
|
+
*
|
|
6
|
+
* Pi sessions are parsed from their own JSONL by `parseSessionDigest`. Every
|
|
7
|
+
* other harness (OpenCode's session API, Claude Code's transcripts) maps its
|
|
8
|
+
* records onto the small message shape below and lets this function do the
|
|
9
|
+
* counting, clipping and capping, so the digests stay identical in form.
|
|
10
|
+
*
|
|
11
|
+
* Messages are `unknown` on purpose: records come from outside the process,
|
|
12
|
+
* and anything malformed is skipped rather than trusted.
|
|
13
|
+
*
|
|
14
|
+
* - `{ type: "user", text }`
|
|
15
|
+
* - `{ type: "shell" }` — a direct shell execution by the user
|
|
16
|
+
* - `{ type: "compaction", summary }`
|
|
17
|
+
* - `{ type: "assistant", model: { providerID, id }, finish?, error?, content: [{ type: "text", text } | { type: "tool", name, state: { status } }] }`
|
|
18
|
+
*/
|
|
19
|
+
export interface DigestSessionInfo {
|
|
20
|
+
id: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
parentSession: string | null;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
endedAt: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function record(value: unknown): Record<string, unknown> {
|
|
29
|
+
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clipped(value: unknown, max: number): string {
|
|
33
|
+
if (typeof value !== "string") return "";
|
|
34
|
+
const text = value.trim();
|
|
35
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function digestFromMessages(info: DigestSessionInfo, messages: readonly unknown[]): SessionDigest {
|
|
39
|
+
const digest: SessionDigest = {
|
|
40
|
+
...info,
|
|
41
|
+
models: [],
|
|
42
|
+
userCount: 0,
|
|
43
|
+
assistantCount: 0,
|
|
44
|
+
toolResultCount: 0,
|
|
45
|
+
errors: 0,
|
|
46
|
+
bashCount: 0,
|
|
47
|
+
tools: {},
|
|
48
|
+
firstUserMessage: null,
|
|
49
|
+
userMessages: [],
|
|
50
|
+
compactions: [],
|
|
51
|
+
branchSummaries: [],
|
|
52
|
+
lastAssistantText: null,
|
|
53
|
+
};
|
|
54
|
+
for (const raw of messages) {
|
|
55
|
+
const message = record(raw);
|
|
56
|
+
if (message.type === "user") {
|
|
57
|
+
digest.userCount += 1;
|
|
58
|
+
const text = clipped(message.text, 400);
|
|
59
|
+
if (text) {
|
|
60
|
+
digest.userMessages.push(text);
|
|
61
|
+
if (digest.userMessages.length > 60) digest.userMessages.shift();
|
|
62
|
+
digest.firstUserMessage ??= clipped(text, 120);
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (message.type === "shell") {
|
|
67
|
+
digest.bashCount += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (message.type === "compaction") {
|
|
71
|
+
const summary = clipped(message.summary, 1_200);
|
|
72
|
+
if (summary) {
|
|
73
|
+
digest.compactions.push(summary);
|
|
74
|
+
if (digest.compactions.length > 3) digest.compactions.shift();
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (message.type !== "assistant") continue;
|
|
79
|
+
digest.assistantCount += 1;
|
|
80
|
+
const model = record(message.model);
|
|
81
|
+
if (typeof model.providerID === "string" && typeof model.id === "string") {
|
|
82
|
+
const label = `${model.providerID}/${model.id}`;
|
|
83
|
+
if (!digest.models.includes(label)) digest.models.push(label);
|
|
84
|
+
}
|
|
85
|
+
if (message.finish === "error" || message.error) digest.errors += 1;
|
|
86
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
87
|
+
const text = content
|
|
88
|
+
.filter((part) => record(part).type === "text")
|
|
89
|
+
.map((part) => record(part).text)
|
|
90
|
+
.filter((part): part is string => typeof part === "string")
|
|
91
|
+
.join("\n");
|
|
92
|
+
if (text.trim()) digest.lastAssistantText = clipped(text, 1_200);
|
|
93
|
+
for (const part of content) {
|
|
94
|
+
const tool = record(part);
|
|
95
|
+
if (tool.type !== "tool") continue;
|
|
96
|
+
digest.toolResultCount += 1;
|
|
97
|
+
const name = typeof tool.name === "string" ? tool.name : "unknown";
|
|
98
|
+
digest.tools[name] = (digest.tools[name] ?? 0) + 1;
|
|
99
|
+
if (record(tool.state).status === "error") digest.errors += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return digest;
|
|
103
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { executeNoteAction, WEAVE_NOTE_DESCRIPTION, type NoteActionInput, type NoteActionResult } from "./noteAction";
|
|
2
|
+
import { executeRepoAction, WEAVE_REPO_DESCRIPTION, type RepoActionInput } from "./repoAction";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `weave_note` and `weave_repo` tools, defined once for every harness.
|
|
6
|
+
*
|
|
7
|
+
* Plain JSON Schema, because that is the one format Pi, OpenCode and MCP
|
|
8
|
+
* (Claude Code) all accept. Each adapter only maps these definitions onto its
|
|
9
|
+
* own registration call and adds what is genuinely harness-specific, such as
|
|
10
|
+
* Pi's prompt guidelines.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const noteInput = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
action: { type: "string", enum: ["list", "get", "add", "append", "finalize", "search", "links", "suggest"] },
|
|
17
|
+
title: { type: "string", description: "Note title (add)" },
|
|
18
|
+
text: { type: "string", description: "Markdown body (add), addition (append), or restructured body above the raw tail (finalize)" },
|
|
19
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags (add)" },
|
|
20
|
+
slug: { type: "string", description: "Note slug (get, append, finalize)" },
|
|
21
|
+
raw: {
|
|
22
|
+
type: "boolean",
|
|
23
|
+
description: "add/append: keep text as verbatim dictation in the ## Raw tail (timestamped fenced block; tail created if missing). Use for the user's own words, including the first words of a dictated note; omit for structured Markdown",
|
|
24
|
+
},
|
|
25
|
+
source: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: ["human", "agent"],
|
|
28
|
+
description: "Provenance (add): human for user-scribbled notes, agent for agent-drafted (default agent)",
|
|
29
|
+
},
|
|
30
|
+
query: { type: "string", description: "Search query (search)" },
|
|
31
|
+
fix: { type: "boolean", description: "links: apply the unambiguous repairs. Omit for a read-only report" },
|
|
32
|
+
limit: { type: "number", description: "suggest: how many suggestions to return (default 20)" },
|
|
33
|
+
},
|
|
34
|
+
required: ["action"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const repoInput = {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
action: { type: "string", enum: ["overview"] },
|
|
42
|
+
},
|
|
43
|
+
required: ["action"],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export const weaveTools = [
|
|
48
|
+
{
|
|
49
|
+
name: "weave_note",
|
|
50
|
+
description: WEAVE_NOTE_DESCRIPTION,
|
|
51
|
+
input: noteInput,
|
|
52
|
+
execute: (input: unknown, _cwd: string, _progress?: (text: string) => Promise<void>): Promise<NoteActionResult> =>
|
|
53
|
+
executeNoteAction(input as NoteActionInput),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "weave_repo",
|
|
57
|
+
description: WEAVE_REPO_DESCRIPTION,
|
|
58
|
+
input: repoInput,
|
|
59
|
+
execute: (input: unknown, cwd: string, _progress?: (text: string) => Promise<void>) => executeRepoAction(input as RepoActionInput, cwd),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export type WeaveTool = (typeof weaveTools)[number];
|
package/src/opencode/commands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { hashContent } from "../core";
|
|
2
|
-
import
|
|
1
|
+
import { hashContent, type SessionDigest } from "../core";
|
|
2
|
+
import { digestFromMessages } from "../core/sessionDigest";
|
|
3
3
|
import { createWorkspaceCommands, type WorkspaceScanStatus } from "../core/workspaceCommands";
|
|
4
4
|
import type { WorkspaceServerController } from "../web/server/controller";
|
|
5
5
|
|
|
@@ -15,86 +15,19 @@ function timestamp(value: unknown): string {
|
|
|
15
15
|
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : "";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function clipped(value: unknown, max: number): string {
|
|
19
|
-
if (typeof value !== "string") return "";
|
|
20
|
-
const text = value.trim();
|
|
21
|
-
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
18
|
/** Convert OpenCode's public session context to the portable session digest. */
|
|
25
19
|
export function openCodeSessionDigest(session: unknown, messages: readonly unknown[]): SessionDigest {
|
|
26
20
|
const info = record(session);
|
|
27
21
|
const time = record(info.time);
|
|
28
22
|
const location = record(info.location);
|
|
29
|
-
|
|
30
|
-
const digest: SessionDigest = {
|
|
23
|
+
return digestFromMessages({
|
|
31
24
|
id: String(info.id ?? ""),
|
|
32
25
|
cwd: typeof location.directory === "string" ? location.directory : "",
|
|
33
26
|
parentSession: typeof info.parentID === "string" ? info.parentID : null,
|
|
34
27
|
startedAt: timestamp(time.created),
|
|
35
28
|
endedAt: timestamp(time.updated),
|
|
36
29
|
name: typeof info.title === "string" && info.title.trim() ? info.title.trim() : null,
|
|
37
|
-
|
|
38
|
-
userCount: 0,
|
|
39
|
-
assistantCount: 0,
|
|
40
|
-
toolResultCount: 0,
|
|
41
|
-
errors: 0,
|
|
42
|
-
bashCount: 0,
|
|
43
|
-
tools: {},
|
|
44
|
-
firstUserMessage: null,
|
|
45
|
-
userMessages: [],
|
|
46
|
-
compactions: [],
|
|
47
|
-
branchSummaries: [],
|
|
48
|
-
lastAssistantText: null,
|
|
49
|
-
};
|
|
50
|
-
for (const raw of relevant) {
|
|
51
|
-
const message = record(raw);
|
|
52
|
-
if (message.type === "user") {
|
|
53
|
-
digest.userCount += 1;
|
|
54
|
-
const text = clipped(message.text, 400);
|
|
55
|
-
if (text) {
|
|
56
|
-
digest.userMessages.push(text);
|
|
57
|
-
if (digest.userMessages.length > 60) digest.userMessages.shift();
|
|
58
|
-
digest.firstUserMessage ??= clipped(text, 120);
|
|
59
|
-
}
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (message.type === "shell") {
|
|
63
|
-
digest.bashCount += 1;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (message.type === "compaction") {
|
|
67
|
-
const summary = clipped(message.summary, 1_200);
|
|
68
|
-
if (summary) {
|
|
69
|
-
digest.compactions.push(summary);
|
|
70
|
-
if (digest.compactions.length > 3) digest.compactions.shift();
|
|
71
|
-
}
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
digest.assistantCount += 1;
|
|
75
|
-
const model = record(message.model);
|
|
76
|
-
if (typeof model.providerID === "string" && typeof model.id === "string") {
|
|
77
|
-
const label = `${model.providerID}/${model.id}`;
|
|
78
|
-
if (!digest.models.includes(label)) digest.models.push(label);
|
|
79
|
-
}
|
|
80
|
-
if (message.finish === "error" || message.error) digest.errors += 1;
|
|
81
|
-
const content = Array.isArray(message.content) ? message.content : [];
|
|
82
|
-
const text = content
|
|
83
|
-
.filter((part) => record(part).type === "text")
|
|
84
|
-
.map((part) => record(part).text)
|
|
85
|
-
.filter((part): part is string => typeof part === "string")
|
|
86
|
-
.join("\n");
|
|
87
|
-
if (text.trim()) digest.lastAssistantText = clipped(text, 1_200);
|
|
88
|
-
for (const part of content) {
|
|
89
|
-
const tool = record(part);
|
|
90
|
-
if (tool.type !== "tool") continue;
|
|
91
|
-
digest.toolResultCount += 1;
|
|
92
|
-
const name = typeof tool.name === "string" ? tool.name : "unknown";
|
|
93
|
-
digest.tools[name] = (digest.tools[name] ?? 0) + 1;
|
|
94
|
-
if (record(tool.state).status === "error") digest.errors += 1;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return digest;
|
|
30
|
+
}, messages);
|
|
98
31
|
}
|
|
99
32
|
|
|
100
33
|
export async function registerOpenCodeCommands(
|
package/src/opencode/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { formatStatusLine, getWorkspaceStatus } from "../core";
|
|
4
4
|
import { parseFrontMatter, unquoteField } from "../core/frontmatter";
|
|
5
5
|
import { registerOpenCodeCommands } from "./commands";
|
|
6
|
-
import { weaveTools } from "
|
|
6
|
+
import { weaveTools } from "../core/tools";
|
|
7
7
|
import { WEAVE_RPC } from "./rpc";
|
|
8
8
|
import { WorkspaceServerController } from "../web/server/controller";
|
|
9
9
|
|
package/src/opencode/v1.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { hashContent } from "../core";
|
|
|
6
6
|
import { createWorkspaceCommands } from "../core/workspaceCommands";
|
|
7
7
|
import { WorkspaceServerController } from "../web/server/controller";
|
|
8
8
|
import { openCodeSessionDigest } from "./commands";
|
|
9
|
-
import { weaveTools } from "
|
|
9
|
+
import { weaveTools } from "../core/tools";
|
|
10
10
|
|
|
11
11
|
type Client = Parameters<Plugin>[0]["client"];
|
|
12
12
|
type CommandResult = { lines: string[]; viewer?: { url: string; open: boolean } };
|
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from "../../core";
|
|
2
|
+
import { formatRawAppend } from "../../core";
|
|
3
|
+
import { weaveTools } from "../../core/tools";
|
|
4
|
+
import { piParameters } from "./parameters";
|
|
5
|
+
|
|
6
|
+
const tool = weaveTools.find((definition) => definition.name === "weave_note")!;
|
|
9
7
|
|
|
10
8
|
/** `weave_note` — the smart-notepad tool (design §1: vault knowledge). */
|
|
11
9
|
export function registerNoteTool(pi: ExtensionAPI): void {
|
|
12
10
|
pi.registerTool({
|
|
13
|
-
name:
|
|
11
|
+
name: tool.name,
|
|
14
12
|
label: "Weave Note",
|
|
15
|
-
description:
|
|
13
|
+
description: tool.description,
|
|
16
14
|
promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
|
|
17
15
|
promptGuidelines: [
|
|
18
16
|
"Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
|
|
@@ -20,20 +18,10 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
20
18
|
"Use weave_note with action=links to find and repair stale [[wiki-links]] deterministically instead of rereading the vault to reconnect notes by hand; add fix=true to apply the unambiguous repairs.",
|
|
21
19
|
"Use weave_note with action=suggest to discover notes that belong together but are not linked (optionally scoped to one slug); it only reports — propose the links to the user rather than writing them.",
|
|
22
20
|
],
|
|
23
|
-
parameters:
|
|
24
|
-
action: StringEnum(["list", "get", "add", "append", "finalize", "search", "links", "suggest"] as const),
|
|
25
|
-
title: Type.Optional(Type.String({ description: "Note title (add)" })),
|
|
26
|
-
text: Type.Optional(Type.String({ description: "Markdown body (add), addition (append), or restructured body above the raw tail (finalize)" })),
|
|
27
|
-
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags (add)" })),
|
|
28
|
-
slug: Type.Optional(Type.String({ description: "Note slug (get, append, finalize)" })),
|
|
29
|
-
raw: Type.Optional(Type.Boolean({ description: "add/append: keep text as verbatim dictation in the ## Raw tail (timestamped fenced block; tail created if missing). Use for the user's own words, including the first words of a dictated note; omit for structured Markdown" })),
|
|
30
|
-
source: Type.Optional(StringEnum(["human", "agent"] as const, { description: "Provenance (add): human for user-scribbled notes, agent for Pi-drafted (default agent)" })),
|
|
31
|
-
query: Type.Optional(Type.String({ description: "Search query (search)" })),
|
|
32
|
-
fix: Type.Optional(Type.Boolean({ description: "links: apply the unambiguous repairs. Omit for a read-only report" })),
|
|
33
|
-
limit: Type.Optional(Type.Number({ description: "suggest: how many suggestions to return (default 20)" })),
|
|
34
|
-
}),
|
|
21
|
+
parameters: piParameters(tool.input),
|
|
35
22
|
async execute(_toolCallId, params) {
|
|
36
|
-
|
|
23
|
+
// The vault is global, so weave_note ignores the working directory.
|
|
24
|
+
const result = await tool.execute(params, "");
|
|
37
25
|
return { content: [{ type: "text", text: result.text }], details: result.details };
|
|
38
26
|
},
|
|
39
27
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pi accepts plain JSON Schema for tool parameters: its validator coerces and
|
|
5
|
+
* checks schemas that carry no TypeBox kind. The shared definitions in
|
|
6
|
+
* `src/core/tools.ts` are plain JSON Schema, so they only need the type.
|
|
7
|
+
*/
|
|
8
|
+
export function piParameters(schema: object): TSchema {
|
|
9
|
+
return structuredClone(schema) as TSchema;
|
|
10
|
+
}
|
package/src/pi/tools/repoTool.ts
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { weaveTools } from "../../core/tools";
|
|
3
|
+
import { piParameters } from "./parameters";
|
|
4
|
+
|
|
5
|
+
const tool = weaveTools.find((definition) => definition.name === "weave_repo")!;
|
|
5
6
|
|
|
6
7
|
/** `weave_repo` — the repository-exploration tool (design §2/§8). */
|
|
7
8
|
export function registerRepoTool(pi: ExtensionAPI): void {
|
|
8
9
|
pi.registerTool({
|
|
9
|
-
name:
|
|
10
|
+
name: tool.name,
|
|
10
11
|
label: "Weave Repo",
|
|
11
|
-
description:
|
|
12
|
+
description: tool.description,
|
|
12
13
|
promptSnippet: "Explore the repository's structure via its .okf knowledge index",
|
|
13
14
|
promptGuidelines: [
|
|
14
15
|
"Use weave_repo action=overview to learn repository structure before broad code exploration instead of scanning files one by one.",
|
|
15
16
|
],
|
|
16
|
-
parameters:
|
|
17
|
-
action: StringEnum(["overview"] as const),
|
|
18
|
-
}),
|
|
17
|
+
parameters: piParameters(tool.input),
|
|
19
18
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
20
|
-
const result = await
|
|
19
|
+
const result = await tool.execute(params, ctx.cwd);
|
|
21
20
|
return { content: [{ type: "text", text: result.text }], details: result.details };
|
|
22
21
|
},
|
|
23
22
|
});
|
package/src/opencode/tools.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { executeNoteAction, executeRepoAction, WEAVE_NOTE_DESCRIPTION, WEAVE_REPO_DESCRIPTION, type NoteActionInput, type RepoActionInput } from "../core";
|
|
2
|
-
|
|
3
|
-
const noteInput = {
|
|
4
|
-
type: "object",
|
|
5
|
-
properties: {
|
|
6
|
-
action: { type: "string", enum: ["list", "get", "add", "append", "finalize", "search", "links", "suggest"] },
|
|
7
|
-
title: { type: "string", description: "Note title (add)" },
|
|
8
|
-
text: { type: "string", description: "Markdown body (add), addition (append), or restructured body (finalize)" },
|
|
9
|
-
tags: { type: "array", items: { type: "string" }, description: "Tags (add)" },
|
|
10
|
-
slug: { type: "string", description: "Note slug (get, append, finalize)" },
|
|
11
|
-
raw: { type: "boolean", description: "add/append: preserve text verbatim in the ## Raw tail (use for the user's own words)" },
|
|
12
|
-
source: { type: "string", enum: ["human", "agent"], description: "Provenance (add; defaults to agent)" },
|
|
13
|
-
query: { type: "string", description: "Search query (search)" },
|
|
14
|
-
fix: { type: "boolean", description: "links: apply unambiguous repairs" },
|
|
15
|
-
limit: { type: "number", description: "suggest: maximum suggestions (default 20)" },
|
|
16
|
-
},
|
|
17
|
-
required: ["action"],
|
|
18
|
-
additionalProperties: false,
|
|
19
|
-
} as const;
|
|
20
|
-
|
|
21
|
-
const repoInput = {
|
|
22
|
-
type: "object",
|
|
23
|
-
properties: {
|
|
24
|
-
action: { type: "string", enum: ["overview"] },
|
|
25
|
-
},
|
|
26
|
-
required: ["action"],
|
|
27
|
-
additionalProperties: false,
|
|
28
|
-
} as const;
|
|
29
|
-
|
|
30
|
-
export const weaveTools = [
|
|
31
|
-
{
|
|
32
|
-
name: "weave_note",
|
|
33
|
-
description: WEAVE_NOTE_DESCRIPTION,
|
|
34
|
-
input: noteInput,
|
|
35
|
-
execute: (input: unknown, _cwd: string, _progress?: (text: string) => Promise<void>) => executeNoteAction(input as NoteActionInput),
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: "weave_repo",
|
|
39
|
-
description: WEAVE_REPO_DESCRIPTION,
|
|
40
|
-
input: repoInput,
|
|
41
|
-
execute: (input: unknown, cwd: string) => executeRepoAction(input as RepoActionInput, cwd),
|
|
42
|
-
},
|
|
43
|
-
];
|