pi-weave 0.1.22 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -20
- 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/graph/column.model.ts +14 -0
- package/src/web/client/graph/groups.ts +4 -4
package/README.md
CHANGED
|
@@ -11,24 +11,56 @@
|
|
|
11
11
|
<a href="./LICENSE"><img alt="license: MIT" src="https://img.shields.io/npm/l/pi-weave?color=green"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
**
|
|
14
|
+
**Your notes. Your agent's notes. One searchable knowledge workspace.**
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
I've been chasing the right note-taking workflow for years. Notion, OneNote, Obsidian, and plenty of others.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
With pi-weave, I've made one that finally answers the way I want to work today — especially now that AI agents are part of that work.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The idea is simple:
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
- **Verbatim narration with structured summaries.** During dictation, Pi preserves each spoken passage in an append-only `## Raw` section while maintaining an organized summary above it.
|
|
24
|
-
- **Knowledge retrieval.** Pi searches existing notes when answering questions about previous decisions, people, projects, or meetings.
|
|
25
|
-
- **Unified visual workspace.** `/weave-view` presents notes, links, repository structure, and provenance in a live browser interface.
|
|
26
|
-
- **Optional session memory.** `/weave-scan sessions` turns changed pi transcripts into searchable notes with reusable technical takeaways.
|
|
27
|
-
- **Repository exploration.** A lightweight, git-aware index gives Pi a structural overview of the current codebase before it reads files.
|
|
22
|
+
**You and your agent should share the same knowledge.**
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
Talk naturally and let your thoughts become structured notes. Write notes yourself. Let your agent capture decisions, discoveries, and
|
|
25
|
+
useful context. Bring important knowledge forward from previous sessions.
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
Then find it again — by searching the workspace yourself, or simply asking your agent.
|
|
28
|
+
|
|
29
|
+
Everything is local. Everything is readable. Your notes are ordinary files, not knowledge trapped inside another chat.
|
|
30
|
+
|
|
31
|
+
## Think out loud
|
|
32
|
+
|
|
33
|
+
Dictation is one of my favorite ways to use AI.
|
|
34
|
+
|
|
35
|
+
Instead of stopping to organize every thought while I'm having it, I can just talk:
|
|
36
|
+
|
|
37
|
+
> Start a note about pi-weave positioning. I'm going to think out loud.
|
|
38
|
+
|
|
39
|
+
pi-weave keeps what I actually said while Pi continuously turns it into a useful, structured note.
|
|
40
|
+
|
|
41
|
+
The raw thought stays yours. The organization is handled for you.
|
|
42
|
+
|
|
43
|
+
And it doesn't end with human notes.
|
|
44
|
+
|
|
45
|
+
## Your knowledge + your agent's knowledge
|
|
46
|
+
|
|
47
|
+
pi-weave gives humans and agents one workspace.
|
|
48
|
+
|
|
49
|
+
A note you dictate, something you write manually, a useful discovery made by an agent, or knowledge recovered from an earlier session can
|
|
50
|
+
all live together.
|
|
51
|
+
|
|
52
|
+
pi-weave keeps track of where knowledge came from, while making the whole workspace searchable by both you and your agent.
|
|
53
|
+
|
|
54
|
+
**The conversation can end. The knowledge doesn't have to.**
|
|
55
|
+
|
|
56
|
+
## Built on Pi
|
|
57
|
+
|
|
58
|
+
pi-weave is built as an extension for [Pi](https://github.com/earendil-works/pi).
|
|
59
|
+
|
|
60
|
+
I've grown to love Pi precisely because it is such a lightweight agent harness. It gives the model tools and context without trying to
|
|
61
|
+
become the product itself, and its extensibility makes projects like pi-weave possible.
|
|
62
|
+
|
|
63
|
+
Install pi-weave:
|
|
32
64
|
|
|
33
65
|
```bash
|
|
34
66
|
pi install npm:pi-weave
|
|
@@ -43,6 +75,8 @@ pi install /path/to/pi-weave
|
|
|
43
75
|
|
|
44
76
|
Requires Node **20.13 or newer**.
|
|
45
77
|
|
|
78
|
+
Then just talk to Pi.
|
|
79
|
+
|
|
46
80
|
## Start taking notes
|
|
47
81
|
|
|
48
82
|
Talk to Pi normally:
|
|
@@ -71,7 +105,8 @@ For each chunk, Pi:
|
|
|
71
105
|
2. refreshes the structured summary above it;
|
|
72
106
|
3. leaves the raw record untouched.
|
|
73
107
|
|
|
74
|
-
This makes the note readable during the conversation without replacing your words with an AI reconstruction. Notes based on your dictation
|
|
108
|
+
This makes the note readable during the conversation without replacing your words with an AI reconstruction. Notes based on your dictation
|
|
109
|
+
remain marked `source: human`; notes drafted by Pi are marked `source: agent`.
|
|
75
110
|
|
|
76
111
|
Useful requests include:
|
|
77
112
|
|
|
@@ -82,6 +117,24 @@ Useful requests include:
|
|
|
82
117
|
| “Clean up” or “finalize this note” | Reorganizes the readable body and preserves the raw tail |
|
|
83
118
|
| “What did we decide about…?” | Searches the vault, then reads the relevant notes |
|
|
84
119
|
| “Remember that…” | Stores durable knowledge for a future session |
|
|
120
|
+
| “Fix the broken links” | Audits and repairs stale `[[wiki-links]]` deterministically |
|
|
121
|
+
|
|
122
|
+
## Keeping the vault connected
|
|
123
|
+
|
|
124
|
+
A wiki-link resolves to nothing when it is written as a bare title — `[[Quarterly Roadmap]]` when the note lives at `planning/roadmap-2026`
|
|
125
|
+
— or when it points at a note that was never written. Pi repairs those in one deterministic pass instead of rereading the vault and
|
|
126
|
+
guessing.
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
You: Check the links in my notes.
|
|
130
|
+
You: Fix the ones you're sure about.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A target resolves by exact slug, then unique basename, then unique title — each rule requiring **exactly one** candidate. Ambiguous links
|
|
134
|
+
are reported with their candidates and never guessed; links to notes that were never written are listed, never invented. Repairs preserve
|
|
135
|
+
the alias, so the visible text does not change, and never touch the `## Raw` tail or code fences.
|
|
136
|
+
|
|
137
|
+
Renaming or moving a note rewrites its inbound links automatically, so its backlinks stay intact.
|
|
85
138
|
|
|
86
139
|
## `/weave-view`
|
|
87
140
|
|
|
@@ -94,11 +147,13 @@ Useful requests include:
|
|
|
94
147
|
The browser workspace has four connected views:
|
|
95
148
|
|
|
96
149
|
- **Tree** — notes, folders, and repository structure, with text and provenance filters.
|
|
97
|
-
- **Note** — rendered Markdown with clickable `[[wikilinks]]`, link previews, tags, authorship, and an action to open the source in
|
|
150
|
+
- **Note** — rendered Markdown with clickable `[[wikilinks]]`, link previews, tags, authorship, and an action to open the source in
|
|
151
|
+
`$EDITOR`.
|
|
98
152
|
- **Graph** — a navigable map of notes, links, mentions, modules, and repository relationships. Selecting something updates every view.
|
|
99
153
|
- **Context** — links, backlinks, tags, and code mentions for the current selection.
|
|
100
154
|
|
|
101
|
-
Search with `⌘K` / `Ctrl K`. Press `?` for all shortcuts. The workspace updates as notes change on disk, so a note written by Pi appears
|
|
155
|
+
Search with `⌘K` / `Ctrl K`. Press `?` for all shortcuts. The workspace updates as notes change on disk, so a note written by Pi appears
|
|
156
|
+
without a reload. It follows the system theme by default and can be switched between light and dark.
|
|
102
157
|
|
|
103
158
|
The browser is read-only. Edit with `$EDITOR`, Obsidian, or the `weave_note` tool; unknown front-matter fields remain preserved.
|
|
104
159
|
|
|
@@ -111,7 +166,10 @@ The browser is read-only. Edit with `$EDITOR`, Obsidian, or the `weave_note` too
|
|
|
111
166
|
/weave-scan sessions /path/to/history # explicit history root
|
|
112
167
|
```
|
|
113
168
|
|
|
114
|
-
This opt-in scan treats a supplied file—or every bounded text file under a supplied directory—as opaque session material for the active
|
|
169
|
+
This opt-in scan treats a supplied file—or every bounded text file under a supplied directory—as opaque session material for the active
|
|
170
|
+
model to interpret, then writes generated notes under `~/.okf/notes/sessions/`. That makes it usable with Claude Code, opencode, Codex, or
|
|
171
|
+
exported history trees without requiring their schema or file extension. It skips unchanged files, captures outcomes plus reusable technical
|
|
172
|
+
takeaways, works outside Git repositories, and can be stopped with `/weave-scan-cancel`.
|
|
115
173
|
|
|
116
174
|
## Repository knowledge
|
|
117
175
|
|
|
@@ -122,9 +180,11 @@ Inside a Git repository, pi-weave detects whether `<repo>/.okf/` is missing, fre
|
|
|
122
180
|
/weave-scan deep # also summarize changed files with the active model
|
|
123
181
|
```
|
|
124
182
|
|
|
125
|
-
The light index covers languages, packages, modules, entry points, and Git state. A deep scan adds short per-file summaries and only
|
|
183
|
+
The light index covers languages, packages, modules, entry points, and Git state. A deep scan adds short per-file summaries and only
|
|
184
|
+
revisits files whose content changed.
|
|
126
185
|
|
|
127
|
-
The repository index is a cache, not a source of truth. Delete `.okf`, scan again, and nothing important is lost. pi-weave excludes it
|
|
186
|
+
The repository index is a cache, not a source of truth. Delete `.okf`, scan again, and nothing important is lost. pi-weave excludes it
|
|
187
|
+
locally from Git by default.
|
|
128
188
|
|
|
129
189
|
## Commands and tools
|
|
130
190
|
|
|
@@ -181,7 +241,9 @@ We probably want OIDC next quarter…
|
|
|
181
241
|
|
|
182
242
|
Set `PI_WEAVE_VAULT` to use a different vault location.
|
|
183
243
|
|
|
184
|
-
Reading, writing, searching, and viewing notes are local operations. Deep repository scans and session summaries send bounded input to
|
|
244
|
+
Reading, writing, searching, and viewing notes are local operations. Deep repository scans and session summaries send bounded input to
|
|
245
|
+
whichever model you configured in pi. The browser workspace binds only to loopback, uses a per-session token, and shuts down with the pi
|
|
246
|
+
session.
|
|
185
247
|
|
|
186
248
|
The vault format, repository index, and skills are intentionally harness-agnostic. `src/core` contains no pi-specific imports.
|
|
187
249
|
|
|
@@ -201,7 +263,8 @@ npm run coverage
|
|
|
201
263
|
npm run build:web
|
|
202
264
|
```
|
|
203
265
|
|
|
204
|
-
Coverage must remain at or above **95%** for lines, branches, functions, and statements. If browser source changes, rebuild and commit
|
|
266
|
+
Coverage must remain at or above **95%** for lines, branches, functions, and statements. If browser source changes, rebuild and commit
|
|
267
|
+
`src/web/client/dist/app.js`.
|
|
205
268
|
|
|
206
269
|
Read [AGENTS.md](AGENTS.md) before contributing. Work on a feature branch; do not commit directly to `main`.
|
|
207
270
|
|
|
@@ -209,6 +272,7 @@ Read [AGENTS.md](AGENTS.md) before contributing. Work on a feature branch; do no
|
|
|
209
272
|
|
|
210
273
|
- [Design](docs/design.md) — product and architecture
|
|
211
274
|
- [Notepad skill](skills/weave-notepad/SKILL.md) — capture, narration, and provenance behavior
|
|
275
|
+
- [Link repair reference](skills/weave-notepad/references/link-repair.md) — how stale links resolve, and what repair will not do
|
|
212
276
|
- [Historical browser workspace notes](docs/weave-workspace.md) — superseded implementation record
|
|
213
277
|
- [Repository exploration skill](skills/weave-explore/SKILL.md) — how Pi uses the index
|
|
214
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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";
|