pi-weave 0.1.21 → 0.1.23
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 +19 -0
- package/package.json +1 -1
- package/skills/weave-notepad/SKILL.md +42 -8
- package/skills/weave-notepad/references/link-repair.md +90 -0
- package/src/core/cache/workspace.ts +7 -13
- package/src/core/graph/build.ts +13 -35
- package/src/core/graph/current.ts +4 -5
- package/src/core/index.ts +18 -0
- package/src/core/links/repair.ts +330 -0
- package/src/core/links/similar.ts +279 -0
- package/src/core/vault.ts +147 -3
- package/src/core/view/health.ts +4 -0
- package/src/pi/tools/noteTool.ts +144 -4
- package/src/web/client/dist/app.js +42 -42
- package/src/web/client/graph/Graph.tsx +15 -0
- package/src/web/client/graph/column.model.ts +49 -33
- package/src/web/client/graph/graph.model.ts +205 -8
- package/src/web/client/graph/groups.ts +4 -4
- package/src/web/client/graph/renderer.dom.ts +5 -1
- package/src/web/client/graph/renderer.ts +115 -5
package/README.md
CHANGED
|
@@ -82,6 +82,24 @@ Useful requests include:
|
|
|
82
82
|
| “Clean up” or “finalize this note” | Reorganizes the readable body and preserves the raw tail |
|
|
83
83
|
| “What did we decide about…?” | Searches the vault, then reads the relevant notes |
|
|
84
84
|
| “Remember that…” | Stores durable knowledge for a future session |
|
|
85
|
+
| “Fix the broken links” | Audits and repairs stale `[[wiki-links]]` deterministically |
|
|
86
|
+
|
|
87
|
+
## Keeping the vault connected
|
|
88
|
+
|
|
89
|
+
A wiki-link resolves to nothing when it is written as a bare title — `[[Quarterly Roadmap]]` when the note lives at
|
|
90
|
+
`planning/roadmap-2026` — or when it points at a note that was never written. Pi repairs those in one deterministic pass instead of
|
|
91
|
+
rereading the vault and guessing.
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
You: Check the links in my notes.
|
|
95
|
+
You: Fix the ones you're sure about.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A target resolves by exact slug, then unique basename, then unique title — each rule requiring **exactly one** candidate. Ambiguous links
|
|
99
|
+
are reported with their candidates and never guessed; links to notes that were never written are listed, never invented. Repairs preserve
|
|
100
|
+
the alias, so the visible text does not change, and never touch the `## Raw` tail or code fences.
|
|
101
|
+
|
|
102
|
+
Renaming or moving a note rewrites its inbound links automatically, so its backlinks stay intact.
|
|
85
103
|
|
|
86
104
|
## `/weave-view`
|
|
87
105
|
|
|
@@ -209,6 +227,7 @@ Read [AGENTS.md](AGENTS.md) before contributing. Work on a feature branch; do no
|
|
|
209
227
|
|
|
210
228
|
- [Design](docs/design.md) — product and architecture
|
|
211
229
|
- [Notepad skill](skills/weave-notepad/SKILL.md) — capture, narration, and provenance behavior
|
|
230
|
+
- [Link repair reference](skills/weave-notepad/references/link-repair.md) — how stale links resolve, and what repair will not do
|
|
212
231
|
- [Historical browser workspace notes](docs/weave-workspace.md) — superseded implementation record
|
|
213
232
|
- [Repository exploration skill](skills/weave-explore/SKILL.md) — how Pi uses the index
|
|
214
233
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: weave-notepad
|
|
3
|
-
description: "Take and retrieve durable notes in the pi-weave vault. Use when the user asks to remember something or to start/add to a note (aliases: notes, ai note, note-taking, note-taker), or when answering questions about past decisions, people, or projects. Also handles interview note-taking where raw dictations are appended AND expanded."
|
|
3
|
+
description: "Take and retrieve durable notes in the pi-weave vault. Use when the user asks to remember something or to start/add to a note (aliases: notes, ai note, note-taking, note-taker), or when answering questions about past decisions, people, or projects. Also handles interview note-taking where raw dictations are appended AND expanded, and deterministic repair of stale [[wiki-links]] between notes."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Weave Notepad
|
|
@@ -14,7 +14,7 @@ In pi, use the `weave_note` tool. In other harnesses (or when the tool is not av
|
|
|
14
14
|
|
|
15
15
|
- **Notes** live at `~/.okf/notes/<slug>.md` (vault root overridable via `PI_WEAVE_VAULT`).
|
|
16
16
|
- Each note has YAML front matter: `title`, `created`, `updated` (ISO-8601), `tags: [..]`, and `source: human | agent | generated`.
|
|
17
|
-
- `weave_note` actions: `list`, `get`, `add`, `append`, `finalize`, `search`. `finalize` restructures the body *above* the `## Raw` tail and
|
|
17
|
+
- `weave_note` actions: `list`, `get`, `add`, `append`, `finalize`, `search`, `links`, `suggest`. `finalize` restructures the body *above* the `## Raw` tail and
|
|
18
18
|
preserves the tail verbatim — a body with no tail yet is preserved **in full** as a newly created tail, so finalization never destroys
|
|
19
19
|
dictation.
|
|
20
20
|
- **Dictation appends**: use `append` with `raw: true` — the tool appends the text verbatim into the `## Raw` tail as a dated fenced block,
|
|
@@ -79,19 +79,26 @@ down". Never promote conversation into a note on your own initiative — capture
|
|
|
79
79
|
|
|
80
80
|
## How to write a good note
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
0. **Go straight to the tool.** A note is one `weave_note` call. Do not `list` the vault, inspect the repository, or run `git` — none of that
|
|
83
|
+
informs what to write, and on a large vault `list` alone floods the context. When the user gives you the content ("note that says X"),
|
|
84
|
+
`add` it and report the slug.
|
|
85
|
+
1. **Search first when adding to existing knowledge** (`weave_note` action=search, with the note's key terms): if a note on the subject
|
|
86
|
+
exists, `append` to it rather than creating a duplicate. Skip this when the user is clearly starting something new — one targeted search,
|
|
87
|
+
never a vault listing.
|
|
83
88
|
2. Title: short noun phrase ("Auth boundary decision", not "Notes").
|
|
84
|
-
3. **
|
|
89
|
+
3. **Ask only when the content is genuinely missing.** A vague request ("write a note weave") needs one short question, not exploration —
|
|
90
|
+
searching the vault or the repo will not reveal what the user meant.
|
|
91
|
+
4. **Scribble in, verbatim.** When the user is dictating, append their words to the note as rough, verbatim scribbles — no silent rewording.
|
|
85
92
|
Append with `raw: true` so they land under the `## Raw` tail at the end of the note (the tail is created automatically if missing).
|
|
86
|
-
|
|
93
|
+
5. **Compile continuously during dictation.** After *every* interactive append in dictation mode, immediately finalize the body *above* the
|
|
87
94
|
raw tail so the compiled doc stays current (see [Dictation mode](#dictation-mode-continuous-compile)). Outside dictation mode,
|
|
88
95
|
compilation stays on request.
|
|
89
|
-
|
|
96
|
+
6. **Finalize on request.** When the user says "finalize this" / "clean this up", restructure the body *above* the raw tail: front-loaded
|
|
90
97
|
summary, sections, entities, links. Use `weave_note` action=finalize (or edit the file directly in other harnesses). Move nothing out of
|
|
91
98
|
`## Raw` — it is append-only and never rewritten. A note with no `## Raw` tail yet gets its entire pre-finalize body preserved as a new
|
|
92
99
|
raw tail: finalization is editorial, never destructive.
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
7. Tags: 1–4 lowercase tags; reuse existing tags when possible.
|
|
101
|
+
8. Provenance: notes the user scribbled stay `source: human` (finalization is editorial, not authorship) — pass `source: "human"` to `add`
|
|
95
102
|
for user-scribbled notes. Notes you draft from scratch are `source: agent` (the default). Never overwrite a `source: human` note's
|
|
96
103
|
meaning; append with a dated "Agent addendum" section instead.
|
|
97
104
|
|
|
@@ -109,3 +116,30 @@ The scan is opt-in and never runs on its own. Suggest it when the user asks why
|
|
|
109
116
|
|
|
110
117
|
Use `weave_note` action=search with the user's key terms, then `get` the best hits. When a note and the repository index disagree, trust the
|
|
111
118
|
repository for facts about code and flag the discrepancy — the note may be stale intent.
|
|
119
|
+
|
|
120
|
+
## Repairing stale links
|
|
121
|
+
|
|
122
|
+
A link written as a bare title or basename — `[[Quarterly Roadmap]]` when the note is `planning/roadmap-2026` — resolves to nothing.
|
|
123
|
+
**Never reconnect a vault by reading every note and guessing which ones relate.** Run the deterministic pass instead:
|
|
124
|
+
|
|
125
|
+
```jsonc
|
|
126
|
+
weave_note { "action": "links" } // report: fixable / ambiguous / unresolvable
|
|
127
|
+
weave_note { "action": "links", "fix": true } // apply only the unambiguous repairs
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
It resolves by exact slug, then unique basename, then unique title — each requiring exactly one candidate. Ambiguous links are reported
|
|
131
|
+
with their candidates and never guessed; unresolvable ones point at notes that were never written. Aliases are preserved, the `## Raw`
|
|
132
|
+
tail and code fences are never touched, and `updated` is not bumped. Report first, apply after the user sees it.
|
|
133
|
+
|
|
134
|
+
To find connections that were **never written** — two notes that belong together but have never referenced each other — use `suggest`:
|
|
135
|
+
|
|
136
|
+
```jsonc
|
|
137
|
+
weave_note { "action": "suggest" } // strongest unlinked pairs
|
|
138
|
+
weave_note { "action": "suggest", "slug": "some/note" } // what relates to one note
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
It ranks pairs by how much *rare* vocabulary they share, and cites the shared terms as evidence. **`suggest` never writes** — there is no `fix`.
|
|
142
|
+
A similarity score is a soft signal and a `[[link]]` is a hard claim, so propose the worthwhile pairs to the user and add links only to those they
|
|
143
|
+
confirm.
|
|
144
|
+
|
|
145
|
+
See [references/link-repair.md](references/link-repair.md) for the full rules, guarantees, and when to run each.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Link repair — keeping the vault connected
|
|
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
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
weave_note { "action": "links" } // read-only report
|
|
15
|
+
weave_note { "action": "links", "fix": true } // apply the unambiguous repairs
|
|
16
|
+
```
|
|
17
|
+
|
|
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.**
|
|
38
|
+
|
|
39
|
+
## What a repair does and does not do
|
|
40
|
+
|
|
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.
|
|
47
|
+
|
|
48
|
+
## Renames repair themselves
|
|
49
|
+
|
|
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.
|
|
52
|
+
|
|
53
|
+
## Finding connections that were never made
|
|
54
|
+
|
|
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`:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
weave_note { "action": "suggest" } // strongest pairs vault-wide
|
|
60
|
+
weave_note { "action": "suggest", "slug": "some/note" } // what relates to this note
|
|
61
|
+
weave_note { "action": "suggest", "limit": 40 }
|
|
62
|
+
```
|
|
63
|
+
|
|
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.
|
|
89
|
+
|
|
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.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
30
30
|
import { gitSpawnCount } from "../git";
|
|
31
31
|
import { NOTES_DIR } from "../paths";
|
|
32
|
-
import { buildGraph,
|
|
32
|
+
import { buildGraph, type BuildGraphInput } from "../graph/build";
|
|
33
33
|
import type { GraphModel } from "../graph/model";
|
|
34
34
|
import { readRepositorySide } from "../graph/current";
|
|
35
35
|
import { withMutationQueue } from "../mutex";
|
|
@@ -41,16 +41,14 @@ import type { HtmlArtifact, Note } from "../types";
|
|
|
41
41
|
*
|
|
42
42
|
* The two travel together because a caller deriving anything per-note — the
|
|
43
43
|
* tag index (§4.3) is the motivating case — must use *the same* list the
|
|
44
|
-
* graph used
|
|
45
|
-
*
|
|
46
|
-
* pointing at a slug the graph has no node for.
|
|
44
|
+
* graph used. Reading the notes from a second call could race a vault change
|
|
45
|
+
* and produce a tag pointing at a slug the graph has no node for.
|
|
47
46
|
*/
|
|
48
47
|
export interface WorkspaceSnapshot {
|
|
49
48
|
model: GraphModel;
|
|
50
49
|
/**
|
|
51
|
-
* Exactly the notes `buildGraph` saw
|
|
52
|
-
*
|
|
53
|
-
* caller mutating it would corrupt the next build.
|
|
50
|
+
* Exactly the notes `buildGraph` saw, newest-updated first. Frozen — this
|
|
51
|
+
* is the cache's own array and a caller mutating it would corrupt the next build.
|
|
54
52
|
*/
|
|
55
53
|
notes: readonly Note[];
|
|
56
54
|
}
|
|
@@ -383,10 +381,6 @@ export class WorkspaceCache {
|
|
|
383
381
|
return this.lastSnapshot;
|
|
384
382
|
}
|
|
385
383
|
|
|
386
|
-
// Truncated once, here, and then handed to *both* the builder and the
|
|
387
|
-
// snapshot — so a caller deriving per-note data cannot see a note the
|
|
388
|
-
// graph has no node for (§4.3).
|
|
389
|
-
const kept = notes.slice(0, DEFAULT_MAX_NOTES);
|
|
390
384
|
const input: BuildGraphInput = {
|
|
391
385
|
vault: {
|
|
392
386
|
root: this.vaultRoot,
|
|
@@ -395,7 +389,7 @@ export class WorkspaceCache {
|
|
|
395
389
|
...(this.folders.length > 0 ? { folders: this.folders } : {}),
|
|
396
390
|
...(this.artifactCount > 0 ? { artifactCount: this.artifactCount } : {}),
|
|
397
391
|
},
|
|
398
|
-
notes
|
|
392
|
+
notes,
|
|
399
393
|
artifacts: refreshed.artifacts,
|
|
400
394
|
repository: repo?.repository ?? null,
|
|
401
395
|
};
|
|
@@ -405,7 +399,7 @@ export class WorkspaceCache {
|
|
|
405
399
|
this.builtAt = this.now().toISOString();
|
|
406
400
|
const snapshot: WorkspaceSnapshot = {
|
|
407
401
|
model: buildGraph(input),
|
|
408
|
-
notes: Object.freeze(
|
|
402
|
+
notes: Object.freeze(notes),
|
|
409
403
|
};
|
|
410
404
|
this.lastSnapshot = snapshot;
|
|
411
405
|
return snapshot;
|
package/src/core/graph/build.ts
CHANGED
|
@@ -15,9 +15,6 @@ import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
|
|
|
15
15
|
import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
|
|
16
16
|
import { extractWikilinks } from "./wikilinks";
|
|
17
17
|
|
|
18
|
-
/** Hard cap on note nodes so a large vault cannot overwhelm the viewer. */
|
|
19
|
-
export const DEFAULT_MAX_NOTES = 500;
|
|
20
|
-
|
|
21
18
|
export interface BuildGraphInput {
|
|
22
19
|
vault: VaultStatus;
|
|
23
20
|
/** Full notes including bodies (for wiki-link extraction). */
|
|
@@ -118,15 +115,11 @@ function parseRemote(raw: string): { label: string; url: string } {
|
|
|
118
115
|
|
|
119
116
|
function buildVaultSide(
|
|
120
117
|
input: BuildGraphInput,
|
|
121
|
-
maxNotes: number,
|
|
122
118
|
nodes: GraphNode[],
|
|
123
119
|
edges: GraphEdge[],
|
|
124
120
|
danglingLinks: Record<string, string[]>,
|
|
125
121
|
paths: PathIndex,
|
|
126
|
-
):
|
|
127
|
-
const truncated = input.notes.length > maxNotes;
|
|
128
|
-
const kept = input.notes.slice(0, maxNotes);
|
|
129
|
-
|
|
122
|
+
): void {
|
|
130
123
|
const vaultDetail: Record<string, string> = {
|
|
131
124
|
root: input.vault.root,
|
|
132
125
|
notes: String(input.vault.noteCount),
|
|
@@ -135,12 +128,9 @@ function buildVaultSide(
|
|
|
135
128
|
if ((input.vault.artifactCount ?? artifacts.length) > 0) {
|
|
136
129
|
vaultDetail.artifacts = String(input.vault.artifactCount ?? artifacts.length);
|
|
137
130
|
}
|
|
138
|
-
if (truncated) {
|
|
139
|
-
vaultDetail.warning = `Graph shows the ${maxNotes} most recent notes — the vault holds ${input.vault.noteCount}. Wiki-links to older notes are omitted.`;
|
|
140
|
-
}
|
|
141
131
|
nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
|
|
142
132
|
|
|
143
|
-
const
|
|
133
|
+
const allSlugs = new Set(input.notes.map((n) => n.slug));
|
|
144
134
|
const artifactSlugs = new Set(artifacts.map((a) => a.slug));
|
|
145
135
|
const artifactLinks = new Map<string, number>();
|
|
146
136
|
|
|
@@ -153,23 +143,17 @@ function buildVaultSide(
|
|
|
153
143
|
const noteDirs = [
|
|
154
144
|
...new Set([
|
|
155
145
|
...(input.vault.folders ?? []),
|
|
156
|
-
...
|
|
146
|
+
...input.notes.map((n) => n.slug.split("/").slice(0, -1).join("/")),
|
|
157
147
|
...artifacts.map((a) => a.slug.split("/").slice(0, -1).join("/")),
|
|
158
148
|
]),
|
|
159
149
|
]
|
|
160
150
|
.filter((d) => d.length > 0)
|
|
161
151
|
.sort();
|
|
162
|
-
const notesIn = (dir: string): number =>
|
|
163
|
-
const totalNotesIn = (dir: string): number => input.notes.filter((n) => n.slug.startsWith(`${dir}/`)).length;
|
|
152
|
+
const notesIn = (dir: string): number => input.notes.filter((n) => n.slug.startsWith(`${dir}/`)).length;
|
|
164
153
|
for (const dir of noteDirs) {
|
|
165
154
|
const id = `vfolder:${dir}`;
|
|
166
155
|
folderIds.set(dir, id);
|
|
167
|
-
const
|
|
168
|
-
const totalCount = totalNotesIn(dir);
|
|
169
|
-
const folderDetail: Record<string, string> = { path: dir, notes: String(keptCount) };
|
|
170
|
-
if (totalCount > keptCount) {
|
|
171
|
-
folderDetail.warning = `${totalCount - keptCount} older note(s) in this folder omitted by note limit`;
|
|
172
|
-
}
|
|
156
|
+
const folderDetail: Record<string, string> = { path: dir, notes: String(notesIn(dir)) };
|
|
173
157
|
nodes.push({
|
|
174
158
|
id,
|
|
175
159
|
kind: "module",
|
|
@@ -186,9 +170,9 @@ function buildVaultSide(
|
|
|
186
170
|
return (dir.length > 0 && folderIds.get(dir)) || "vault";
|
|
187
171
|
};
|
|
188
172
|
|
|
189
|
-
for (const note of
|
|
173
|
+
for (const note of input.notes) {
|
|
190
174
|
const links = extractWikilinks(note.body);
|
|
191
|
-
const resolved = links.filter((slug) =>
|
|
175
|
+
const resolved = links.filter((slug) => allSlugs.has(slug));
|
|
192
176
|
const detail: Record<string, string> = {
|
|
193
177
|
slug: note.slug,
|
|
194
178
|
source: note.source,
|
|
@@ -198,7 +182,7 @@ function buildVaultSide(
|
|
|
198
182
|
// The names, not just the count (§4.2). `detail` keeps carrying the count
|
|
199
183
|
// because it is what the TUI's side panel prints; the structured targets
|
|
200
184
|
// go on the model, where a UI can turn them into ghost nodes.
|
|
201
|
-
const dangling = links.filter((slug) => !
|
|
185
|
+
const dangling = links.filter((slug) => !allSlugs.has(slug) && !artifactSlugs.has(slug));
|
|
202
186
|
if (dangling.length > 0) {
|
|
203
187
|
detail["dangling links"] = String(dangling.length);
|
|
204
188
|
danglingLinks[note.slug] = dangling;
|
|
@@ -238,7 +222,6 @@ function buildVaultSide(
|
|
|
238
222
|
nodes.push({ id: `artifact:${artifact.slug}`, kind: "file", label: artifact.title, provenance: null, detail });
|
|
239
223
|
edges.push({ source: parent, target: `artifact:${artifact.slug}`, kind: "contains" });
|
|
240
224
|
}
|
|
241
|
-
return [...keptSlugs];
|
|
242
225
|
}
|
|
243
226
|
|
|
244
227
|
function buildRepositorySide(
|
|
@@ -355,12 +338,8 @@ function buildRepositorySide(
|
|
|
355
338
|
}
|
|
356
339
|
}
|
|
357
340
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
360
|
-
* (docs/design.md §11); the vault node carries a warning when truncated.
|
|
361
|
-
*/
|
|
362
|
-
export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number } = {}): GraphModel {
|
|
363
|
-
const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
|
|
341
|
+
/** Build the complete graph model for the viewer. */
|
|
342
|
+
export function buildGraph(input: BuildGraphInput): GraphModel {
|
|
364
343
|
const nodes: GraphNode[] = [];
|
|
365
344
|
const edges: GraphEdge[] = [];
|
|
366
345
|
const danglingLinks: Record<string, string[]> = {};
|
|
@@ -372,7 +351,7 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
372
351
|
const paths = input.repository === null
|
|
373
352
|
? (new Map<string, string>() as PathIndex)
|
|
374
353
|
: buildPathIndex(input.repository.index.structure);
|
|
375
|
-
buildVaultSide(input,
|
|
354
|
+
buildVaultSide(input, nodes, edges, danglingLinks, paths);
|
|
376
355
|
if (input.repository !== null) {
|
|
377
356
|
buildRepositorySide(input.repository, input.summaries, nodes, edges);
|
|
378
357
|
}
|
|
@@ -382,9 +361,8 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
382
361
|
nodes,
|
|
383
362
|
edges,
|
|
384
363
|
danglingLinks,
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
contentDigest: noteContentDigest(input.notes.slice(0, maxNotes)),
|
|
364
|
+
// Slug-ordered inside, hence order-stable.
|
|
365
|
+
contentDigest: noteContentDigest(input.notes),
|
|
388
366
|
};
|
|
389
367
|
}
|
|
390
368
|
|
|
@@ -19,7 +19,7 @@ import { assessStaleness, readRepoIndex } from "../repoIndex";
|
|
|
19
19
|
import { readSummaryMap } from "../summaries";
|
|
20
20
|
import type { Note } from "../types";
|
|
21
21
|
import { getNote, readVault, resolveNotePath } from "../vault";
|
|
22
|
-
import { buildGraph,
|
|
22
|
+
import { buildGraph, type BuildGraphInput } from "./build";
|
|
23
23
|
import type { GraphModel } from "./model";
|
|
24
24
|
|
|
25
25
|
/** A note read for the viewers (read-only; never cached). Mirrors the vault `Note` shape. */
|
|
@@ -91,9 +91,8 @@ export async function readRepositorySide(
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Assemble the fresh graph from disk. Called on every viewer fetch
|
|
94
|
-
* (no caching). Reads the vault
|
|
95
|
-
*
|
|
96
|
-
* index + deep-scan summary sidecars. Degrades to a vault-only graph when
|
|
94
|
+
* (no caching). Reads the complete vault and, when cwd is an indexed git
|
|
95
|
+
* repository, the repo index + deep-scan summary sidecars. Degrades to a vault-only graph when
|
|
97
96
|
* the repo has no index or the index is corrupt.
|
|
98
97
|
*
|
|
99
98
|
* One read per note: `readVault` returns bodies *and* the file count, so the
|
|
@@ -111,7 +110,7 @@ export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolve
|
|
|
111
110
|
...(folders ? { folders } : {}),
|
|
112
111
|
...(artifactCount ? { artifactCount } : {}),
|
|
113
112
|
},
|
|
114
|
-
notes
|
|
113
|
+
notes,
|
|
115
114
|
...(artifacts ? { artifacts } : {}),
|
|
116
115
|
repository: null,
|
|
117
116
|
};
|
package/src/core/index.ts
CHANGED
|
@@ -53,12 +53,30 @@ export {
|
|
|
53
53
|
moveNote,
|
|
54
54
|
parseHtmlArtifact,
|
|
55
55
|
renameFolder,
|
|
56
|
+
readVault,
|
|
56
57
|
renameNote,
|
|
58
|
+
repairVaultLinks,
|
|
57
59
|
resolveHtmlPath,
|
|
58
60
|
resolveNotePath,
|
|
59
61
|
searchNotes,
|
|
60
62
|
upsertNote,
|
|
63
|
+
type LinkRepairResult,
|
|
61
64
|
} from "./vault";
|
|
65
|
+
export {
|
|
66
|
+
suggestLinks,
|
|
67
|
+
type LinkSuggestion,
|
|
68
|
+
type SuggestOptions,
|
|
69
|
+
type SuggestionReport,
|
|
70
|
+
} from "./links/similar";
|
|
71
|
+
export {
|
|
72
|
+
auditLinks,
|
|
73
|
+
rewriteLinks,
|
|
74
|
+
scanLinks,
|
|
75
|
+
type AmbiguousLink,
|
|
76
|
+
type LinkAudit,
|
|
77
|
+
type LinkFix,
|
|
78
|
+
type UnresolvableLink,
|
|
79
|
+
} from "./links/repair";
|
|
62
80
|
export type { HtmlArtifact } from "./types";
|
|
63
81
|
export { withMutationQueue } from "./mutex";
|
|
64
82
|
export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
|