pi-weave 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,90 +1,42 @@
1
- # Link repair — keeping the vault connected
1
+ # Link repair
2
2
 
3
- A wiki-link resolves to nothing when it is written as a bare title or basename — `[[Quarterly Roadmap]]` while the note lives at
4
- `planning/roadmap-2026` — or when it points at a note that was never written. The graph then shows an isolated note that is in fact well
5
- connected.
6
-
7
- **Never reconnect a vault by reading it.** Do not search the vault note by note, infer which notes "feel related", and hand-write links.
8
- That is slow, costs tokens, and is not reproducible — two runs give two different answers. There is a deterministic pass that does it in
9
- one shot.
10
-
11
- ## The tool
3
+ Use deterministic repair for stale `[[wiki-links]]`; do not read every note and guess connections.
12
4
 
13
5
  ```jsonc
14
- weave_note { "action": "links" } // read-only report
15
- weave_note { "action": "links", "fix": true } // apply the unambiguous repairs
6
+ weave_note { "action": "links" } // report
7
+ weave_note { "action": "links", "fix": true } // apply unambiguous repairs
16
8
  ```
17
9
 
18
- In other harnesses, call `repairVaultLinks(vaultRoot, { apply })` from `pi-weave/core`.
19
-
20
- Always run the report first, read it, then apply. The report is cheap (one pass over the vault, no model calls).
21
-
22
- ## How targets resolve
23
-
24
- Three rules, tried in order. A rule fires only when it yields **exactly one** candidate:
25
-
26
- | # | Rule | Example |
27
- |---|------|---------|
28
- | 1 | exact slug | `[[planning/roadmap-2026]]` — already correct, left alone |
29
- | 2 | unique basename | `[[roadmap-2026]]` → `planning/roadmap-2026` |
30
- | 3 | unique slugified title | `[[Quarterly Roadmap]]` → `planning/roadmap-2026` |
31
-
32
- Anything else is reported, never guessed:
33
-
34
- - **ambiguous** — several notes match (two `plan.md` in different folders). The report lists the candidates; a human picks one, or you
35
- ask. Do not choose on their behalf.
36
- - **unresolvable** — no note matches. The link points at something never written. Offer to create the note or drop the link; **never
37
- invent content to satisfy a link.**
10
+ Without the tool, call `repairVaultLinks(vaultRoot, { apply })` from `pi-weave/core`. Report first, then apply after user review; never run
11
+ `fix: true` unprompted on a vault you did not just change.
38
12
 
39
- ## What a repair does and does not do
13
+ ## Resolution
40
14
 
41
- - Rewrites `[[Quarterly Roadmap]]` → `[[planning/roadmap-2026|Quarterly Roadmap]]`. The **alias preserves the visible text**, so the rendered prose is unchanged —
42
- only the target moves.
43
- - **Does not touch the `## Raw` tail.** A link inside dictation is the user's words, quoted. Off limits, always.
44
- - **Does not touch fenced code blocks.** `[[…]]` in a code sample is a string literal.
45
- - **Does not bump `updated`.** A repair is bookkeeping, not an edit; bumping it would reorder the whole vault by recency.
46
- - **Is idempotent.** A second run finds nothing.
15
+ Try these in order; each requires exactly one candidate:
47
16
 
48
- ## Renames repair themselves
17
+ | Rule | Example |
18
+ |------|---------|
19
+ | Exact slug | `[[planning/roadmap-2026]]` — unchanged |
20
+ | Unique basename | `[[roadmap-2026]]` → `planning/roadmap-2026` |
21
+ | Unique slugified title | `[[Quarterly Roadmap]]` → `planning/roadmap-2026` |
49
22
 
50
- `renameNote`, `moveNote` and `renameFolder` rewrite inbound links automatically, so renaming or moving a note keeps its backlinks intact.
51
- The repair pass is for links that were *written* stale — typed as a bare title, or pointing somewhere that never existed.
23
+ - **Ambiguous:** report candidates and ask; never choose for the user.
24
+ - **Unresolvable:** offer to create the missing note or remove the link; never invent content to satisfy it.
52
25
 
53
- ## Finding connections that were never made
26
+ Repairs preserve visible text through aliases: `[[Quarterly Roadmap]]` becomes `[[planning/roadmap-2026|Quarterly Roadmap]]`. They leave raw
27
+ tails, fenced code, and `updated` untouched. A second run makes no changes. `renameNote`, `moveNote`, and `renameFolder` already repair
28
+ inbound links automatically.
54
29
 
55
- Repair fixes links that point wrong. It cannot find links that were **never written** — two notes that belong together but have never referenced each
56
- other. That is `suggest`:
30
+ ## Unwritten connections
57
31
 
58
32
  ```jsonc
59
- weave_note { "action": "suggest" } // strongest pairs vault-wide
60
- weave_note { "action": "suggest", "slug": "some/note" } // what relates to this note
33
+ weave_note { "action": "suggest" } // strongest unlinked pairs
34
+ weave_note { "action": "suggest", "slug": "some/note" } // one note's candidates
61
35
  weave_note { "action": "suggest", "limit": 40 }
62
36
  ```
63
37
 
64
- It ranks unlinked pairs by IDF-weighted cosine over every term a note carries — title, tags and body in one bag — weighting each term by how rare
65
- it is *in this vault*. Vocabulary shared by most notes (`sprint`, `meeting`, a tag on half the vault) scores near zero and connects nothing;
66
- a ticket id or an unusual name on a handful of notes scores high. Nothing is domain-specific: the vault's own frequencies decide.
67
-
68
- Every suggestion cites the shared terms that earned it. **Read the evidence, not the score** — a list like `shared: acme-1234, release-pipeline`
69
- is checkable, `0.16` is not.
70
-
71
- ### suggest never writes
72
-
73
- This is the rule that matters. `suggest` only reports; there is no `fix`. A similarity score is a soft signal and a `[[link]]` is a hard claim —
74
- once written into a body it is indistinguishable from one the user wrote deliberately. Good scores here are around 0.1–0.3, not 0.9, so treat the
75
- output as a shortlist for a human:
76
-
77
- 1. Run `suggest`, read the shared terms.
78
- 2. Propose the worthwhile pairs **to the user**.
79
- 3. Add `[[wikilinks]]` only to those they confirm.
80
-
81
- Never bulk-apply suggestions, and never present one as an established connection.
82
-
83
- ## When to run it
84
-
85
- - The user asks to "fix the links in" their notes — `links`.
86
- - The health panel or `/weave` reports dangling links — `links`.
87
- - After bulk-importing or reorganising notes outside the tool — `links`.
88
- - The user asks what a note "relates to", or to "connect" / "link up" the vault — `suggest`, then confirm before writing.
38
+ Suggestions rank unlinked pairs by IDF-weighted cosine over title, tags, and body. Rare shared vocabulary matters more than common terms.
39
+ Read the cited terms, not just the score. `suggest` never writes and has no `fix`: propose useful pairs and add links only after
40
+ confirmation. Never bulk-apply suggestions or present them as established connections.
89
41
 
90
- Do not run `fix: true` unprompted on a vault you did not just change — show the report and let the user approve. Reporting is always safe.
42
+ Use `links` for dangling links or after external bulk imports; use `suggest` when asked to discover relationships or connect notes.
@@ -14,7 +14,7 @@ import { NOTE_SOURCES } from "./types";
14
14
  * not the same promise. `parseNoteFile` read five keys and `serializeNote`
15
15
  * wrote five keys, so every write through core silently deleted whatever
16
16
  * else the file carried — `aliases`, `cssclass`, `publish`, any property an
17
- * Obsidian user or another tool had added. Reading a note and appending one
17
+ * external editor or another tool had added. Reading a note and appending one
18
18
  * line destroyed the rest of its metadata (weave-workspace §11 P5).
19
19
  *
20
20
  * The fix is *not* a fuller YAML parser. A vault is plain text a human edits,
@@ -46,8 +46,8 @@ import { NOTE_SOURCES } from "./types";
46
46
  * change rather than a silent one. See {@link isDefaulted}.
47
47
  *
48
48
  * **An owned key whose on-disk syntax this subset cannot represent is
49
- * frozen**: carried verbatim, and not re-rendered or duplicated. Obsidian's
50
- * property editor writes tags as a YAML block list —
49
+ * frozen**: carried verbatim, and not re-rendered or duplicated. A property
50
+ * editor may write tags as a YAML block list —
51
51
  *
52
52
  * ```yaml
53
53
  * tags:
@@ -64,8 +64,8 @@ export interface GraphModel {
64
64
  * slug → wiki-link targets that resolved to no note in the graph
65
65
  * (weave-workspace §4.2).
66
66
  *
67
- * A `[[target]]` that matches nothing is not an error — it is Obsidian's
68
- * ghost node, the affordance that offers to create the missing note. The
67
+ * A `[[target]]` that matches nothing is not an error — it is a ghost node,
68
+ * the affordance that offers to create the missing note. The
69
69
  * builder used to count these and throw the names away, leaving
70
70
  * `detail["dangling links"] = "3"` as the only trace; a display string is
71
71
  * not something a UI can navigate.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Obsidian-compatible [[wiki-link]] extraction from note bodies.
2
+ * Wiki-link extraction from note bodies.
3
3
  * Pure module used by graph construction and viewer view-models.
4
4
  */
5
5
 
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";
@@ -84,6 +96,18 @@ export type { HtmlArtifact } from "./types";
84
96
  export { withMutationQueue } from "./mutex";
85
97
  export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
86
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";
87
111
  export {
88
112
  buildCurrentGraph,
89
113
  readNoteForView,
@@ -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
+ }