pi-weave 0.2.3 → 0.3.0
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 +40 -21
- package/package.json +22 -5
- package/skills/weave-explore/SKILL.md +17 -33
- package/skills/weave-notepad/SKILL.md +57 -109
- package/skills/weave-notepad/references/link-repair.md +25 -73
- package/src/core/index.ts +27 -1
- package/src/core/links/similar.ts +97 -30
- package/src/core/noteAction.ts +343 -0
- package/src/core/repoAction.ts +84 -0
- package/src/core/sessions.ts +80 -1
- package/src/core/summaries.ts +16 -0
- package/src/core/vault.ts +78 -6
- package/src/core/workspace.ts +1 -1
- package/src/core/workspaceCommands.ts +227 -0
- package/src/opencode/commands.ts +138 -0
- package/src/opencode/index.ts +88 -0
- package/src/opencode/rpc.ts +37 -0
- package/src/opencode/tools.ts +43 -0
- package/src/opencode/tui.ts +96 -0
- package/src/opencode/v1.ts +161 -0
- package/src/pi/sessionScan.ts +4 -28
- package/src/pi/summarize.ts +10 -25
- package/src/pi/tools/noteTool.ts +8 -296
- package/src/pi/tools/repoTool.ts +8 -75
- package/src/pi/viewer/web/run.ts +16 -77
- package/src/web/client/dist/app.js +13 -13
- package/src/web/client/graph/graph.model.ts +6 -4
- package/src/web/server/controller.ts +79 -0
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -1,133 +1,22 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { join } from "node:path";
|
|
4
3
|
import { Type } from "typebox";
|
|
5
4
|
import {
|
|
6
|
-
|
|
7
|
-
appendToNote,
|
|
8
|
-
extractRawTail,
|
|
9
|
-
finalizeNote,
|
|
10
|
-
formatNote,
|
|
5
|
+
executeNoteAction,
|
|
11
6
|
formatRawAppend,
|
|
12
|
-
|
|
13
|
-
listNotes,
|
|
14
|
-
NOTES_DIR,
|
|
15
|
-
repairVaultLinks,
|
|
16
|
-
resolveNotePath,
|
|
17
|
-
withMutationQueue,
|
|
18
|
-
resolveVaultRoot,
|
|
19
|
-
searchNotes,
|
|
20
|
-
suggestLinks,
|
|
21
|
-
readVault,
|
|
22
|
-
type LinkRepairResult,
|
|
23
|
-
type SuggestionReport,
|
|
7
|
+
WEAVE_NOTE_DESCRIPTION,
|
|
24
8
|
} from "../../core";
|
|
25
9
|
|
|
26
|
-
/**
|
|
27
|
-
const LINK_REPORT_CAP = 20;
|
|
28
|
-
|
|
29
|
-
/** Cap on notes printed by `list`; `details.notes` still carries them all. */
|
|
30
|
-
const LIST_CAP = 50;
|
|
31
|
-
|
|
32
|
-
function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
|
|
33
|
-
const lines = items.slice(0, LINK_REPORT_CAP).map(render);
|
|
34
|
-
if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
|
|
35
|
-
return lines;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Render suggestions as the text the model reads.
|
|
40
|
-
*
|
|
41
|
-
* Every row carries the shared terms that earned it. A bare score is
|
|
42
|
-
* unreviewable; the evidence is what lets a human accept or reject a
|
|
43
|
-
* suggestion without opening both notes.
|
|
44
|
-
*/
|
|
45
|
-
function formatSuggestions(report: SuggestionReport, focus: string | undefined): string {
|
|
46
|
-
if (report.suggestions.length === 0) {
|
|
47
|
-
return focus === undefined
|
|
48
|
-
? `No unlinked notes share enough distinctive vocabulary to suggest a connection (${report.considered} note(s) considered).`
|
|
49
|
-
: `Nothing unlinked looks related to '${focus}' (${report.considered} note(s) considered).`;
|
|
50
|
-
}
|
|
51
|
-
const head = focus === undefined
|
|
52
|
-
? `${report.suggestions.length} suggested connection(s) across ${report.considered} note(s):`
|
|
53
|
-
: `${report.suggestions.length} note(s) look related to '${focus}':`;
|
|
54
|
-
const rows = report.suggestions.map((s) => {
|
|
55
|
-
const pair = focus === undefined ? `${s.a} ↔ ${s.b}` : s.a === focus ? s.b : s.a;
|
|
56
|
-
return ` ${s.score.toFixed(3)} ${pair}\n shared: ${s.shared.join(", ")}`;
|
|
57
|
-
});
|
|
58
|
-
return [
|
|
59
|
-
head,
|
|
60
|
-
...rows,
|
|
61
|
-
"",
|
|
62
|
-
"These are suggestions, not links — nothing was written. Add a [[wikilink]] to any pair worth keeping.",
|
|
63
|
-
].join("\n");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Render a link audit (and any repair) as the text the model reads. */
|
|
67
|
-
function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
|
|
68
|
-
const { audit } = result;
|
|
69
|
-
const stale = audit.total - audit.resolved;
|
|
70
|
-
// Occurrences, not rows. `total`/`resolved` count every `[[…]]` in the
|
|
71
|
-
// vault, while `fixable`/`unresolvable` are grouped (per note+target, per
|
|
72
|
-
// target). Reporting "70 stale" beside "40 unresolvable" with no unit
|
|
73
|
-
// invites the reader to subtract them and find 30 phantom links; saying
|
|
74
|
-
// what each number counts is the whole fix.
|
|
75
|
-
const lines = [
|
|
76
|
-
`${audit.total} wiki-link(s): ${audit.resolved} resolved, ${stale} stale.`,
|
|
77
|
-
];
|
|
78
|
-
if (applied) {
|
|
79
|
-
lines.push(
|
|
80
|
-
result.applied.length === 0
|
|
81
|
-
? "Nothing to repair automatically."
|
|
82
|
-
: `Repaired ${result.applied.length} link(s) across ${result.notes.length} note(s):`,
|
|
83
|
-
...capped(result.applied, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
|
|
84
|
-
);
|
|
85
|
-
} else if (audit.fixable.length > 0) {
|
|
86
|
-
lines.push(
|
|
87
|
-
`${audit.fixable.length} auto-fixable (re-run with fix: true):`,
|
|
88
|
-
...capped(audit.fixable, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
if (audit.ambiguous.length > 0) {
|
|
92
|
-
lines.push(
|
|
93
|
-
`${audit.ambiguous.length} ambiguous link(s) (several candidates — pick one and edit the note):`,
|
|
94
|
-
...capped(audit.ambiguous, (a) => ` ${a.slug}: [[${a.target}]] → ${a.candidates.join(" | ")}`),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
if (audit.unresolvable.length > 0) {
|
|
98
|
-
lines.push(
|
|
99
|
-
`${audit.unresolvable.length} unresolvable target(s) (no such note — write it or drop the link):`,
|
|
100
|
-
...capped(audit.unresolvable, (u) => ` [[${u.target}]] ← ${u.notes.join(", ")}`),
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
if (audit.fixable.length === 0 && audit.ambiguous.length === 0 && audit.unresolvable.length === 0) {
|
|
104
|
-
lines.push("Every link resolves.");
|
|
105
|
-
}
|
|
106
|
-
return lines.join("\n");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* `weave_note` — the smart-notepad tool (design §1: vault knowledge).
|
|
111
|
-
*
|
|
112
|
-
* The LLM uses this to remember decisions, facts, and preferences as plain
|
|
113
|
-
* Markdown notes that humans can read and edit directly on disk.
|
|
114
|
-
*/
|
|
10
|
+
/** `weave_note` — the smart-notepad tool (design §1: vault knowledge). */
|
|
115
11
|
export function registerNoteTool(pi: ExtensionAPI): void {
|
|
116
12
|
pi.registerTool({
|
|
117
13
|
name: "weave_note",
|
|
118
14
|
label: "Weave Note",
|
|
119
|
-
description:
|
|
120
|
-
"Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
|
|
121
|
-
"of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
|
|
122
|
-
"append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
|
|
123
|
-
"finalize (restructure a note above its raw tail), search (title/tags/body), " +
|
|
124
|
-
"links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
|
|
125
|
-
"suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
|
|
126
|
-
"Use it to remember decisions, facts, and user preferences across sessions.",
|
|
15
|
+
description: WEAVE_NOTE_DESCRIPTION,
|
|
127
16
|
promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
|
|
128
17
|
promptGuidelines: [
|
|
129
18
|
"Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
|
|
130
|
-
"Use weave_note with action=search before answering questions about past decisions, people, or projects;
|
|
19
|
+
"Use weave_note with action=get when the slug is known; otherwise use one targeted action=search before answering questions about past decisions, people, or projects. A resolved search already contains the full note; do not fetch it again or retry with reformulated queries.",
|
|
131
20
|
"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.",
|
|
132
21
|
"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.",
|
|
133
22
|
],
|
|
@@ -143,186 +32,9 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
143
32
|
fix: Type.Optional(Type.Boolean({ description: "links: apply the unambiguous repairs. Omit for a read-only report" })),
|
|
144
33
|
limit: Type.Optional(Type.Number({ description: "suggest: how many suggestions to return (default 20)" })),
|
|
145
34
|
}),
|
|
146
|
-
async execute(
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
switch (params.action) {
|
|
150
|
-
case "list": {
|
|
151
|
-
const notes = await listNotes(vault);
|
|
152
|
-
if (notes.length === 0) {
|
|
153
|
-
return {
|
|
154
|
-
content: [{ type: "text", text: `The vault at ${vault} has no notes yet.` }],
|
|
155
|
-
details: { action: "list", notes: [] },
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
// Truncated, because the whole list is rarely the answer and on a
|
|
159
|
-
// large vault it is actively harmful: hundreds of lines of slugs
|
|
160
|
-
// crowd out the conversation that prompted the call. Newest first
|
|
161
|
-
// (`listNotes` order), so the cap keeps what is most likely wanted,
|
|
162
|
-
// and the footer names `search` — the action that answers "is there
|
|
163
|
-
// a note about X" without reading the vault aloud.
|
|
164
|
-
const shown = notes.slice(0, LIST_CAP);
|
|
165
|
-
const lines = shown.map(
|
|
166
|
-
(n) => `- ${n.slug}: ${n.title}${n.tags.length > 0 ? ` [${n.tags.join(", ")}]` : ""} (updated ${n.updated}, source: ${n.source})`,
|
|
167
|
-
);
|
|
168
|
-
if (notes.length > shown.length) {
|
|
169
|
-
lines.push(
|
|
170
|
-
`… and ${notes.length - shown.length} more (newest ${shown.length} shown) — use action=search to find a specific note.`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
return {
|
|
174
|
-
content: [{ type: "text", text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}` }],
|
|
175
|
-
details: { action: "list", notes },
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
case "get": {
|
|
180
|
-
if (!params.slug) throw new Error("weave_note(get) requires 'slug'");
|
|
181
|
-
const note = await getNote(vault, params.slug);
|
|
182
|
-
if (!note) {
|
|
183
|
-
return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "get", found: false } };
|
|
184
|
-
}
|
|
185
|
-
return { content: [{ type: "text", text: formatNote(note) }], details: { action: "get", found: true, note } };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
case "add": {
|
|
189
|
-
if (!params.title) throw new Error("weave_note(add) requires 'title'");
|
|
190
|
-
if (!params.text) throw new Error("weave_note(add) requires 'text'");
|
|
191
|
-
const title = params.title;
|
|
192
|
-
const text = params.text;
|
|
193
|
-
// Serialized per vault: parallel adds of the same title must not
|
|
194
|
-
// race the unique-slug check and overwrite each other.
|
|
195
|
-
const note = await withMutationQueue(join(vault, NOTES_DIR), () =>
|
|
196
|
-
addNote(vault, {
|
|
197
|
-
title,
|
|
198
|
-
body: text,
|
|
199
|
-
...(params.tags ? { tags: params.tags } : {}),
|
|
200
|
-
...(params.source ? { source: params.source } : {}),
|
|
201
|
-
}),
|
|
202
|
-
);
|
|
203
|
-
return {
|
|
204
|
-
content: [{ type: "text", text: `Note created: ${note.slug} (${vault})` }],
|
|
205
|
-
details: { action: "add", note },
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
case "append": {
|
|
210
|
-
if (!params.slug) throw new Error("weave_note(append) requires 'slug'");
|
|
211
|
-
if (!params.text) throw new Error("weave_note(append) requires 'text'");
|
|
212
|
-
const slug = params.slug;
|
|
213
|
-
const text = params.text;
|
|
214
|
-
const path = resolveNotePath(vault, slug);
|
|
215
|
-
if (!path) {
|
|
216
|
-
return {
|
|
217
|
-
content: [
|
|
218
|
-
{
|
|
219
|
-
type: "text",
|
|
220
|
-
text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
|
|
221
|
-
},
|
|
222
|
-
],
|
|
223
|
-
details: { action: "append", found: false },
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
// Serialized read-modify-write: parallel weave_note appends (and
|
|
227
|
-
// pi's own file tools) targeting the same note would otherwise
|
|
228
|
-
// lose each other's additions.
|
|
229
|
-
const note = await withMutationQueue(path, () =>
|
|
230
|
-
appendToNote(vault, slug, text, new Date(), params.raw ? { raw: true } : {}),
|
|
231
|
-
);
|
|
232
|
-
if (!note) {
|
|
233
|
-
return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "append", found: false } };
|
|
234
|
-
}
|
|
235
|
-
return {
|
|
236
|
-
content: [{ type: "text", text: params.raw ? `Appended verbatim to the ## Raw tail of ${note.slug} (updated ${note.updated}).` : `Appended to ${note.slug} (updated ${note.updated}).` }],
|
|
237
|
-
details: { action: "append", found: true, note },
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
case "finalize": {
|
|
242
|
-
if (!params.slug) throw new Error("weave_note(finalize) requires 'slug'");
|
|
243
|
-
if (!params.text) throw new Error("weave_note(finalize) requires 'text'");
|
|
244
|
-
const slug = params.slug;
|
|
245
|
-
const text = params.text;
|
|
246
|
-
const path = resolveNotePath(vault, slug);
|
|
247
|
-
if (!path) {
|
|
248
|
-
return {
|
|
249
|
-
content: [
|
|
250
|
-
{
|
|
251
|
-
type: "text",
|
|
252
|
-
text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
|
|
253
|
-
},
|
|
254
|
-
],
|
|
255
|
-
details: { action: "finalize", found: false },
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
// Serialized read-modify-write, same as append: finalize replaces the
|
|
259
|
-
// body above the raw tail, so it must not race other writers.
|
|
260
|
-
const note = await withMutationQueue(path, () => finalizeNote(vault, slug, { body: text }));
|
|
261
|
-
if (!note) {
|
|
262
|
-
return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "finalize", found: false } };
|
|
263
|
-
}
|
|
264
|
-
// Be truthful about what was preserved: a note with no `## Raw`
|
|
265
|
-
// marker yet gets its whole pre-finalize body preserved as a new
|
|
266
|
-
// raw tail (never silently dropped).
|
|
267
|
-
const preserved = extractRawTail(note.body) !== "";
|
|
268
|
-
return {
|
|
269
|
-
content: [{ type: "text", text: `Finalized ${note.slug} (updated ${note.updated}). ${preserved ? "Raw tail preserved beneath the structured body." : "Note body was empty — nothing to preserve."}` }],
|
|
270
|
-
details: { action: "finalize", found: true, note },
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
case "search": {
|
|
275
|
-
if (!params.query) throw new Error("weave_note(search) requires 'query'");
|
|
276
|
-
const hits = await searchNotes(vault, params.query);
|
|
277
|
-
if (hits.length === 0) {
|
|
278
|
-
return {
|
|
279
|
-
content: [{ type: "text", text: `No notes matched '${params.query}'.` }],
|
|
280
|
-
details: { action: "search", hits: [] },
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
const lines = hits.map(
|
|
284
|
-
(h) => `- ${h.summary.slug}: ${h.summary.title} (score ${h.score})\n ${h.snippet}`,
|
|
285
|
-
);
|
|
286
|
-
return {
|
|
287
|
-
content: [{ type: "text", text: `${hits.length} hit(s) for '${params.query}':\n${lines.join("\n")}` }],
|
|
288
|
-
details: { action: "search", hits },
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
case "suggest": {
|
|
293
|
-
const { notes } = await readVault(vault);
|
|
294
|
-
const report = suggestLinks(
|
|
295
|
-
{ notes },
|
|
296
|
-
{
|
|
297
|
-
...(params.slug ? { slug: params.slug } : {}),
|
|
298
|
-
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
299
|
-
},
|
|
300
|
-
);
|
|
301
|
-
return {
|
|
302
|
-
content: [{ type: "text", text: formatSuggestions(report, params.slug) }],
|
|
303
|
-
details: { action: "suggest", considered: report.considered, suggestions: report.suggestions },
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
case "links": {
|
|
308
|
-
const apply = params.fix === true;
|
|
309
|
-
const result = await repairVaultLinks(vault, apply ? { apply: true } : {});
|
|
310
|
-
return {
|
|
311
|
-
content: [{ type: "text", text: formatLinkReport(result, apply) }],
|
|
312
|
-
details: {
|
|
313
|
-
action: "links",
|
|
314
|
-
fixed: apply,
|
|
315
|
-
total: result.audit.total,
|
|
316
|
-
resolved: result.audit.resolved,
|
|
317
|
-
fixable: result.audit.fixable,
|
|
318
|
-
ambiguous: result.audit.ambiguous,
|
|
319
|
-
unresolvable: result.audit.unresolvable,
|
|
320
|
-
applied: result.applied,
|
|
321
|
-
notes: result.notes,
|
|
322
|
-
},
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
}
|
|
35
|
+
async execute(_toolCallId, params) {
|
|
36
|
+
const result = await executeNoteAction(params);
|
|
37
|
+
return { content: [{ type: "text", text: result.text }], details: result.details };
|
|
326
38
|
},
|
|
327
39
|
});
|
|
328
40
|
}
|
package/src/pi/tools/repoTool.ts
CHANGED
|
@@ -1,31 +1,14 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import {
|
|
5
|
-
assessStaleness,
|
|
6
|
-
buildRepoIndex,
|
|
7
|
-
findGitRoot,
|
|
8
|
-
readRepoIndex,
|
|
9
|
-
repoIndexDir,
|
|
10
|
-
summarizeIndex,
|
|
11
|
-
writeRepoIndex,
|
|
12
|
-
} from "../../core";
|
|
4
|
+
import { executeRepoAction, WEAVE_REPO_DESCRIPTION } from "../../core";
|
|
13
5
|
|
|
14
|
-
/**
|
|
15
|
-
* `weave_repo` — the repository-exploration tool (design §2/§8).
|
|
16
|
-
*
|
|
17
|
-
* Builds and reads the derived .okf index: structure, modules, packages,
|
|
18
|
-
* entry points, plus git-aware staleness.
|
|
19
|
-
*/
|
|
6
|
+
/** `weave_repo` — the repository-exploration tool (design §2/§8). */
|
|
20
7
|
export function registerRepoTool(pi: ExtensionAPI): void {
|
|
21
8
|
pi.registerTool({
|
|
22
9
|
name: "weave_repo",
|
|
23
10
|
label: "Weave Repo",
|
|
24
|
-
description:
|
|
25
|
-
"Explore the current git repository through its pi-weave knowledge index (.okf). " +
|
|
26
|
-
"Actions: status (index freshness vs git state), scan (build/refresh the index), " +
|
|
27
|
-
"overview (read the indexed structure: languages, packages, modules, entry points). " +
|
|
28
|
-
"The index is derived and rebuildable; scanning is always safe.",
|
|
11
|
+
description: WEAVE_REPO_DESCRIPTION,
|
|
29
12
|
promptSnippet: "Explore the repository's structure via its .okf knowledge index",
|
|
30
13
|
promptGuidelines: [
|
|
31
14
|
"Use weave_repo action=overview to learn repository structure before broad code exploration instead of scanning files one by one.",
|
|
@@ -34,61 +17,11 @@ export function registerRepoTool(pi: ExtensionAPI): void {
|
|
|
34
17
|
parameters: Type.Object({
|
|
35
18
|
action: StringEnum(["status", "scan", "overview"] as const),
|
|
36
19
|
}),
|
|
37
|
-
async execute(
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
details: { action: params.action, inRepo: false },
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
switch (params.action) {
|
|
47
|
-
case "status": {
|
|
48
|
-
const staleness = await assessStaleness(root);
|
|
49
|
-
const index = staleness.state !== "missing" ? await readRepoIndex(root) : null;
|
|
50
|
-
const lines = [`Index state: ${staleness.state}`];
|
|
51
|
-
for (const reason of staleness.reasons) lines.push(`- ${reason}`);
|
|
52
|
-
if (index) lines.push(`Indexed at: ${index.updated} by ${index.generator}`);
|
|
53
|
-
return {
|
|
54
|
-
content: [{ type: "text", text: `Repository ${root}\n${lines.join("\n")}` }],
|
|
55
|
-
details: { action: "status", inRepo: true, staleness, indexed: index !== null },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
case "scan": {
|
|
60
|
-
onUpdate?.({ content: [{ type: "text", text: `Scanning ${root}…` }], details: {} });
|
|
61
|
-
const index = await buildRepoIndex(root);
|
|
62
|
-
if (!index) {
|
|
63
|
-
return {
|
|
64
|
-
content: [{ type: "text", text: "Cannot build index: the repository has no commits yet." }],
|
|
65
|
-
details: { action: "scan", inRepo: true, scanned: false },
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
const dir = await writeRepoIndex(root, index);
|
|
69
|
-
const summary = summarizeIndex(index).join("\n");
|
|
70
|
-
return {
|
|
71
|
-
content: [{ type: "text", text: `Knowledge index written to ${dir}\n\n${summary}` }],
|
|
72
|
-
details: { action: "scan", inRepo: true, scanned: true, index },
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
case "overview": {
|
|
77
|
-
const index = await readRepoIndex(root);
|
|
78
|
-
if (!index) {
|
|
79
|
-
return {
|
|
80
|
-
content: [{ type: "text", text: `No knowledge index at ${repoIndexDir(root)} yet. Use action=scan to build one.` }],
|
|
81
|
-
details: { action: "overview", inRepo: true, indexed: false },
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
const staleness = await assessStaleness(root);
|
|
85
|
-
const header = staleness.state === "fresh" ? "" : `⚠ index is ${staleness.state} (consider rescanning)\n`;
|
|
86
|
-
return {
|
|
87
|
-
content: [{ type: "text", text: header + summarizeIndex(index).join("\n") }],
|
|
88
|
-
details: { action: "overview", inRepo: true, indexed: true, staleness, index },
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
}
|
|
20
|
+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
21
|
+
const result = await executeRepoAction(params, ctx.cwd, (text) => {
|
|
22
|
+
onUpdate?.({ content: [{ type: "text", text }], details: {} });
|
|
23
|
+
});
|
|
24
|
+
return { content: [{ type: "text", text: result.text }], details: result.details };
|
|
92
25
|
},
|
|
93
26
|
});
|
|
94
27
|
}
|
package/src/pi/viewer/web/run.ts
CHANGED
|
@@ -33,43 +33,19 @@
|
|
|
33
33
|
* run, rather than that some private function was called.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { platform as osPlatform } from "node:os";
|
|
37
36
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
38
|
-
import { resolveVaultRoot, WorkspaceCache } from "../../../core";
|
|
39
37
|
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
type
|
|
43
|
-
|
|
38
|
+
browserOpenCommand,
|
|
39
|
+
WorkspaceServerController,
|
|
40
|
+
type StartServerFn,
|
|
41
|
+
type WorkspaceServerSession,
|
|
42
|
+
} from "../../../web/server/controller";
|
|
44
43
|
|
|
45
44
|
/** The slice of `ExtensionAPI.exec` this module uses. */
|
|
46
45
|
export type ExecFn = (command: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
export type
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The command that hands a URL to the desktop's default browser.
|
|
53
|
-
*
|
|
54
|
-
* `os` is injectable so the whole mapping is unit-testable on any host
|
|
55
|
-
* without stubbing globals — the same convention as `openNoteCommand`.
|
|
56
|
-
*/
|
|
57
|
-
export function browserOpenCommand(
|
|
58
|
-
url: string,
|
|
59
|
-
os: NodeJS.Platform = osPlatform(),
|
|
60
|
-
): { command: string; args: string[] } {
|
|
61
|
-
if (os === "darwin") return { command: "open", args: [url] };
|
|
62
|
-
// The empty string is `start`'s title argument. Without it, a URL that
|
|
63
|
-
// happens to be quoted is consumed as the window title and nothing opens.
|
|
64
|
-
if (os === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
|
|
65
|
-
return { command: "xdg-open", args: [url] };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Everything one running workspace owns. Closed as a unit. */
|
|
69
|
-
export interface WebWorkspaceSession {
|
|
70
|
-
server: WorkspaceServer;
|
|
71
|
-
cache: WorkspaceCache;
|
|
72
|
-
}
|
|
47
|
+
export { browserOpenCommand };
|
|
48
|
+
export type WebWorkspaceSession = WorkspaceServerSession;
|
|
73
49
|
|
|
74
50
|
export interface WebWorkspaceDeps {
|
|
75
51
|
/** `ExtensionAPI.exec`. Used only to launch the browser. */
|
|
@@ -115,17 +91,20 @@ export interface RunWebOutcome {
|
|
|
115
91
|
*/
|
|
116
92
|
export class WebWorkspaceController {
|
|
117
93
|
private readonly deps: WebWorkspaceDeps;
|
|
118
|
-
private
|
|
119
|
-
/** The single in-flight boot, so two fast `/weave-view`s do not race. */
|
|
120
|
-
private booting: Promise<WebWorkspaceSession> | null = null;
|
|
94
|
+
private readonly server: WorkspaceServerController;
|
|
121
95
|
|
|
122
96
|
constructor(deps: WebWorkspaceDeps) {
|
|
123
97
|
this.deps = deps;
|
|
98
|
+
this.server = new WorkspaceServerController({
|
|
99
|
+
...(deps.startServer ? { startServer: deps.startServer } : {}),
|
|
100
|
+
...(deps.vaultRoot ? { vaultRoot: deps.vaultRoot } : {}),
|
|
101
|
+
...(deps.onStateChange ? { onStateChange: deps.onStateChange } : {}),
|
|
102
|
+
});
|
|
124
103
|
}
|
|
125
104
|
|
|
126
105
|
/** Bound port while a workspace is running, else `null`. Drives the status line. */
|
|
127
106
|
port(): number | null {
|
|
128
|
-
return this.
|
|
107
|
+
return this.server.port();
|
|
129
108
|
}
|
|
130
109
|
|
|
131
110
|
/**
|
|
@@ -136,9 +115,7 @@ export class WebWorkspaceController {
|
|
|
136
115
|
* rather than a server they cannot find.
|
|
137
116
|
*/
|
|
138
117
|
async run(ctx: ExtensionCommandContext, opts: RunWebOptions): Promise<RunWebOutcome> {
|
|
139
|
-
const
|
|
140
|
-
const session = existing ?? (await this.boot(ctx));
|
|
141
|
-
const started = existing === null;
|
|
118
|
+
const { session, started } = await this.server.run(ctx.cwd);
|
|
142
119
|
|
|
143
120
|
// Only a session with a UI has a browser worth spawning: over `--mode
|
|
144
121
|
// rpc` or a plain SSH pipe there is no desktop on this side of the
|
|
@@ -154,45 +131,7 @@ export class WebWorkspaceController {
|
|
|
154
131
|
|
|
155
132
|
/** Stop the workspace server. Idempotent. */
|
|
156
133
|
async close(): Promise<void> {
|
|
157
|
-
|
|
158
|
-
// or `session_shutdown` during a slow start leaks the whole stack.
|
|
159
|
-
const booting = this.booting;
|
|
160
|
-
if (booting !== null) await booting.catch(() => undefined);
|
|
161
|
-
const session = this.session;
|
|
162
|
-
if (session === null) return;
|
|
163
|
-
this.session = null;
|
|
164
|
-
await session.server.close();
|
|
165
|
-
this.deps.onStateChange?.();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// --- internals --------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
private async boot(ctx: ExtensionCommandContext): Promise<WebWorkspaceSession> {
|
|
171
|
-
if (this.booting !== null) return this.booting;
|
|
172
|
-
const boot = this.bootOnce(ctx);
|
|
173
|
-
this.booting = boot;
|
|
174
|
-
try {
|
|
175
|
-
const session = await boot;
|
|
176
|
-
this.session = session;
|
|
177
|
-
this.deps.onStateChange?.();
|
|
178
|
-
return session;
|
|
179
|
-
} finally {
|
|
180
|
-
this.booting = null;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private async bootOnce(ctx: ExtensionCommandContext): Promise<WebWorkspaceSession> {
|
|
185
|
-
const cwd = ctx.cwd;
|
|
186
|
-
const vaultRoot = (this.deps.vaultRoot ?? resolveVaultRoot)();
|
|
187
|
-
const cache = new WorkspaceCache({ cwd, vaultRoot });
|
|
188
|
-
const startServer = this.deps.startServer ?? startWorkspaceServer;
|
|
189
|
-
const server = await startServer({
|
|
190
|
-
cwd,
|
|
191
|
-
vaultRoot,
|
|
192
|
-
cache,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
return { server, cache };
|
|
134
|
+
await this.server.close();
|
|
196
135
|
}
|
|
197
136
|
|
|
198
137
|
/** Spawn the browser, reporting failure rather than throwing it. */
|