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.
- package/README.md +67 -37
- package/package.json +12 -3
- package/skills/weave-explore/SKILL.md +17 -33
- package/skills/weave-notepad/SKILL.md +57 -120
- package/skills/weave-notepad/references/link-repair.md +25 -73
- package/src/core/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- package/src/core/index.ts +25 -1
- 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/types.ts +1 -1
- 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 +7 -406
- package/src/pi/tools/repoTool.ts +8 -75
- package/src/pi/viewer/tui/surface/detail.ts +1 -1
- package/src/pi/viewer/web/run.ts +16 -77
- package/src/web/client/dist/app.js +210 -178
- package/src/web/client/graph/Graph.tsx +18 -0
- package/src/web/client/graph/column.model.ts +19 -2
- package/src/web/client/graph/graph.model.ts +7 -5
- package/src/web/client/graph/positions.ts +1 -1
- package/src/web/client/graph/renderer.ts +9 -1
- package/src/web/client/note/Note.tsx +66 -91
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Shell.tsx +371 -254
- package/src/web/client/shell/StatusBar.tsx +1 -1
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +26 -31
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +31 -107
- package/src/web/client/shell/theme.model.ts +4 -4
- package/src/web/client/shell/theme.ts +12 -93
- package/src/web/client/shell/workspace.css.ts +117 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/controller.ts +79 -0
- package/src/web/server/routes.ts +34 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/workspace.ts +270 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- package/src/web/client/shell/layout.model.ts +0 -132
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
appendToNote,
|
|
8
|
-
extractRawTail,
|
|
9
|
-
finalizeNote,
|
|
10
|
-
formatNote,
|
|
5
|
+
executeNoteAction,
|
|
11
6
|
formatRawAppend,
|
|
12
|
-
|
|
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
|
-
/**
|
|
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(
|
|
227
|
-
const
|
|
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
|
}
|
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
|
}
|
|
@@ -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 (
|
|
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);
|
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. */
|