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
|
@@ -1,90 +1,42 @@
|
|
|
1
|
-
# Link repair
|
|
1
|
+
# Link repair
|
|
2
2
|
|
|
3
|
-
|
|
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" } //
|
|
15
|
-
weave_note { "action": "links", "fix": true } // apply
|
|
6
|
+
weave_note { "action": "links" } // report
|
|
7
|
+
weave_note { "action": "links", "fix": true } // apply unambiguous repairs
|
|
16
8
|
```
|
|
17
9
|
|
|
18
|
-
|
|
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
|
-
##
|
|
13
|
+
## Resolution
|
|
40
14
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
other. That is `suggest`:
|
|
30
|
+
## Unwritten connections
|
|
57
31
|
|
|
58
32
|
```jsonc
|
|
59
|
-
weave_note { "action": "suggest" }
|
|
60
|
-
weave_note { "action": "suggest", "slug": "some/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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
42
|
+
Use `links` for dangling links or after external bulk imports; use `suggest` when asked to discover relationships or connect notes.
|
package/src/core/frontmatter.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
50
|
-
*
|
|
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:
|
package/src/core/graph/model.ts
CHANGED
|
@@ -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
|
|
68
|
-
*
|
|
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.
|
package/src/core/index.ts
CHANGED
|
@@ -13,11 +13,20 @@ export {
|
|
|
13
13
|
summarizeIndex,
|
|
14
14
|
writeRepoIndex,
|
|
15
15
|
} from "./repoIndex";
|
|
16
|
-
export {
|
|
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
|
+
}
|