pi-weave 0.1.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.
@@ -0,0 +1,85 @@
1
+ import { findGitRoot } from "./git";
2
+ import { assessStaleness, readRepoIndex } from "./repoIndex";
3
+ import { readSummaries } from "./summaries";
4
+ import { basename } from "node:path";
5
+ import { resolveVaultRoot } from "./paths";
6
+ import { noteCount, vaultExists } from "./vault";
7
+ import type { WorkspaceStatus } from "./types";
8
+
9
+ /**
10
+ * The combined knowledge-workspace view: vault + repository (design §5/§17).
11
+ * This is what session_start, the /weave command, and future adapters read
12
+ * to decide what knowledge is available in the current directory.
13
+ */
14
+
15
+ export interface WorkspaceOptions {
16
+ /** Override the vault root (defaults to PI_WEAVE_VAULT or ~/.okf). */
17
+ vaultRoot?: string;
18
+ }
19
+
20
+ export async function getWorkspaceStatus(cwd: string, options: WorkspaceOptions = {}): Promise<WorkspaceStatus> {
21
+ const vaultRoot = options.vaultRoot ?? resolveVaultRoot();
22
+
23
+ const [exists, count, repoRoot] = await Promise.all([
24
+ vaultExists(vaultRoot),
25
+ noteCount(vaultRoot),
26
+ findGitRoot(cwd),
27
+ ]);
28
+
29
+ const status: WorkspaceStatus = {
30
+ cwd,
31
+ vault: { root: vaultRoot, exists, noteCount: count },
32
+ repository: null,
33
+ };
34
+
35
+ if (!repoRoot) return status;
36
+
37
+ const index = await readRepoIndex(repoRoot);
38
+ const staleness = await assessStaleness(repoRoot);
39
+ status.repository = {
40
+ root: repoRoot,
41
+ name: index?.identity.name ?? basename(repoRoot),
42
+ indexed: index !== null,
43
+ staleness,
44
+ summaryCount: index === null ? 0 : (await readSummaries(repoRoot)).length,
45
+ };
46
+ return status;
47
+ }
48
+
49
+ /** One-line status string for footers/status bars. */
50
+ export function formatStatusLine(status: WorkspaceStatus): string {
51
+ const vault = `vault:${status.vault.noteCount}`;
52
+ if (!status.repository) return `🧵 ${vault}`;
53
+ if (!status.repository.indexed) return `🧵 ${vault} · repo:unindexed`;
54
+ const mark = status.repository.staleness.state === "fresh" ? "ok" : status.repository.staleness.state;
55
+ return `🧵 ${vault} · ${status.repository.name}:${mark}`;
56
+ }
57
+
58
+ /** Multi-line dashboard used by the /weave command and notifications. */
59
+ export function formatDashboard(status: WorkspaceStatus): string {
60
+ const lines: string[] = [];
61
+ lines.push(`Vault (${status.vault.root}):`);
62
+ lines.push(
63
+ status.vault.exists
64
+ ? ` ${status.vault.noteCount} note(s)`
65
+ : " not initialized — add a note to create it",
66
+ );
67
+ if (!status.repository) {
68
+ lines.push("Repository: none (not inside a git repository)");
69
+ return lines.join("\n");
70
+ }
71
+ const repo = status.repository;
72
+ lines.push(`Repository (${repo.name} @ ${repo.root}):`);
73
+ if (!repo.indexed) {
74
+ lines.push(" not indexed — run a repository scan or ask pi to explore");
75
+ return lines.join("\n");
76
+ }
77
+ lines.push(` index: ${repo.staleness.state}`);
78
+ for (const reason of repo.staleness.reasons) {
79
+ lines.push(` - ${reason}`);
80
+ }
81
+ if (repo.summaryCount > 0) {
82
+ lines.push(` summaries: ${repo.summaryCount} file(s) — run /weave-scan deep to refresh`);
83
+ }
84
+ return lines.join("\n");
85
+ }
@@ -0,0 +1,191 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ buildRepoIndex,
4
+ findGitRoot,
5
+ formatDashboard,
6
+ formatStatusLine,
7
+ getWorkspaceStatus,
8
+ summarizeIndex,
9
+ writeRepoIndex,
10
+ type WorkspaceStatus,
11
+ } from "../core";
12
+ import { registerNoteTool } from "./tools/noteTool";
13
+ import { registerRepoTool } from "./tools/repoTool";
14
+ import { deepScanRepository, formatDeepScanResult } from "./summarize";
15
+ import { openInBrowser } from "./viewer/browser";
16
+ import { startViewer, type ViewerServer } from "./viewer/server";
17
+ import { runWeaveViewTui } from "./viewer/tui/run";
18
+
19
+ /**
20
+ * pi-weave — an agent-native knowledge workspace (docs/design.md):
21
+ *
22
+ * 1. Smart notepad with AI skills → the `weave_note` tool + skills/
23
+ * 2. Repository exploration → the `weave_repo` tool building .okf
24
+ * 3. Humans and agents alike → plain Markdown / JSON on disk
25
+ *
26
+ * All behavior lives in src/core (portable); this file only wires it into pi.
27
+ */
28
+ export default function piWeave(pi: ExtensionAPI): void {
29
+ registerNoteTool(pi);
30
+ registerRepoTool(pi);
31
+
32
+ // Session-scoped viewer: lazy start on first /weave-view, never from the
33
+ // factory (extension rules); idempotent stop on session_shutdown.
34
+ let viewer: ViewerServer | null = null;
35
+
36
+ pi.on("session_shutdown", async () => {
37
+ const server = viewer;
38
+ viewer = null;
39
+ await server?.stop();
40
+ });
41
+
42
+ pi.on("session_start", async (_event, ctx) => {
43
+ const status = await getWorkspaceStatus(ctx.cwd);
44
+ ctx.ui.setStatus("weave", formatStatusLine(status));
45
+
46
+ if (!ctx.hasUI) return;
47
+ const repo = status.repository;
48
+ if (repo && !repo.indexed) {
49
+ ctx.ui.notify(
50
+ "pi-weave: this repository has no knowledge index yet — ask pi to explore it, or run /weave-scan.",
51
+ "info",
52
+ );
53
+ } else if (repo && repo.staleness.state === "stale") {
54
+ const reason = repo.staleness.reasons[0] ?? "index is stale";
55
+ ctx.ui.notify(`pi-weave: repository index is stale (${reason}). Run /weave-scan to refresh.`, "warning");
56
+ }
57
+ });
58
+
59
+ pi.registerCommand("weave", {
60
+ description: "Show the pi-weave workspace dashboard (vault + repository knowledge)",
61
+ handler: async (_args, ctx) => {
62
+ const status = await getWorkspaceStatus(ctx.cwd);
63
+ ctx.ui.notify(formatDashboard(status), "info");
64
+ },
65
+ });
66
+
67
+ pi.registerCommand("weave-view", {
68
+ description: "Open the local knowledge-graph viewer in your browser (vault + repository); '/weave-view tui' explores in-terminal",
69
+ handler: async (args, ctx) => {
70
+ const arg = args.trim().toLowerCase();
71
+ if (arg === "tui") {
72
+ await runWeaveViewTui(ctx);
73
+ return;
74
+ }
75
+ if (arg !== "") {
76
+ ctx.ui.notify("usage: /weave-view [tui]", "warning");
77
+ return;
78
+ }
79
+ viewer ??= await startViewer({ cwd: ctx.cwd });
80
+ ctx.ui.notify(`pi-weave viewer: ${viewer.url} (reads from disk live; refresh the page any time)`, "info");
81
+ await openInBrowser(pi, ctx, viewer.url);
82
+ },
83
+ });
84
+
85
+ pi.registerCommand("weave-scan", {
86
+ description: "Build or refresh the repository knowledge index (.okf); 'deep' also summarizes files with the session model",
87
+ handler: async (args, ctx) => {
88
+ const root = await findGitRoot(ctx.cwd);
89
+ if (!root) {
90
+ ctx.ui.notify("pi-weave: not inside a git repository.", "warning");
91
+ return;
92
+ }
93
+ const index = await buildRepoIndex(root);
94
+ if (!index) {
95
+ ctx.ui.notify("pi-weave: cannot index — the repository has no commits yet.", "warning");
96
+ return;
97
+ }
98
+ await writeRepoIndex(root, index);
99
+ ctx.ui.notify(`pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`, "info");
100
+
101
+ if (args.trim().toLowerCase() === "deep") {
102
+ if (inFlightDeepScans.has(root)) {
103
+ ctx.ui.notify("pi-weave: a deep scan is already running for this repository — run /weave-scan-cancel to stop it.", "warning");
104
+ } else {
105
+ // Compute the settled status here (no git-lock contention) and let
106
+ // the background scan restore it when it finishes.
107
+ const status = await getWorkspaceStatus(ctx.cwd);
108
+ startDeepScan(root, ctx, status);
109
+ }
110
+ return; // the background scan owns the status line until it settles
111
+ }
112
+
113
+ const status = await getWorkspaceStatus(ctx.cwd);
114
+ ctx.ui.setStatus("weave", formatStatusLine(status));
115
+ },
116
+ });
117
+
118
+ pi.registerCommand("weave-scan-cancel", {
119
+ description: "Cancel an in-flight /weave-scan deep run",
120
+ handler: async (_args, ctx) => {
121
+ const root = await findGitRoot(ctx.cwd);
122
+ const scan = root ? inFlightDeepScans.get(root) : undefined;
123
+ if (!scan) {
124
+ ctx.ui.notify("pi-weave: no deep scan is currently running.", "info");
125
+ return;
126
+ }
127
+ scan.controller.abort();
128
+ ctx.ui.notify("pi-weave: deep scan cancellation requested.", "info");
129
+ },
130
+ });
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ */
134
+ /* In-flight deep scans (background, cancellable) */
135
+ /* ------------------------------------------------------------------ */
136
+
137
+ interface InFlightDeepScan {
138
+ controller: AbortController;
139
+ /** Resolves when the background scan settles (done, cancelled, or torn down). */
140
+ done: Promise<void>;
141
+ }
142
+
143
+ /** In-flight deep scans keyed by repo root — the /weave-scan-cancel target. */
144
+ const inFlightDeepScans = new Map<string, InFlightDeepScan>();
145
+
146
+ /** Test seam: resolve when the in-flight deep scan for `root` settles. */
147
+ export async function deepScanDone(root: string): Promise<void | undefined> {
148
+ const canonical = await findGitRoot(root).catch(() => null);
149
+ return inFlightDeepScans.get(canonical ?? root)?.done;
150
+ }
151
+
152
+ /**
153
+ * Kick off a deep scan in the background so the user keeps control of the
154
+ * session (a blocking command can't be cancelled in the TUI — Esc only aborts
155
+ * streaming/bash). Progress is pushed to the status line; completion or
156
+ * cancellation is reported via a notification. `baseStatus` is the workspace
157
+ * status captured before the scan and restored when it settles.
158
+ */
159
+ function startDeepScan(root: string, ctx: ExtensionCommandContext, baseStatus: WorkspaceStatus): void {
160
+ const controller = new AbortController();
161
+ const done = (async () => {
162
+ try {
163
+ ctx.ui.setStatus("weave", "🧵 deep scan: starting…");
164
+ const outcome = await deepScanRepository(root, ctx, {
165
+ onProgress: ({ current, total, path }) => {
166
+ const pct = total > 0 ? Math.round((current / total) * 100) : 100;
167
+ ctx.ui.setStatus("weave", `🧵 deep scan: ${current}/${total} (${pct}%) — ${path}`);
168
+ },
169
+ signal: controller.signal,
170
+ });
171
+ if (controller.signal.aborted) {
172
+ ctx.ui.notify("pi-weave: deep scan cancelled.", "warning");
173
+ } else if (outcome.kind === "no-model") {
174
+ ctx.ui.notify(
175
+ "pi-weave: deep scan needs an active session model — none configured. Light index only.",
176
+ "warning",
177
+ );
178
+ } else if (outcome.kind === "ok") {
179
+ ctx.ui.notify(`pi-weave: deep scan complete — ${formatDeepScanResult(outcome.result)}`, "info");
180
+ }
181
+ } catch {
182
+ // session ended or extension torn down — stop quietly
183
+ } finally {
184
+ // Restore the settled status before removing the map entry, so a caller
185
+ // awaiting deepScanDone() observes the settled status line.
186
+ ctx.ui.setStatus("weave", formatStatusLine(baseStatus));
187
+ inFlightDeepScans.delete(root);
188
+ }
189
+ })();
190
+ inFlightDeepScans.set(root, { controller, done });
191
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * pi adapter for deep scans: resolves the session's already-configured model
3
+ * (the codebase-memory-mcp lesson — no extra keys/providers) and drives
4
+ * pi-ai completion through `ctx.modelRegistry`, which owns auth.
5
+ *
6
+ * `createLlmSummarizer` returns null when no model is active (headless
7
+ * runs without a provider, etc.) — callers fall back to light-only.
8
+ * `deps.complete` is the test seam: unit tests inject a fake and never
9
+ * touch a network.
10
+ */
11
+
12
+ import {
13
+ contentText,
14
+ type Api,
15
+ type AssistantMessage,
16
+ type Context,
17
+ type Model,
18
+ } from "@earendil-works/pi-ai";
19
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { runDeepScan, type DeepScanOptions, type DeepScanResult, type SummarizeFn } from "../core";
21
+
22
+ export type CompleteFn = (
23
+ model: Model<Api>,
24
+ context: Context,
25
+ options: { maxTokens?: number; signal?: AbortSignal },
26
+ ) => Promise<AssistantMessage>;
27
+
28
+ export interface SummarizerDeps {
29
+ complete?: CompleteFn;
30
+ }
31
+
32
+ export interface LlmSummarizer {
33
+ summarize: SummarizeFn;
34
+ /** Provenance label recorded in sidecar front matter (e.g. "ollama/kimi-k3:cloud"). */
35
+ label: string;
36
+ }
37
+
38
+ const SYSTEM_PROMPT = [
39
+ "You write terse, navigation-oriented summaries of source files for a codebase index.",
40
+ "Rules: 1–3 sentences. What the file does, its outward surface (exports/routes/commands),",
41
+ "anything surprising (globals, side effects, generated sections). No preamble, no headings, no code fences.",
42
+ ].join("\n");
43
+
44
+ const MAX_OUTPUT_TOKENS = 220;
45
+ const REQUEST_TIMEOUT_MS = 30_000;
46
+
47
+ /** Create the model-backed summarizer, or null when the session has no active model. */
48
+ export function createLlmSummarizer(
49
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
50
+ deps: SummarizerDeps = {},
51
+ ): LlmSummarizer | null {
52
+ const model = ctx.model;
53
+ if (!model) return null;
54
+ const complete: CompleteFn =
55
+ deps.complete ?? ((m, c, o) => ctx.modelRegistry.complete(m, c, o));
56
+ const label = `${model.provider}/${model.id}`;
57
+ const summarize: SummarizeFn = async ({ path, content }) => {
58
+ const message = await complete(
59
+ model,
60
+ {
61
+ systemPrompt: SYSTEM_PROMPT,
62
+ messages: [
63
+ {
64
+ role: "user",
65
+ content: `File: ${path}\n\n\`\`\`\n${content}\n\`\`\``,
66
+ timestamp: Date.now(),
67
+ },
68
+ ],
69
+ },
70
+ { maxTokens: MAX_OUTPUT_TOKENS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
71
+ );
72
+ const text = contentText(message.content).trim();
73
+ if (text.length === 0) {
74
+ throw new Error("model returned an empty summary");
75
+ }
76
+ return text;
77
+ };
78
+ return { summarize, label };
79
+ }
80
+
81
+ export type DeepScanOutcome =
82
+ | { kind: "ok"; result: DeepScanResult }
83
+ | { kind: "no-model" }
84
+ | { kind: "not-a-repo" };
85
+
86
+ type DeepScanTuning = {
87
+ at?: DeepScanOptions["at"];
88
+ maxFiles?: DeepScanOptions["maxFiles"];
89
+ maxFileBytes?: DeepScanOptions["maxFileBytes"];
90
+ concurrency?: DeepScanOptions["concurrency"];
91
+ onProgress?: DeepScanOptions["onProgress"];
92
+ signal?: DeepScanOptions["signal"];
93
+ };
94
+
95
+ /** Run the deep pass against a repo root using the session model. */
96
+ export async function deepScanRepository(
97
+ repoRoot: string,
98
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
99
+ deps: SummarizerDeps & DeepScanTuning = {},
100
+ ): Promise<DeepScanOutcome> {
101
+ const llm = createLlmSummarizer(ctx, deps);
102
+ if (!llm) return { kind: "no-model" };
103
+ // exactOptionalPropertyTypes: only present keys may be spread in.
104
+ const result = await runDeepScan(repoRoot, {
105
+ summarize: llm.summarize,
106
+ model: llm.label,
107
+ ...(deps.at !== undefined ? { at: deps.at } : {}),
108
+ ...(deps.maxFiles !== undefined ? { maxFiles: deps.maxFiles } : {}),
109
+ ...(deps.maxFileBytes !== undefined ? { maxFileBytes: deps.maxFileBytes } : {}),
110
+ ...(deps.concurrency !== undefined ? { concurrency: deps.concurrency } : {}),
111
+ ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
112
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
113
+ });
114
+ if (result === null) return { kind: "not-a-repo" };
115
+ return { kind: "ok", result };
116
+ }
117
+
118
+ /** One-line human summary of a deep-scan result (for notify output). */
119
+ export function formatDeepScanResult(result: DeepScanResult): string {
120
+ const parts = [
121
+ `${result.written} summarized`,
122
+ `${result.skippedFresh} unchanged`,
123
+ ];
124
+ if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size/type)`);
125
+ if (result.pruned > 0) parts.push(`${result.pruned} pruned`);
126
+ let text = `${parts.join(", ")} — ${result.considered} files considered`;
127
+ if (result.failed.length > 0) {
128
+ const [failed0] = result.failed;
129
+ if (failed0) {
130
+ text += `; ${result.failed.length} failed, first: ${failed0.path}: ${failed0.error}`;
131
+ }
132
+ }
133
+ return text;
134
+ }
@@ -0,0 +1,178 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { join } from "node:path";
4
+ import { Type } from "typebox";
5
+ import {
6
+ addNote,
7
+ appendToNote,
8
+ finalizeNote,
9
+ formatNote,
10
+ getNote,
11
+ listNotes,
12
+ NOTES_DIR,
13
+ resolveNotePath,
14
+ withMutationQueue,
15
+ resolveVaultRoot,
16
+ searchNotes,
17
+ } from "../../core";
18
+
19
+ /**
20
+ * `weave_note` — the smart-notepad tool (design §1: vault knowledge).
21
+ *
22
+ * The LLM uses this to remember decisions, facts, and preferences as plain
23
+ * Markdown notes that humans can read and edit directly on disk.
24
+ */
25
+ export function registerNoteTool(pi: ExtensionAPI): void {
26
+ pi.registerTool({
27
+ name: "weave_note",
28
+ label: "Weave Note",
29
+ description:
30
+ "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
31
+ "of Markdown notes. Actions: list (all notes), get (one note by slug), add (new note), " +
32
+ "append (extend a note), finalize (restructure a note above its raw tail), search (title/tags/body). " +
33
+ "Use it to remember decisions, facts, and user preferences across sessions.",
34
+ promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
35
+ promptGuidelines: [
36
+ "Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
37
+ "Use weave_note with action=search before answering questions about past decisions, people, or projects.",
38
+ ],
39
+ parameters: Type.Object({
40
+ action: StringEnum(["list", "get", "add", "append", "finalize", "search"] as const),
41
+ title: Type.Optional(Type.String({ description: "Note title (add)" })),
42
+ text: Type.Optional(Type.String({ description: "Markdown body (add), addition (append), or restructured body above the raw tail (finalize)" })),
43
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Tags (add)" })),
44
+ slug: Type.Optional(Type.String({ description: "Note slug (get, append, finalize)" })),
45
+ source: Type.Optional(StringEnum(["human", "agent"] as const, { description: "Provenance (add): human for user-scribbled notes, agent for Pi-drafted (default agent)" })),
46
+ query: Type.Optional(Type.String({ description: "Search query (search)" })),
47
+ }),
48
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
49
+ const vault = resolveVaultRoot();
50
+
51
+ switch (params.action) {
52
+ case "list": {
53
+ const notes = await listNotes(vault);
54
+ if (notes.length === 0) {
55
+ return {
56
+ content: [{ type: "text", text: `The vault at ${vault} has no notes yet.` }],
57
+ details: { action: "list", notes: [] },
58
+ };
59
+ }
60
+ const lines = notes.map(
61
+ (n) => `- ${n.slug}: ${n.title}${n.tags.length > 0 ? ` [${n.tags.join(", ")}]` : ""} (updated ${n.updated}, source: ${n.source})`,
62
+ );
63
+ return {
64
+ content: [{ type: "text", text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}` }],
65
+ details: { action: "list", notes },
66
+ };
67
+ }
68
+
69
+ case "get": {
70
+ if (!params.slug) throw new Error("weave_note(get) requires 'slug'");
71
+ const note = await getNote(vault, params.slug);
72
+ if (!note) {
73
+ return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "get", found: false } };
74
+ }
75
+ return { content: [{ type: "text", text: formatNote(note) }], details: { action: "get", found: true, note } };
76
+ }
77
+
78
+ case "add": {
79
+ if (!params.title) throw new Error("weave_note(add) requires 'title'");
80
+ if (!params.text) throw new Error("weave_note(add) requires 'text'");
81
+ const title = params.title;
82
+ const text = params.text;
83
+ // Serialized per vault: parallel adds of the same title must not
84
+ // race the unique-slug check and overwrite each other.
85
+ const note = await withMutationQueue(join(vault, NOTES_DIR), () =>
86
+ addNote(vault, {
87
+ title,
88
+ body: text,
89
+ ...(params.tags ? { tags: params.tags } : {}),
90
+ ...(params.source ? { source: params.source } : {}),
91
+ }),
92
+ );
93
+ return {
94
+ content: [{ type: "text", text: `Note created: ${note.slug} (${vault})` }],
95
+ details: { action: "add", note },
96
+ };
97
+ }
98
+
99
+ case "append": {
100
+ if (!params.slug) throw new Error("weave_note(append) requires 'slug'");
101
+ if (!params.text) throw new Error("weave_note(append) requires 'text'");
102
+ const slug = params.slug;
103
+ const text = params.text;
104
+ const path = resolveNotePath(vault, slug);
105
+ if (!path) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
111
+ },
112
+ ],
113
+ details: { action: "append", found: false },
114
+ };
115
+ }
116
+ // Serialized read-modify-write: parallel weave_note appends (and
117
+ // pi's own file tools) targeting the same note would otherwise
118
+ // lose each other's additions.
119
+ const note = await withMutationQueue(path, () => appendToNote(vault, slug, text));
120
+ if (!note) {
121
+ return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "append", found: false } };
122
+ }
123
+ return {
124
+ content: [{ type: "text", text: `Appended to ${note.slug} (updated ${note.updated}).` }],
125
+ details: { action: "append", found: true, note },
126
+ };
127
+ }
128
+
129
+ case "finalize": {
130
+ if (!params.slug) throw new Error("weave_note(finalize) requires 'slug'");
131
+ if (!params.text) throw new Error("weave_note(finalize) requires 'text'");
132
+ const slug = params.slug;
133
+ const text = params.text;
134
+ const path = resolveNotePath(vault, slug);
135
+ if (!path) {
136
+ return {
137
+ content: [
138
+ {
139
+ type: "text",
140
+ text: `Invalid note slug '${slug}' — notes are flat files inside the vault (no path separators or '..').`,
141
+ },
142
+ ],
143
+ details: { action: "finalize", found: false },
144
+ };
145
+ }
146
+ // Serialized read-modify-write, same as append: finalize replaces the
147
+ // body above the raw tail, so it must not race other writers.
148
+ const note = await withMutationQueue(path, () => finalizeNote(vault, slug, { body: text }));
149
+ if (!note) {
150
+ return { content: [{ type: "text", text: `No note found with slug '${params.slug}'.` }], details: { action: "finalize", found: false } };
151
+ }
152
+ return {
153
+ content: [{ type: "text", text: `Finalized ${note.slug} (updated ${note.updated}). Raw notes tail preserved.` }],
154
+ details: { action: "finalize", found: true, note },
155
+ };
156
+ }
157
+
158
+ case "search": {
159
+ if (!params.query) throw new Error("weave_note(search) requires 'query'");
160
+ const hits = await searchNotes(vault, params.query);
161
+ if (hits.length === 0) {
162
+ return {
163
+ content: [{ type: "text", text: `No notes matched '${params.query}'.` }],
164
+ details: { action: "search", hits: [] },
165
+ };
166
+ }
167
+ const lines = hits.map(
168
+ (h) => `- ${h.summary.slug}: ${h.summary.title} (score ${h.score})\n ${h.snippet}`,
169
+ );
170
+ return {
171
+ content: [{ type: "text", text: `${hits.length} hit(s) for '${params.query}':\n${lines.join("\n")}` }],
172
+ details: { action: "search", hits },
173
+ };
174
+ }
175
+ }
176
+ },
177
+ });
178
+ }
@@ -0,0 +1,94 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import {
5
+ assessStaleness,
6
+ buildRepoIndex,
7
+ findGitRoot,
8
+ readRepoIndex,
9
+ repoIndexDir,
10
+ summarizeIndex,
11
+ writeRepoIndex,
12
+ } from "../../core";
13
+
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
+ */
20
+ export function registerRepoTool(pi: ExtensionAPI): void {
21
+ pi.registerTool({
22
+ name: "weave_repo",
23
+ 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.",
29
+ promptSnippet: "Explore the repository's structure via its .okf knowledge index",
30
+ promptGuidelines: [
31
+ "Use weave_repo action=overview to learn repository structure before broad code exploration instead of scanning files one by one.",
32
+ "Use weave_repo action=scan when the user asks to explore or index this repository.",
33
+ ],
34
+ parameters: Type.Object({
35
+ action: StringEnum(["status", "scan", "overview"] as const),
36
+ }),
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
+ }
92
+ },
93
+ });
94
+ }