pi-weave 0.3.12 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -8
- package/package.json +6 -3
- package/src/claude/cli.ts +13 -0
- package/src/claude/control.ts +84 -0
- package/src/claude/generate.ts +62 -0
- package/src/claude/index.ts +87 -0
- package/src/claude/mcp.ts +105 -0
- package/src/claude/server.ts +154 -0
- package/src/claude/sessions.ts +158 -0
- package/src/core/index.ts +3 -0
- package/src/core/sessionDigest.ts +103 -0
- package/src/core/tools.ts +63 -0
- package/src/core/vault.ts +79 -5
- package/src/core/view/tree.ts +2 -2
- package/src/opencode/commands.ts +4 -71
- package/src/opencode/index.ts +1 -1
- package/src/opencode/v1.ts +1 -1
- package/src/pi/tools/noteTool.ts +10 -22
- package/src/pi/tools/parameters.ts +10 -0
- package/src/pi/tools/repoTool.ts +8 -9
- package/src/web/client/api.ts +4 -0
- package/src/web/client/dist/app.js +48 -48
- package/src/web/client/shell/shell.model.ts +3 -2
- package/src/web/client/tree/Tree.tsx +8 -6
- package/src/web/client/tree/tree.model.ts +22 -7
- package/src/web/server/routes.ts +25 -3
- package/src/opencode/tools.ts +0 -43
|
@@ -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
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -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/core/vault.ts
CHANGED
|
@@ -764,6 +764,80 @@ export async function moveNote(root: string, slug: string, folder: string | null
|
|
|
764
764
|
});
|
|
765
765
|
}
|
|
766
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Move a standalone HTML artifact to an existing vault folder, or to the vault
|
|
769
|
+
* root with `null`. Notes that link to it by path follow it, as they do a note.
|
|
770
|
+
*/
|
|
771
|
+
export async function moveHtmlArtifact(root: string, slug: string, folder: string | null): Promise<VaultMutationResult> {
|
|
772
|
+
const from = resolveHtmlPath(root, slug);
|
|
773
|
+
if (from === null) return { ok: false, reason: "invalid" };
|
|
774
|
+
const targetDir = folder === null ? join(root, NOTES_DIR) : resolveFolderPath(root, folder);
|
|
775
|
+
if (targetDir === null || !(await isDirectory(targetDir))) return { ok: false, reason: "missing" };
|
|
776
|
+
const target = folder === null ? basename(slug) : `${folder}/${basename(slug)}`;
|
|
777
|
+
const to = resolveHtmlPath(root, target)!;
|
|
778
|
+
return withVaultLock(root, async () => {
|
|
779
|
+
if (!(await exists(from))) return { ok: false, reason: "missing" };
|
|
780
|
+
if (from === to) return { ok: true, slug };
|
|
781
|
+
if (await exists(to)) return { ok: false, reason: "collision" };
|
|
782
|
+
await fs.rename(from, to);
|
|
783
|
+
await repointBacklinks(root, new Map([[slug, target]]));
|
|
784
|
+
return { ok: true, slug: target };
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Rename a standalone HTML artifact in place. The new name sets the file name
|
|
790
|
+
* (slugified, extension kept) and the title the workspace shows, which lives
|
|
791
|
+
* in a leading `<!-- --- title: ... --- -->` comment or in `<title>`; a file
|
|
792
|
+
* with neither takes its title from the file name. Links follow the file.
|
|
793
|
+
*/
|
|
794
|
+
export async function renameHtmlArtifact(root: string, slug: string, name: string): Promise<VaultMutationResult> {
|
|
795
|
+
const from = resolveHtmlPath(root, slug);
|
|
796
|
+
const title = name.trim().replace(/\.html?$/i, "").trim();
|
|
797
|
+
if (from === null || title === "") return { ok: false, reason: "invalid" };
|
|
798
|
+
const ext = /\.html?$/i.exec(slug)![0];
|
|
799
|
+
const parent = slug.split("/").slice(0, -1).join("/");
|
|
800
|
+
const target = [...(parent === "" ? [] : [parent]), `${slugify(title)}${ext}`].join("/");
|
|
801
|
+
const to = resolveHtmlPath(root, target)!;
|
|
802
|
+
return withVaultLock(root, async () => {
|
|
803
|
+
let text: string;
|
|
804
|
+
try {
|
|
805
|
+
text = await fs.readFile(from, "utf8");
|
|
806
|
+
} catch {
|
|
807
|
+
return { ok: false, reason: "missing" };
|
|
808
|
+
}
|
|
809
|
+
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
810
|
+
await fs.writeFile(from, retitleHtml(text, title), "utf8");
|
|
811
|
+
if (from === to) return { ok: true, slug };
|
|
812
|
+
await fs.rename(from, to);
|
|
813
|
+
await repointBacklinks(root, new Map([[slug, target]]));
|
|
814
|
+
return { ok: true, slug: target };
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Set the title `parseHtmlArtifact` reads, leaving every other byte alone. */
|
|
819
|
+
function retitleHtml(text: string, title: string): string {
|
|
820
|
+
const comment = /<!--[\s\S]*?---\n([\s\S]*?)\n---[\s\S]*?-->/m.exec(text);
|
|
821
|
+
if (comment !== null && /^title:.*$/m.test(comment[1]!)) {
|
|
822
|
+
// `-->` would close the comment early.
|
|
823
|
+
const fields = comment[1]!.replace(/^title:.*$/m, () => `title: ${quoteField(title.replace(/-->/g, "->"))}`);
|
|
824
|
+
return text.slice(0, comment.index) + comment[0].replace(comment[1]!, () => fields) + text.slice(comment.index + comment[0].length);
|
|
825
|
+
}
|
|
826
|
+
const escaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
827
|
+
return text.replace(/(<title\b[^>]*>)[\s\S]*?(<\/title>)/i, (_, open: string, close: string) => `${open}${escaped}${close}`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Permanently delete one standalone HTML artifact. */
|
|
831
|
+
export async function deleteHtmlArtifact(root: string, slug: string): Promise<VaultMutationResult> {
|
|
832
|
+
const path = resolveHtmlPath(root, slug);
|
|
833
|
+
if (path === null) return { ok: false, reason: "invalid" };
|
|
834
|
+
return withVaultLock(root, async () => {
|
|
835
|
+
if (!(await exists(path))) return { ok: false, reason: "missing" };
|
|
836
|
+
await fs.unlink(path);
|
|
837
|
+
return { ok: true };
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
767
841
|
/** Permanently delete one note. */
|
|
768
842
|
export async function deleteNote(root: string, slug: string): Promise<VaultMutationResult> {
|
|
769
843
|
const path = resolveNotePath(root, slug);
|
|
@@ -789,12 +863,12 @@ export async function renameFolder(root: string, folder: string, name: string):
|
|
|
789
863
|
if (!(await isDirectory(from))) return { ok: false, reason: "missing" };
|
|
790
864
|
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
791
865
|
if (from === to) return { ok: true, path: target };
|
|
792
|
-
// Every note under the folder changes slug, so the whole
|
|
793
|
-
// backlinks move with it — collected before the rename, while
|
|
794
|
-
// paths still exist.
|
|
866
|
+
// Every note and HTML file under the folder changes slug, so the whole
|
|
867
|
+
// subtree's backlinks move with it — collected before the rename, while
|
|
868
|
+
// the old paths still exist. HTML slugs keep their extension.
|
|
795
869
|
const moved = (await listNoteFiles(root))
|
|
796
|
-
.filter((f) =>
|
|
797
|
-
.map((f) => f.slice(0, -".md".length));
|
|
870
|
+
.filter((f) => f.startsWith(`${folder}/`))
|
|
871
|
+
.map((f) => isMarkdown(f) ? f.slice(0, -".md".length) : f);
|
|
798
872
|
await fs.rename(from, to);
|
|
799
873
|
await moveHistoryFolder(root, folder, target);
|
|
800
874
|
await repointBacklinks(root, new Map(moved.map((s) => [s, `${target}/${s.slice(folder.length + 1)}`])));
|
package/src/core/view/tree.ts
CHANGED
|
@@ -273,8 +273,8 @@ export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
|
|
|
273
273
|
export function treeEmptyHint(model: GraphModel): string | null {
|
|
274
274
|
const vault = model.nodes.find((n) => n.kind === "vault");
|
|
275
275
|
if (!vault) return null;
|
|
276
|
-
// no
|
|
277
|
-
const hasNotes = model.nodes.some((n) => n.kind === "note");
|
|
276
|
+
// nothing in the vault (no note, HTML file or folder) and no repository
|
|
277
|
+
const hasNotes = model.nodes.some((n) => n.kind === "note" || n.id.startsWith("artifact:") || n.id.startsWith("vfolder:"));
|
|
278
278
|
const hasRepo = model.nodes.some((n) => n.kind === "repository");
|
|
279
279
|
if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
|
|
280
280
|
return null;
|
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
|
});
|