pi-weave 0.2.4 → 0.3.1

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.
Files changed (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,209 +1,18 @@
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
- addNote,
7
- appendToNote,
8
- extractRawTail,
9
- finalizeNote,
10
- formatNote,
5
+ executeNoteAction,
11
6
  formatRawAppend,
12
- getNote,
13
- listNotes,
14
- NOTES_DIR,
15
- relatedNotes,
16
- repairVaultLinks,
17
- resolveNotePath,
18
- withMutationQueue,
19
- resolveVaultRoot,
20
- searchNotes,
21
- suggestLinks,
22
- readVault,
23
- type LinkRepairResult,
24
- type RelatedNote,
25
- type SuggestionReport,
7
+ WEAVE_NOTE_DESCRIPTION,
26
8
  } from "../../core";
27
- import type { Note, NoteSearchHit } from "../../core/types";
28
9
 
29
- /** Cap on how many rows of each link-audit category get printed. */
30
- const LINK_REPORT_CAP = 20;
31
-
32
- /** Cap on notes printed by `list`; `details.notes` still carries them all. */
33
- const LIST_CAP = 50;
34
-
35
- /** Keep search useful without flooding the model on a broad query. */
36
- const SEARCH_REPORT_CAP = 10;
37
- const RELATED_REPORT_CAP = 5;
38
- const COMPLETE_BODY_CAP = 2_000;
39
- const COMPLETE_BODY_RESULTS = 3;
40
-
41
- function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
42
- const lines = items.slice(0, LINK_REPORT_CAP).map(render);
43
- if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
44
- return lines;
45
- }
46
-
47
- /**
48
- * Render suggestions as the text the model reads.
49
- *
50
- * Every row carries the shared terms that earned it. A bare score is
51
- * unreviewable; the evidence is what lets a human accept or reject a
52
- * suggestion without opening both notes.
53
- */
54
- function formatSuggestions(report: SuggestionReport, focus: string | undefined): string {
55
- if (report.suggestions.length === 0) {
56
- return focus === undefined
57
- ? `No unlinked notes share enough distinctive vocabulary to suggest a connection (${report.considered} note(s) considered).`
58
- : `Nothing unlinked looks related to '${focus}' (${report.considered} note(s) considered).`;
59
- }
60
- const head = focus === undefined
61
- ? `${report.suggestions.length} suggested connection(s) across ${report.considered} note(s):`
62
- : `${report.suggestions.length} note(s) look related to '${focus}':`;
63
- const rows = report.suggestions.map((s) => {
64
- const pair = focus === undefined ? `${s.a} ↔ ${s.b}` : s.a === focus ? s.b : s.a;
65
- return ` ${s.score.toFixed(3)} ${pair}\n shared: ${s.shared.join(", ")}`;
66
- });
67
- return [
68
- head,
69
- ...rows,
70
- "",
71
- "These are suggestions, not links — nothing was written. Add a [[wikilink]] to any pair worth keeping.",
72
- ].join("\n");
73
- }
74
-
75
- function searchReasons(hit: NoteSearchHit, query: string): string[] {
76
- const q = query.trim().toLowerCase();
77
- const title = hit.summary.title.toLowerCase();
78
- const slug = hit.summary.slug.toLowerCase();
79
- const reasons: string[] = [];
80
- if (title === q) reasons.push("exact title");
81
- else if (slug === q) reasons.push("exact slug");
82
- else if (title.includes(q)) reasons.push("title");
83
- else if (slug.includes(q)) reasons.push("slug");
84
- const tags = hit.summary.tags.filter((tag) => tag.toLowerCase().includes(q));
85
- if (tags.length > 0) reasons.push(`tags: ${tags.join(", ")}`);
86
- if (hit.snippet.toLowerCase().includes(q)) reasons.push("body");
87
- if (reasons.length === 0) {
88
- const haystack = `${title} ${slug} ${hit.summary.tags.join(" ").toLowerCase()} ${hit.snippet.toLowerCase()}`;
89
- const terms = [...new Set(q.match(/[\p{L}\p{N}_-]{2,}/gu) ?? [])].filter((term) => haystack.includes(term));
90
- if (terms.length > 0) reasons.push(`query terms: ${terms.join(", ")}`);
91
- }
92
- return reasons;
93
- }
94
-
95
- function searchContent(note: Note | undefined, snippet: string, complete: boolean): string {
96
- const body = note?.body.trim() ?? "";
97
- if (complete && body.length <= COMPLETE_BODY_CAP) return `[complete body]\n ${body}`;
98
- return `[excerpt${body.length > COMPLETE_BODY_CAP ? "; use get for the full note" : ""}]\n ${snippet}`;
99
- }
100
-
101
- function formatSearchHits(
102
- hits: readonly NoteSearchHit[],
103
- query: string,
104
- relations: ReadonlyMap<string, RelatedNote>,
105
- notes: ReadonlyMap<string, Note>,
106
- ): string {
107
- const shown = hits.slice(0, SEARCH_REPORT_CAP);
108
- const lines = shown.map((hit, index) => {
109
- const tags = hit.summary.tags.length > 0 ? `; tags: ${hit.summary.tags.join(", ")}` : "";
110
- const relation = relations.get(hit.summary.slug);
111
- const connected = relation ? `; connected: ${relation.reasons.join("; ")}` : "";
112
- const content = searchContent(notes.get(hit.summary.slug), hit.snippet, index < COMPLETE_BODY_RESULTS);
113
- return `- ${hit.summary.slug}: ${hit.summary.title}\n matched: ${searchReasons(hit, query).join(", ")}${connected}; source: ${hit.summary.source}; updated: ${hit.summary.updated}${tags}\n ${content}`;
114
- });
115
- if (hits.length > shown.length) lines.push(`… and ${hits.length - shown.length} more direct match(es).`);
116
- return lines.join("\n");
117
- }
118
-
119
- function preview(body: string): string {
120
- const flat = body.trim().replace(/\s+/g, " ");
121
- return flat.length > 180 ? `${flat.slice(0, 180)}…` : flat;
122
- }
123
-
124
- function formatRelatedNotes(
125
- relations: readonly RelatedNote[],
126
- notes: ReadonlyMap<string, Note>,
127
- direct: ReadonlySet<string>,
128
- anchor: Note,
129
- ): string {
130
- const shown = relations
131
- .filter((relation) => !direct.has(relation.slug))
132
- .flatMap((relation) => {
133
- const note = notes.get(relation.slug);
134
- return note ? [{ relation, note }] : [];
135
- })
136
- .slice(0, RELATED_REPORT_CAP);
137
- if (shown.length === 0) return "";
138
- const lines = shown.map(({ relation, note }, index) => {
139
- const tags = note.tags.length > 0 ? `; tags: ${note.tags.join(", ")}` : "";
140
- const content = searchContent(note, preview(note.body), index < COMPLETE_BODY_RESULTS);
141
- return `- ${note.slug}: ${note.title}\n connected: ${relation.reasons.join("; ")}; source: ${note.source}; updated: ${note.updated}${tags}\n ${content}`;
142
- });
143
- return `Connected notes to '${anchor.title}' (discovery context only — not direct query matches or evidence about the query):\n${lines.join("\n")}`;
144
- }
145
-
146
- /** Render a link audit (and any repair) as the text the model reads. */
147
- function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
148
- const { audit } = result;
149
- const stale = audit.total - audit.resolved;
150
- // Occurrences, not rows. `total`/`resolved` count every `[[…]]` in the
151
- // vault, while `fixable`/`unresolvable` are grouped (per note+target, per
152
- // target). Reporting "70 stale" beside "40 unresolvable" with no unit
153
- // invites the reader to subtract them and find 30 phantom links; saying
154
- // what each number counts is the whole fix.
155
- const lines = [
156
- `${audit.total} wiki-link(s): ${audit.resolved} resolved, ${stale} stale.`,
157
- ];
158
- if (applied) {
159
- lines.push(
160
- result.applied.length === 0
161
- ? "Nothing to repair automatically."
162
- : `Repaired ${result.applied.length} link(s) across ${result.notes.length} note(s):`,
163
- ...capped(result.applied, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
164
- );
165
- } else if (audit.fixable.length > 0) {
166
- lines.push(
167
- `${audit.fixable.length} auto-fixable (re-run with fix: true):`,
168
- ...capped(audit.fixable, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
169
- );
170
- }
171
- if (audit.ambiguous.length > 0) {
172
- lines.push(
173
- `${audit.ambiguous.length} ambiguous link(s) (several candidates — pick one and edit the note):`,
174
- ...capped(audit.ambiguous, (a) => ` ${a.slug}: [[${a.target}]] → ${a.candidates.join(" | ")}`),
175
- );
176
- }
177
- if (audit.unresolvable.length > 0) {
178
- lines.push(
179
- `${audit.unresolvable.length} unresolvable target(s) (no such note — write it or drop the link):`,
180
- ...capped(audit.unresolvable, (u) => ` [[${u.target}]] ← ${u.notes.join(", ")}`),
181
- );
182
- }
183
- if (audit.fixable.length === 0 && audit.ambiguous.length === 0 && audit.unresolvable.length === 0) {
184
- lines.push("Every link resolves.");
185
- }
186
- return lines.join("\n");
187
- }
188
-
189
- /**
190
- * `weave_note` — the smart-notepad tool (design §1: vault knowledge).
191
- *
192
- * The LLM uses this to remember decisions, facts, and preferences as plain
193
- * Markdown notes that humans can read and edit directly on disk.
194
- */
10
+ /** `weave_note` — the smart-notepad tool (design §1: vault knowledge). */
195
11
  export function registerNoteTool(pi: ExtensionAPI): void {
196
12
  pi.registerTool({
197
13
  name: "weave_note",
198
14
  label: "Weave Note",
199
- description:
200
- "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
201
- "of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
202
- "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
203
- "finalize (restructure a note above its raw tail), search (ranked slug/title/tags/body matches plus linked, tagged, and lexically related notes; returns the full note when one result or one exact identity resolves), " +
204
- "links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
205
- "suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
206
- "Use it to remember decisions, facts, and user preferences across sessions.",
15
+ description: WEAVE_NOTE_DESCRIPTION,
207
16
  promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
208
17
  promptGuidelines: [
209
18
  "Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
@@ -223,217 +32,9 @@ export function registerNoteTool(pi: ExtensionAPI): void {
223
32
  fix: Type.Optional(Type.Boolean({ description: "links: apply the unambiguous repairs. Omit for a read-only report" })),
224
33
  limit: Type.Optional(Type.Number({ description: "suggest: how many suggestions to return (default 20)" })),
225
34
  }),
226
- async execute(toolCallId, params, signal, onUpdate, ctx) {
227
- const vault = resolveVaultRoot();
228
-
229
- switch (params.action) {
230
- case "list": {
231
- const notes = await listNotes(vault);
232
- if (notes.length === 0) {
233
- return {
234
- content: [{ type: "text", text: `The vault at ${vault} has no notes yet.` }],
235
- details: { action: "list", notes: [] },
236
- };
237
- }
238
- // Truncated, because the whole list is rarely the answer and on a
239
- // large vault it is actively harmful: hundreds of lines of slugs
240
- // crowd out the conversation that prompted the call. Newest first
241
- // (`listNotes` order), so the cap keeps what is most likely wanted,
242
- // and the footer names `search` — the action that answers "is there
243
- // a note about X" without reading the vault aloud.
244
- const shown = notes.slice(0, LIST_CAP);
245
- const lines = shown.map(
246
- (n) => `- ${n.slug}: ${n.title}${n.tags.length > 0 ? ` [${n.tags.join(", ")}]` : ""} (updated ${n.updated}, source: ${n.source})`,
247
- );
248
- if (notes.length > shown.length) {
249
- lines.push(
250
- `… and ${notes.length - shown.length} more (newest ${shown.length} shown) — use action=search to find a specific note.`,
251
- );
252
- }
253
- return {
254
- content: [{ type: "text", text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}` }],
255
- details: { action: "list", notes },
256
- };
257
- }
258
-
259
- case "get": {
260
- if (!params.slug) throw new Error("weave_note(get) requires 'slug'");
261
- const note = await getNote(vault, params.slug);
262
- if (!note) {
263
- return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "get", found: false } };
264
- }
265
- return { content: [{ type: "text", text: formatNote(note) }], details: { action: "get", found: true, note } };
266
- }
267
-
268
- case "add": {
269
- if (!params.title) throw new Error("weave_note(add) requires 'title'");
270
- if (!params.text) throw new Error("weave_note(add) requires 'text'");
271
- const title = params.title;
272
- const text = params.text;
273
- // Serialized per vault: parallel adds of the same title must not
274
- // race the unique-slug check and overwrite each other.
275
- const note = await withMutationQueue(join(vault, NOTES_DIR), () =>
276
- addNote(vault, {
277
- title,
278
- body: text,
279
- ...(params.tags ? { tags: params.tags } : {}),
280
- ...(params.source ? { source: params.source } : {}),
281
- }),
282
- );
283
- return {
284
- content: [{ type: "text", text: `Note created: ${note.slug} (${vault})` }],
285
- details: { action: "add", note },
286
- };
287
- }
288
-
289
- case "append": {
290
- if (!params.slug) throw new Error("weave_note(append) requires 'slug'");
291
- if (!params.text) throw new Error("weave_note(append) requires 'text'");
292
- const slug = params.slug;
293
- const text = params.text;
294
- const path = resolveNotePath(vault, slug);
295
- if (!path) {
296
- return {
297
- content: [
298
- {
299
- type: "text",
300
- text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
301
- },
302
- ],
303
- details: { action: "append", found: false },
304
- };
305
- }
306
- // Serialized read-modify-write: parallel weave_note appends (and
307
- // pi's own file tools) targeting the same note would otherwise
308
- // lose each other's additions.
309
- const note = await withMutationQueue(path, () =>
310
- appendToNote(vault, slug, text, new Date(), params.raw ? { raw: true } : {}),
311
- );
312
- if (!note) {
313
- return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "append", found: false } };
314
- }
315
- return {
316
- 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}).` }],
317
- details: { action: "append", found: true, note },
318
- };
319
- }
320
-
321
- case "finalize": {
322
- if (!params.slug) throw new Error("weave_note(finalize) requires 'slug'");
323
- if (!params.text) throw new Error("weave_note(finalize) requires 'text'");
324
- const slug = params.slug;
325
- const text = params.text;
326
- const path = resolveNotePath(vault, slug);
327
- if (!path) {
328
- return {
329
- content: [
330
- {
331
- type: "text",
332
- text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
333
- },
334
- ],
335
- details: { action: "finalize", found: false },
336
- };
337
- }
338
- // Serialized read-modify-write, same as append: finalize replaces the
339
- // body above the raw tail, so it must not race other writers.
340
- const note = await withMutationQueue(path, () => finalizeNote(vault, slug, { body: text }));
341
- if (!note) {
342
- return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "finalize", found: false } };
343
- }
344
- // Be truthful about what was preserved: a note with no `## Raw`
345
- // marker yet gets its whole pre-finalize body preserved as a new
346
- // raw tail (never silently dropped).
347
- const preserved = extractRawTail(note.body) !== "";
348
- return {
349
- 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."}` }],
350
- details: { action: "finalize", found: true, note },
351
- };
352
- }
353
-
354
- case "search": {
355
- if (!params.query) throw new Error("weave_note(search) requires 'query'");
356
- const hits = await searchNotes(vault, params.query);
357
- if (hits.length === 0) {
358
- return {
359
- content: [{ type: "text", text: `No notes matched '${params.query}'.` }],
360
- details: { action: "search", hits: [] },
361
- };
362
- }
363
- const { notes } = await readVault(vault);
364
- const bySlug = new Map(notes.map((note) => [note.slug, note]));
365
- const direct = new Set(hits.map((hit) => hit.summary.slug));
366
- const anchor = bySlug.get(hits[0]!.summary.slug);
367
- const related = anchor ? relatedNotes({ notes }, anchor.slug) : [];
368
- const relatedBySlug = new Map(related.map((relation) => [relation.slug, relation]));
369
- const connected = anchor ? formatRelatedNotes(related, bySlug, direct, anchor) : "";
370
- const query = params.query.trim().toLowerCase();
371
- const exact = hits.filter((hit) =>
372
- hit.summary.title.trim().toLowerCase() === query || hit.summary.slug.toLowerCase() === query
373
- );
374
- const resolved = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
375
- if (resolved) {
376
- const note = bySlug.get(resolved.summary.slug);
377
- if (note) {
378
- const others = hits.filter((hit) => hit.summary.slug !== note.slug);
379
- const otherMatches = others.length > 0
380
- ? `Other direct matches (${others.length}):\n${formatSearchHits(others, params.query, relatedBySlug, bySlug)}`
381
- : "";
382
- return {
383
- content: [{
384
- type: "text",
385
- text: [formatNote(note).trimEnd(), otherMatches, connected].filter(Boolean).join("\n\n") + "\n",
386
- }],
387
- details: { action: "search", hits, resolved: note, related },
388
- };
389
- }
390
- }
391
- return {
392
- content: [{
393
- type: "text",
394
- text: [
395
- `${hits.length} direct match(es) for '${params.query}', strongest first:\n${formatSearchHits(hits, params.query, relatedBySlug, bySlug)}`,
396
- connected,
397
- ].filter(Boolean).join("\n\n"),
398
- }],
399
- details: { action: "search", hits, related },
400
- };
401
- }
402
-
403
- case "suggest": {
404
- const { notes } = await readVault(vault);
405
- const report = suggestLinks(
406
- { notes },
407
- {
408
- ...(params.slug ? { slug: params.slug } : {}),
409
- ...(params.limit !== undefined ? { limit: params.limit } : {}),
410
- },
411
- );
412
- return {
413
- content: [{ type: "text", text: formatSuggestions(report, params.slug) }],
414
- details: { action: "suggest", considered: report.considered, suggestions: report.suggestions },
415
- };
416
- }
417
-
418
- case "links": {
419
- const apply = params.fix === true;
420
- const result = await repairVaultLinks(vault, apply ? { apply: true } : {});
421
- return {
422
- content: [{ type: "text", text: formatLinkReport(result, apply) }],
423
- details: {
424
- action: "links",
425
- fixed: apply,
426
- total: result.audit.total,
427
- resolved: result.audit.resolved,
428
- fixable: result.audit.fixable,
429
- ambiguous: result.audit.ambiguous,
430
- unresolvable: result.audit.unresolvable,
431
- applied: result.applied,
432
- notes: result.notes,
433
- },
434
- };
435
- }
436
- }
35
+ async execute(_toolCallId, params) {
36
+ const result = await executeNoteAction(params);
37
+ return { content: [{ type: "text", text: result.text }], details: result.details };
437
38
  },
438
39
  });
439
40
  }
@@ -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(toolCallId, params, signal, onUpdate, ctx) {
38
- const root = await findGitRoot(ctx.cwd);
39
- if (!root) {
40
- return {
41
- content: [{ type: "text", text: "Not inside a git repository — repository knowledge is unavailable here." }],
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
  }
@@ -148,7 +148,7 @@ export class DetailSurface implements Surface {
148
148
  if (matchesKey(data, "enter")) {
149
149
  const row = this.activeRow();
150
150
  if (row?.target) {
151
- // rebind this detail pane to the target (Obsidian "open in main" in place)
151
+ // rebind this detail pane to the target (open in main, in place)
152
152
  this.state = { nodeId: row.target, selectedId: row.target, scrollOffset: 0, gotoBuf: null };
153
153
  this._lines = undefined;
154
154
  this.requestBody(row.target);
@@ -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
- startWorkspaceServer,
41
- type StartWorkspaceServerOptions,
42
- type WorkspaceServer,
43
- } from "../../../web/server/server";
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
- /** `startWorkspaceServer`, as a seam so tests can pin `idleMs` and the bundle. */
49
- export type StartServerFn = (opts: StartWorkspaceServerOptions) => Promise<WorkspaceServer>;
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 session: WebWorkspaceSession | null = null;
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.session?.server.port ?? null;
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 existing = this.session;
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
- // A close arriving mid-boot must still close what that boot produced,
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. */