pi-weave 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/index.ts CHANGED
@@ -13,11 +13,20 @@ export {
13
13
  summarizeIndex,
14
14
  writeRepoIndex,
15
15
  } from "./repoIndex";
16
- export { runDeepScan, type DeepScanOptions, type DeepScanResult, type SummarizeFn } from "./summaries";
16
+ export {
17
+ DEEP_SCAN_SYSTEM_PROMPT,
18
+ formatDeepScanResult,
19
+ hashContent,
20
+ runDeepScan,
21
+ type DeepScanOptions,
22
+ type DeepScanResult,
23
+ type SummarizeFn,
24
+ } from "./summaries";
17
25
  export {
18
26
  DEFAULT_SESSIONS_ROOT,
19
27
  SESSIONS_ENV_VAR,
20
28
  deriveSessionTitle,
29
+ formatSessionScanResult,
21
30
  listSessionFiles,
22
31
  migrateLegacySessionNotes,
23
32
  parseSessionDigest,
@@ -27,6 +36,8 @@ export {
27
36
  renderSessionDigest,
28
37
  resolveSessionsRoot,
29
38
  runSessionScan,
39
+ runSessionDigestScan,
40
+ SESSION_SCAN_SYSTEM_PROMPT,
30
41
  sessionHasContent,
31
42
  sessionNoteBody,
32
43
  sessionNoteFields,
@@ -34,6 +45,7 @@ export {
34
45
  writeSessionNote,
35
46
  type SessionChain,
36
47
  type SessionDigest,
48
+ type SessionDigestScanOptions,
37
49
  type SessionScanOptions,
38
50
  type SessionScanResult,
39
51
  } from "./sessions";
@@ -64,8 +76,10 @@ export {
64
76
  type LinkRepairResult,
65
77
  } from "./vault";
66
78
  export {
79
+ relatedNotes,
67
80
  suggestLinks,
68
81
  type LinkSuggestion,
82
+ type RelatedNote,
69
83
  type SuggestOptions,
70
84
  type SuggestionReport,
71
85
  } from "./links/similar";
@@ -82,6 +96,18 @@ export type { HtmlArtifact } from "./types";
82
96
  export { withMutationQueue } from "./mutex";
83
97
  export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
84
98
  export { WorkspaceCache } from "./cache/workspace";
99
+ export {
100
+ executeNoteAction,
101
+ WEAVE_NOTE_DESCRIPTION,
102
+ type NoteActionInput,
103
+ type NoteActionResult,
104
+ } from "./noteAction";
105
+ export {
106
+ executeRepoAction,
107
+ WEAVE_REPO_DESCRIPTION,
108
+ type RepoActionInput,
109
+ type RepoActionResult,
110
+ } from "./repoAction";
85
111
  export {
86
112
  buildCurrentGraph,
87
113
  readNoteForView,
@@ -87,8 +87,9 @@ export interface SuggestOptions {
87
87
  evidence?: number;
88
88
  }
89
89
 
90
- /** Words too structural to carry meaning in any vault, in any domain. */
91
90
  const TERM_RE = /[\p{L}][\p{L}\p{N}_-]{2,}/gu;
91
+ /** Link prose too structural to connect otherwise unrelated notes. */
92
+ const STRUCTURAL_TERMS = new Set(["and", "see", "the"]);
92
93
 
93
94
  /**
94
95
  * Smallest document-frequency ceiling, whatever `maxDocFrequency` computes.
@@ -114,7 +115,10 @@ export const MIN_TERM_CEILING = 10;
114
115
  function terms(note: Pick<Note, "title" | "tags" | "body">): Set<string> {
115
116
  const text = `${note.title} ${note.tags.join(" ")} ${note.body}`;
116
117
  const out = new Set<string>();
117
- for (const match of text.matchAll(TERM_RE)) out.add(match[0].toLowerCase());
118
+ for (const match of text.matchAll(TERM_RE)) {
119
+ const term = match[0].toLowerCase();
120
+ if (!STRUCTURAL_TERMS.has(term)) out.add(term);
121
+ }
118
122
  return out;
119
123
  }
120
124
 
@@ -123,6 +127,49 @@ export interface SuggestInput {
123
127
  notes: readonly Pick<Note, "slug" | "title" | "tags" | "body">[];
124
128
  }
125
129
 
130
+ /** One deterministic neighbour of a note, with reviewable evidence. */
131
+ export interface RelatedNote {
132
+ slug: string;
133
+ score: number;
134
+ reasons: string[];
135
+ }
136
+
137
+ function resolvedLinks(notes: SuggestInput["notes"]): {
138
+ pairs: Set<string>;
139
+ outgoing: Map<string, Set<string>>;
140
+ } {
141
+ const pairs = new Set<string>();
142
+ const outgoing = new Map<string, Set<string>>();
143
+ const bySlug = new Set(notes.map((note) => note.slug));
144
+ const byBasename = new Map<string, string[]>();
145
+ const byTitle = new Map<string, string[]>();
146
+ for (const note of notes) {
147
+ const base = note.slug.split("/").pop()!;
148
+ byBasename.set(base, [...(byBasename.get(base) ?? []), note.slug]);
149
+ const title = slugify(note.title);
150
+ if (title.length > 0) byTitle.set(title, [...(byTitle.get(title) ?? []), note.slug]);
151
+ }
152
+ for (const note of notes) {
153
+ for (const link of scanLinks(note.body)) {
154
+ const byBase = byBasename.get(link.target) ?? [];
155
+ const byTtl = byTitle.get(link.target) ?? [];
156
+ const target = bySlug.has(link.target)
157
+ ? link.target
158
+ : byBase.length === 1
159
+ ? byBase[0]!
160
+ : byTtl.length === 1
161
+ ? byTtl[0]!
162
+ : null;
163
+ if (target === null) continue;
164
+ pairs.add(pairKey(note.slug, target));
165
+ const targets = outgoing.get(note.slug);
166
+ if (targets) targets.add(target);
167
+ else outgoing.set(note.slug, new Set([target]));
168
+ }
169
+ }
170
+ return { pairs, outgoing };
171
+ }
172
+
126
173
  /**
127
174
  * Rank pairs of notes that share distinctive vocabulary but no link.
128
175
  *
@@ -175,34 +222,7 @@ export function suggestLinks(input: SuggestInput, options: SuggestOptions = {}):
175
222
 
176
223
  // Existing links, both directions, so a connection the user already made
177
224
  // is never offered back to them.
178
- const linked = new Set<string>();
179
- const bySlug = new Set(notes.map((note) => note.slug));
180
- const byBasename = new Map<string, string[]>();
181
- const byTitle = new Map<string, string[]>();
182
- for (const note of notes) {
183
- const base = note.slug.split("/").pop()!;
184
- byBasename.set(base, [...(byBasename.get(base) ?? []), note.slug]);
185
- const title = slugify(note.title);
186
- if (title.length > 0) byTitle.set(title, [...(byTitle.get(title) ?? []), note.slug]);
187
- }
188
- for (const note of notes) {
189
- for (const link of scanLinks(note.body)) {
190
- // The same three-rung ladder `auditLinks` walks — exact slug, unique
191
- // basename, unique title. It has to be all three: a link repair would
192
- // resolve counts as a connection that already exists, so resolving a
193
- // rung short here would suggest a pair the vault has already linked.
194
- const byBase = byBasename.get(link.target) ?? [];
195
- const byTtl = byTitle.get(link.target) ?? [];
196
- const target = bySlug.has(link.target)
197
- ? link.target
198
- : byBase.length === 1
199
- ? byBase[0]!
200
- : byTtl.length === 1
201
- ? byTtl[0]!
202
- : null;
203
- if (target !== null) linked.add(pairKey(note.slug, target));
204
- }
205
- }
225
+ const { pairs: linked } = resolvedLinks(notes);
206
226
 
207
227
  // Candidate generation through an inverted index on distinctive terms
208
228
  // only. Comparing every pair is O(n²) and mostly compares notes with
@@ -252,6 +272,53 @@ export function suggestLinks(input: SuggestInput, options: SuggestOptions = {}):
252
272
  return { considered: vectors.size, suggestions: suggestions.slice(0, limit) };
253
273
  }
254
274
 
275
+ /**
276
+ * Rank the explicit and lexical neighbours of one note.
277
+ *
278
+ * This is deliberately not semantic search: every result cites a link,
279
+ * backlink, shared tag, or shared term already present in the vault.
280
+ */
281
+ export function relatedNotes(input: SuggestInput, slug: string, limit = 20): RelatedNote[] {
282
+ if (limit <= 0) return [];
283
+ const focus = input.notes.find((note) => note.slug === slug);
284
+ if (!focus) return [];
285
+
286
+ const related = new Map<string, RelatedNote>();
287
+ const add = (target: string, score: number, reason: string) => {
288
+ if (target === slug) return;
289
+ const existing = related.get(target);
290
+ if (existing) {
291
+ existing.score = Math.max(existing.score, score);
292
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
293
+ } else {
294
+ related.set(target, { slug: target, score, reasons: [reason] });
295
+ }
296
+ };
297
+
298
+ const { outgoing } = resolvedLinks(input.notes);
299
+ for (const target of outgoing.get(slug) ?? []) add(target, 400, `linked from ${slug}`);
300
+ for (const [source, targets] of outgoing) {
301
+ if (targets.has(slug)) add(source, 400, `links to ${slug}`);
302
+ }
303
+
304
+ const focusTags = new Map(focus.tags.map((tag) => [tag.toLowerCase(), tag]));
305
+ for (const note of input.notes) {
306
+ const shared = [...new Set(note.tags.map((tag) => tag.toLowerCase()))]
307
+ .filter((tag) => focusTags.has(tag))
308
+ .map((tag) => focusTags.get(tag)!);
309
+ if (shared.length > 0) add(note.slug, 300 + shared.length, `shared tags: ${shared.join(", ")}`);
310
+ }
311
+
312
+ for (const suggestion of suggestLinks(input, { slug, limit: Math.max(limit, 20) }).suggestions) {
313
+ const target = suggestion.a === slug ? suggestion.b : suggestion.a;
314
+ add(target, 200 + suggestion.score, `shared terms: ${suggestion.shared.join(", ")}`);
315
+ }
316
+
317
+ return [...related.values()]
318
+ .sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
319
+ .slice(0, limit);
320
+ }
321
+
255
322
  /** Order-independent key for an unordered pair. */
256
323
  function pairKey(a: string, b: string): string {
257
324
  return a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
@@ -0,0 +1,343 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ addNote,
4
+ appendToNote,
5
+ extractRawTail,
6
+ finalizeNote,
7
+ formatNote,
8
+ getNote,
9
+ listNotes,
10
+ readVault,
11
+ repairVaultLinks,
12
+ resolveNotePath,
13
+ searchNotes,
14
+ type LinkRepairResult,
15
+ } from "./vault";
16
+ import { relatedNotes, suggestLinks, type RelatedNote, type SuggestionReport } from "./links/similar";
17
+ import { NOTES_DIR, resolveVaultRoot } from "./paths";
18
+ import { withMutationQueue } from "./mutex";
19
+ import type { Note, NoteSearchHit } from "./types";
20
+
21
+ export const WEAVE_NOTE_DESCRIPTION =
22
+ "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
23
+ "of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
24
+ "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
25
+ "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), " +
26
+ "links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
27
+ "suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
28
+ "Use it to remember decisions, facts, and user preferences across sessions.";
29
+
30
+ export interface NoteActionInput {
31
+ action: "list" | "get" | "add" | "append" | "finalize" | "search" | "links" | "suggest";
32
+ title?: string;
33
+ text?: string;
34
+ tags?: string[];
35
+ slug?: string;
36
+ raw?: boolean;
37
+ source?: "human" | "agent";
38
+ query?: string;
39
+ fix?: boolean;
40
+ limit?: number;
41
+ }
42
+
43
+ export interface NoteActionResult {
44
+ text: string;
45
+ details: Record<string, unknown>;
46
+ }
47
+
48
+ const LINK_REPORT_CAP = 20;
49
+ const LIST_CAP = 50;
50
+ const SEARCH_REPORT_CAP = 10;
51
+ const RELATED_REPORT_CAP = 5;
52
+ const COMPLETE_BODY_CAP = 2_000;
53
+ const COMPLETE_BODY_RESULTS = 3;
54
+
55
+ function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
56
+ const lines = items.slice(0, LINK_REPORT_CAP).map(render);
57
+ if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
58
+ return lines;
59
+ }
60
+
61
+ function formatSuggestions(report: SuggestionReport, focus: string | undefined): string {
62
+ if (report.suggestions.length === 0) {
63
+ return focus === undefined
64
+ ? `No unlinked notes share enough distinctive vocabulary to suggest a connection (${report.considered} note(s) considered).`
65
+ : `Nothing unlinked looks related to '${focus}' (${report.considered} note(s) considered).`;
66
+ }
67
+ const head = focus === undefined
68
+ ? `${report.suggestions.length} suggested connection(s) across ${report.considered} note(s):`
69
+ : `${report.suggestions.length} note(s) look related to '${focus}':`;
70
+ const rows = report.suggestions.map((suggestion) => {
71
+ const pair = focus === undefined
72
+ ? `${suggestion.a} ↔ ${suggestion.b}`
73
+ : suggestion.a === focus ? suggestion.b : suggestion.a;
74
+ return ` ${suggestion.score.toFixed(3)} ${pair}\n shared: ${suggestion.shared.join(", ")}`;
75
+ });
76
+ return [
77
+ head,
78
+ ...rows,
79
+ "",
80
+ "These are suggestions, not links — nothing was written. Add a [[wikilink]] to any pair worth keeping.",
81
+ ].join("\n");
82
+ }
83
+
84
+ function searchReasons(hit: NoteSearchHit, query: string): string[] {
85
+ const q = query.trim().toLowerCase();
86
+ const title = hit.summary.title.toLowerCase();
87
+ const slug = hit.summary.slug.toLowerCase();
88
+ const reasons: string[] = [];
89
+ if (title === q) reasons.push("exact title");
90
+ else if (slug === q) reasons.push("exact slug");
91
+ else if (title.includes(q)) reasons.push("title");
92
+ else if (slug.includes(q)) reasons.push("slug");
93
+ const tags = hit.summary.tags.filter((tag) => tag.toLowerCase().includes(q));
94
+ if (tags.length > 0) reasons.push(`tags: ${tags.join(", ")}`);
95
+ if (hit.snippet.toLowerCase().includes(q)) reasons.push("body");
96
+ if (reasons.length === 0) {
97
+ const haystack = `${title} ${slug} ${hit.summary.tags.join(" ").toLowerCase()} ${hit.snippet.toLowerCase()}`;
98
+ const terms = [...new Set(q.match(/[\p{L}\p{N}_-]{2,}/gu) ?? [])].filter((term) => haystack.includes(term));
99
+ if (terms.length > 0) reasons.push(`query terms: ${terms.join(", ")}`);
100
+ }
101
+ return reasons;
102
+ }
103
+
104
+ function searchContent(note: Note | undefined, snippet: string, complete: boolean): string {
105
+ const body = note?.body.trim() ?? "";
106
+ if (complete && body.length <= COMPLETE_BODY_CAP) return `[complete body]\n ${body}`;
107
+ return `[excerpt${body.length > COMPLETE_BODY_CAP ? "; use get for the full note" : ""}]\n ${snippet}`;
108
+ }
109
+
110
+ function formatSearchHits(
111
+ hits: readonly NoteSearchHit[],
112
+ query: string,
113
+ relations: ReadonlyMap<string, RelatedNote>,
114
+ notes: ReadonlyMap<string, Note>,
115
+ ): string {
116
+ const shown = hits.slice(0, SEARCH_REPORT_CAP);
117
+ const lines = shown.map((hit, index) => {
118
+ const tags = hit.summary.tags.length > 0 ? `; tags: ${hit.summary.tags.join(", ")}` : "";
119
+ const relation = relations.get(hit.summary.slug);
120
+ const connected = relation ? `; connected: ${relation.reasons.join("; ")}` : "";
121
+ const content = searchContent(notes.get(hit.summary.slug), hit.snippet, index < COMPLETE_BODY_RESULTS);
122
+ 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}`;
123
+ });
124
+ if (hits.length > shown.length) lines.push(`… and ${hits.length - shown.length} more direct match(es).`);
125
+ return lines.join("\n");
126
+ }
127
+
128
+ function preview(body: string): string {
129
+ const flat = body.trim().replace(/\s+/g, " ");
130
+ return flat.length > 180 ? `${flat.slice(0, 180)}…` : flat;
131
+ }
132
+
133
+ function formatRelatedNotes(
134
+ relations: readonly RelatedNote[],
135
+ notes: ReadonlyMap<string, Note>,
136
+ direct: ReadonlySet<string>,
137
+ anchor: Note,
138
+ ): string {
139
+ const shown = relations
140
+ .filter((relation) => !direct.has(relation.slug))
141
+ .flatMap((relation) => {
142
+ const note = notes.get(relation.slug);
143
+ return note ? [{ relation, note }] : [];
144
+ })
145
+ .slice(0, RELATED_REPORT_CAP);
146
+ if (shown.length === 0) return "";
147
+ const lines = shown.map(({ relation, note }, index) => {
148
+ const tags = note.tags.length > 0 ? `; tags: ${note.tags.join(", ")}` : "";
149
+ const content = searchContent(note, preview(note.body), index < COMPLETE_BODY_RESULTS);
150
+ return `- ${note.slug}: ${note.title}\n connected: ${relation.reasons.join("; ")}; source: ${note.source}; updated: ${note.updated}${tags}\n ${content}`;
151
+ });
152
+ return `Connected notes to '${anchor.title}' (discovery context only — not direct query matches or evidence about the query):\n${lines.join("\n")}`;
153
+ }
154
+
155
+ function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
156
+ const { audit } = result;
157
+ const lines = [`${audit.total} wiki-link(s): ${audit.resolved} resolved, ${audit.total - audit.resolved} stale.`];
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, (fix) => ` ${fix.slug}: [[${fix.from}]] → [[${fix.to}]] (${fix.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, (fix) => ` ${fix.slug}: [[${fix.from}]] → [[${fix.to}]] (${fix.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, (link) => ` ${link.slug}: [[${link.target}]] → ${link.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, (link) => ` [[${link.target}]] ← ${link.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
+ export async function executeNoteAction(
190
+ params: NoteActionInput,
191
+ vault = resolveVaultRoot(),
192
+ now: () => Date = () => new Date(),
193
+ ): Promise<NoteActionResult> {
194
+ switch (params.action) {
195
+ case "list": {
196
+ const notes = await listNotes(vault);
197
+ if (notes.length === 0) return { text: `The vault at ${vault} has no notes yet.`, details: { action: "list", notes: [] } };
198
+ const shown = notes.slice(0, LIST_CAP);
199
+ const lines = shown.map(
200
+ (note) => `- ${note.slug}: ${note.title}${note.tags.length > 0 ? ` [${note.tags.join(", ")}]` : ""} (updated ${note.updated}, source: ${note.source})`,
201
+ );
202
+ if (notes.length > shown.length) {
203
+ lines.push(`… and ${notes.length - shown.length} more (newest ${shown.length} shown) — use action=search to find a specific note.`);
204
+ }
205
+ return { text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}`, details: { action: "list", notes } };
206
+ }
207
+
208
+ case "get": {
209
+ if (!params.slug) throw new Error("weave_note(get) requires 'slug'");
210
+ const note = await getNote(vault, params.slug);
211
+ return note
212
+ ? { text: formatNote(note), details: { action: "get", found: true, note } }
213
+ : { text: `No note found with slug '${params.slug}'.`, details: { action: "get", found: false } };
214
+ }
215
+
216
+ case "add": {
217
+ if (!params.title) throw new Error("weave_note(add) requires 'title'");
218
+ if (!params.text) throw new Error("weave_note(add) requires 'text'");
219
+ const note = await withMutationQueue(join(vault, NOTES_DIR), () =>
220
+ addNote(vault, {
221
+ title: params.title!,
222
+ body: params.text!,
223
+ ...(params.tags ? { tags: params.tags } : {}),
224
+ ...(params.source ? { source: params.source } : {}),
225
+ }),
226
+ );
227
+ return { text: `Note created: ${note.slug} (${vault})`, details: { action: "add", note } };
228
+ }
229
+
230
+ case "append": {
231
+ if (!params.slug) throw new Error("weave_note(append) requires 'slug'");
232
+ if (!params.text) throw new Error("weave_note(append) requires 'text'");
233
+ const path = resolveNotePath(vault, params.slug);
234
+ if (!path) {
235
+ return {
236
+ text: `Invalid note slug '${params.slug}' — notes are flat files inside the vault (no path separators or '..').`,
237
+ details: { action: "append", found: false },
238
+ };
239
+ }
240
+ const note = await withMutationQueue(path, () =>
241
+ appendToNote(vault, params.slug!, params.text!, now(), params.raw ? { raw: true } : {}),
242
+ );
243
+ if (!note) return { text: `No note found with slug '${params.slug}'.`, details: { action: "append", found: false } };
244
+ return {
245
+ text: params.raw
246
+ ? `Appended verbatim to the ## Raw tail of ${note.slug} (updated ${note.updated}).`
247
+ : `Appended to ${note.slug} (updated ${note.updated}).`,
248
+ details: { action: "append", found: true, note },
249
+ };
250
+ }
251
+
252
+ case "finalize": {
253
+ if (!params.slug) throw new Error("weave_note(finalize) requires 'slug'");
254
+ if (!params.text) throw new Error("weave_note(finalize) requires 'text'");
255
+ const path = resolveNotePath(vault, params.slug);
256
+ if (!path) {
257
+ return {
258
+ text: `Invalid note slug '${params.slug}' — notes are flat files inside the vault (no path separators or '..').`,
259
+ details: { action: "finalize", found: false },
260
+ };
261
+ }
262
+ const note = await withMutationQueue(path, () => finalizeNote(vault, params.slug!, { body: params.text! }));
263
+ if (!note) return { text: `No note found with slug '${params.slug}'.`, details: { action: "finalize", found: false } };
264
+ const preserved = extractRawTail(note.body) !== "";
265
+ return {
266
+ text: `Finalized ${note.slug} (updated ${note.updated}). ${preserved ? "Raw tail preserved beneath the structured body." : "Note body was empty — nothing to preserve."}`,
267
+ details: { action: "finalize", found: true, note },
268
+ };
269
+ }
270
+
271
+ case "search": {
272
+ if (!params.query) throw new Error("weave_note(search) requires 'query'");
273
+ const hits = await searchNotes(vault, params.query);
274
+ if (hits.length === 0) return { text: `No notes matched '${params.query}'.`, details: { action: "search", hits: [] } };
275
+ const { notes } = await readVault(vault);
276
+ const bySlug = new Map(notes.map((note) => [note.slug, note]));
277
+ const direct = new Set(hits.map((hit) => hit.summary.slug));
278
+ const anchor = bySlug.get(hits[0]!.summary.slug);
279
+ const related = anchor ? relatedNotes({ notes }, anchor.slug) : [];
280
+ const relatedBySlug = new Map(related.map((relation) => [relation.slug, relation]));
281
+ const connected = anchor ? formatRelatedNotes(related, bySlug, direct, anchor) : "";
282
+ const query = params.query.trim().toLowerCase();
283
+ const exact = hits.filter((hit) =>
284
+ hit.summary.title.trim().toLowerCase() === query || hit.summary.slug.toLowerCase() === query
285
+ );
286
+ const resolved = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
287
+ if (resolved) {
288
+ const note = bySlug.get(resolved.summary.slug);
289
+ if (note) {
290
+ const others = hits.filter((hit) => hit.summary.slug !== note.slug);
291
+ const otherMatches = others.length > 0
292
+ ? `Other direct matches (${others.length}):\n${formatSearchHits(others, params.query, relatedBySlug, bySlug)}`
293
+ : "";
294
+ return {
295
+ text: [formatNote(note).trimEnd(), otherMatches, connected].filter(Boolean).join("\n\n") + "\n",
296
+ details: { action: "search", hits, resolved: note, related },
297
+ };
298
+ }
299
+ }
300
+ return {
301
+ text: [
302
+ `${hits.length} direct match(es) for '${params.query}', strongest first:\n${formatSearchHits(hits, params.query, relatedBySlug, bySlug)}`,
303
+ connected,
304
+ ].filter(Boolean).join("\n\n"),
305
+ details: { action: "search", hits, related },
306
+ };
307
+ }
308
+
309
+ case "suggest": {
310
+ const { notes } = await readVault(vault);
311
+ const report = suggestLinks(
312
+ { notes },
313
+ {
314
+ ...(params.slug ? { slug: params.slug } : {}),
315
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
316
+ },
317
+ );
318
+ return {
319
+ text: formatSuggestions(report, params.slug),
320
+ details: { action: "suggest", considered: report.considered, suggestions: report.suggestions },
321
+ };
322
+ }
323
+
324
+ case "links": {
325
+ const apply = params.fix === true;
326
+ const result = await repairVaultLinks(vault, apply ? { apply: true } : {});
327
+ return {
328
+ text: formatLinkReport(result, apply),
329
+ details: {
330
+ action: "links",
331
+ fixed: apply,
332
+ total: result.audit.total,
333
+ resolved: result.audit.resolved,
334
+ fixable: result.audit.fixable,
335
+ ambiguous: result.audit.ambiguous,
336
+ unresolvable: result.audit.unresolvable,
337
+ applied: result.applied,
338
+ notes: result.notes,
339
+ },
340
+ };
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ assessStaleness,
3
+ buildRepoIndex,
4
+ readRepoIndex,
5
+ summarizeIndex,
6
+ writeRepoIndex,
7
+ } from "./repoIndex";
8
+ import { findGitRoot } from "./git";
9
+ import { repoIndexDir } from "./paths";
10
+
11
+ export const WEAVE_REPO_DESCRIPTION =
12
+ "Explore the current git repository through its pi-weave knowledge index (.okf). " +
13
+ "Actions: status (index freshness vs git state), scan (build/refresh the index), " +
14
+ "overview (read the indexed structure: languages, packages, modules, entry points). " +
15
+ "The index is derived and rebuildable; scanning is always safe.";
16
+
17
+ export interface RepoActionInput {
18
+ action: "status" | "scan" | "overview";
19
+ }
20
+
21
+ export interface RepoActionResult {
22
+ text: string;
23
+ details: Record<string, unknown>;
24
+ }
25
+
26
+ export async function executeRepoAction(
27
+ params: RepoActionInput,
28
+ cwd: string,
29
+ onProgress?: (text: string) => void | Promise<void>,
30
+ ): Promise<RepoActionResult> {
31
+ const root = await findGitRoot(cwd);
32
+ if (!root) {
33
+ return {
34
+ text: "Not inside a git repository — repository knowledge is unavailable here.",
35
+ details: { action: params.action, inRepo: false },
36
+ };
37
+ }
38
+
39
+ switch (params.action) {
40
+ case "status": {
41
+ const staleness = await assessStaleness(root);
42
+ const index = staleness.state !== "missing" ? await readRepoIndex(root) : null;
43
+ const lines = [`Index state: ${staleness.state}`];
44
+ for (const reason of staleness.reasons) lines.push(`- ${reason}`);
45
+ if (index) lines.push(`Indexed at: ${index.updated} by ${index.generator}`);
46
+ return {
47
+ text: `Repository ${root}\n${lines.join("\n")}`,
48
+ details: { action: "status", inRepo: true, staleness, indexed: index !== null },
49
+ };
50
+ }
51
+
52
+ case "scan": {
53
+ await onProgress?.(`Scanning ${root}…`);
54
+ const index = await buildRepoIndex(root);
55
+ if (!index) {
56
+ return {
57
+ text: "Cannot build index: the repository has no commits yet.",
58
+ details: { action: "scan", inRepo: true, scanned: false },
59
+ };
60
+ }
61
+ const dir = await writeRepoIndex(root, index);
62
+ return {
63
+ text: `Knowledge index written to ${dir}\n\n${summarizeIndex(index).join("\n")}`,
64
+ details: { action: "scan", inRepo: true, scanned: true, index },
65
+ };
66
+ }
67
+
68
+ case "overview": {
69
+ const index = await readRepoIndex(root);
70
+ if (!index) {
71
+ return {
72
+ text: `No knowledge index at ${repoIndexDir(root)} yet. Use action=scan to build one.`,
73
+ details: { action: "overview", inRepo: true, indexed: false },
74
+ };
75
+ }
76
+ const staleness = await assessStaleness(root);
77
+ const header = staleness.state === "fresh" ? "" : `⚠ index is ${staleness.state} (consider rescanning)\n`;
78
+ return {
79
+ text: header + summarizeIndex(index).join("\n"),
80
+ details: { action: "overview", inRepo: true, indexed: true, staleness, index },
81
+ };
82
+ }
83
+ }
84
+ }