pi-weave 0.1.12 → 0.1.14
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 +8 -37
- package/package.json +1 -2
- package/src/core/cache/workspace.ts +70 -16
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +39 -9
- package/src/core/graph/current.ts +11 -6
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +8 -4
- package/src/core/index.ts +30 -27
- package/src/core/paths.ts +0 -7
- package/src/core/types.ts +13 -0
- package/src/core/vault.ts +104 -679
- package/src/core/view/detail.ts +5 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +86 -211
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/note/Note.tsx +45 -63
- package/src/web/client/note/note.model.ts +20 -0
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +17 -70
- package/src/web/client/shell/Divider.tsx +5 -31
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +60 -112
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/cssvars.ts +2 -64
- package/src/web/client/shell/drag.model.ts +10 -137
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/layout.model.ts +37 -399
- package/src/web/client/shell/shell.model.ts +24 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +31 -154
- package/src/web/client/shell/viewport.ts +0 -18
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +9 -10
- package/src/web/server/routes.ts +64 -566
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/README.md
CHANGED
|
@@ -27,7 +27,6 @@ It also understands the repository you are working in. Personal notes live in a
|
|
|
27
27
|
while maintaining an organized summary above it.
|
|
28
28
|
- **Knowledge retrieval.** Pi searches existing notes when answering questions about previous decisions, people, projects, or meetings.
|
|
29
29
|
- **Unified visual workspace.** `/weave-view` presents notes, links, repository structure, and provenance in a live browser interface.
|
|
30
|
-
- **Optional session memory.** `/weave-scan sessions` converts prior pi sessions into searchable notes and skips unchanged transcripts.
|
|
31
30
|
- **Repository exploration.** A lightweight, git-aware index gives Pi a structural overview of the current codebase before it reads files.
|
|
32
31
|
|
|
33
32
|
Nothing is captured silently. pi-weave creates or extends a personal note only when you ask it to.
|
|
@@ -98,42 +97,18 @@ Useful requests include:
|
|
|
98
97
|
|
|
99
98
|
The browser workspace has four connected views:
|
|
100
99
|
|
|
101
|
-
- **Tree** — notes, folders,
|
|
102
|
-
|
|
103
|
-
- **Note** — rendered Markdown with clickable `[[wikilinks]]`, link previews, tags, and authorship. Click the body or press `⌘E` / `Ctrl E`
|
|
104
|
-
to edit; save with `⌘S` / `Ctrl S`.
|
|
100
|
+
- **Tree** — notes, folders, and repository structure, with text and provenance filters.
|
|
101
|
+
- **Note** — rendered Markdown with clickable `[[wikilinks]]`, link previews, tags, authorship, and an action to open the source in `$EDITOR`.
|
|
105
102
|
- **Graph** — a navigable map of notes, links, mentions, modules, and repository relationships. Selecting something updates every view.
|
|
106
103
|
- **Context** — links, backlinks, tags, and code mentions for the current selection.
|
|
107
104
|
|
|
108
105
|
Search with `⌘K` / `Ctrl K`. Press `?` for all shortcuts. The workspace updates as notes change on disk, so a note written by Pi appears
|
|
109
106
|
without a reload. It follows the system theme by default and can be switched between light and dark.
|
|
110
107
|
|
|
111
|
-
The
|
|
112
|
-
work. Unknown front-matter fields are preserved, so the same vault remains safe to edit with Obsidian or a text editor.
|
|
108
|
+
The browser is read-only. Edit with `$EDITOR`, Obsidian, or the `weave_note` tool; unknown front-matter fields remain preserved.
|
|
113
109
|
|
|
114
110
|
`/weave-view tui` is the smaller, read-only terminal explorer: tree, focused neighborhood, details, and link health over the same graph.
|
|
115
111
|
|
|
116
|
-
## Remember past pi sessions
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
/weave-scan sessions
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
This reads pi’s local session transcripts and creates generated notes under:
|
|
123
|
-
|
|
124
|
-
```text
|
|
125
|
-
~/.okf/notes/sessions/
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
Each note records what was asked, what happened, and any open threads. The scan is:
|
|
129
|
-
|
|
130
|
-
- **opt-in** — it never runs automatically;
|
|
131
|
-
- **incremental** — unchanged transcripts use no model calls;
|
|
132
|
-
- **cancellable** — run `/weave-scan-cancel`;
|
|
133
|
-
- **repo-independent** — it works from any directory.
|
|
134
|
-
|
|
135
|
-
Session summaries use the active pi model and are marked `source: generated`.
|
|
136
|
-
|
|
137
112
|
## Repository knowledge
|
|
138
113
|
|
|
139
114
|
Inside a Git repository, pi-weave detects whether `<repo>/.okf/` is missing, fresh, or stale.
|
|
@@ -159,8 +134,7 @@ Most people only need natural language and `/weave-view`.
|
|
|
159
134
|
| Command | `/weave` | Show vault and repository status |
|
|
160
135
|
| Command | `/weave-scan` | Build or refresh the repository index |
|
|
161
136
|
| Command | `/weave-scan deep` | Add incremental model-written file summaries |
|
|
162
|
-
| Command | `/weave-scan
|
|
163
|
-
| Command | `/weave-scan-cancel` | Stop a deep or session scan |
|
|
137
|
+
| Command | `/weave-scan-cancel` | Stop a deep scan |
|
|
164
138
|
| Tool | `weave_note` | List, read, add, append, finalize, and search notes |
|
|
165
139
|
| Tool | `weave_repo` | Check, scan, and summarize the repository index |
|
|
166
140
|
|
|
@@ -174,8 +148,7 @@ Personal notes are ordinary Markdown files:
|
|
|
174
148
|
~/.okf/
|
|
175
149
|
└── notes/
|
|
176
150
|
├── authentication-migration.md
|
|
177
|
-
└──
|
|
178
|
-
└── plan-the-release.md
|
|
151
|
+
└── release-plan.md
|
|
179
152
|
```
|
|
180
153
|
|
|
181
154
|
A note has small YAML front matter followed by Markdown:
|
|
@@ -205,9 +178,8 @@ We probably want OIDC next quarter…
|
|
|
205
178
|
|
|
206
179
|
Set `PI_WEAVE_VAULT` to use a different vault location.
|
|
207
180
|
|
|
208
|
-
Reading, writing, searching, and viewing notes are local operations. Deep repository scans
|
|
209
|
-
|
|
210
|
-
session.
|
|
181
|
+
Reading, writing, searching, and viewing notes are local operations. Deep repository scans send bounded input to whichever model you
|
|
182
|
+
configured in pi. The browser workspace binds only to loopback, uses a per-session token, and shuts down with the pi session.
|
|
211
183
|
|
|
212
184
|
The vault format, repository index, and skills are intentionally harness-agnostic. `src/core` contains no pi-specific imports.
|
|
213
185
|
|
|
@@ -236,8 +208,7 @@ Read [AGENTS.md](AGENTS.md) before contributing. Work on a feature branch; do no
|
|
|
236
208
|
|
|
237
209
|
- [Design](docs/design.md) — product and architecture
|
|
238
210
|
- [Notepad skill](skills/weave-notepad/SKILL.md) — capture, narration, and provenance behavior
|
|
239
|
-
- [
|
|
240
|
-
- [Session scanning](docs/session-scan.md) — incremental session memory
|
|
211
|
+
- [Historical browser workspace notes](docs/weave-workspace.md) — superseded implementation record
|
|
241
212
|
- [Repository exploration skill](skills/weave-explore/SKILL.md) — how Pi uses the index
|
|
242
213
|
|
|
243
214
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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,
|
|
@@ -64,7 +64,6 @@
|
|
|
64
64
|
"@earendil-works/pi-ai": "^0.84.2",
|
|
65
65
|
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
66
66
|
"@earendil-works/pi-tui": "^0.84.2",
|
|
67
|
-
"@preact/signals": "^2.11.1",
|
|
68
67
|
"@types/d3-force": "^3.0.10",
|
|
69
68
|
"@types/node": "^24.0.0",
|
|
70
69
|
"@vitest/coverage-v8": "^3.2.4",
|
|
@@ -33,8 +33,8 @@ import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "../graph/bu
|
|
|
33
33
|
import type { GraphModel } from "../graph/model";
|
|
34
34
|
import { readRepositorySide } from "../graph/current";
|
|
35
35
|
import { withMutationQueue } from "../mutex";
|
|
36
|
-
import { getNote, listNoteFolders, statNotes } from "../vault";
|
|
37
|
-
import type { Note } from "../types";
|
|
36
|
+
import { getHtmlArtifact, getNote, listNoteFolders, statNotes } from "../vault";
|
|
37
|
+
import type { HtmlArtifact, Note } from "../types";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* One build's outputs: the graph, and the notes it was built from.
|
|
@@ -53,6 +53,8 @@ export interface WorkspaceSnapshot {
|
|
|
53
53
|
* caller mutating it would corrupt the next build.
|
|
54
54
|
*/
|
|
55
55
|
notes: readonly Note[];
|
|
56
|
+
/** Exactly the HTML artifacts represented by the graph. */
|
|
57
|
+
artifacts: readonly HtmlArtifact[];
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** Cumulative counters, from construction. Callers take deltas. */
|
|
@@ -74,6 +76,12 @@ interface CachedNote {
|
|
|
74
76
|
note: Note;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
interface CachedArtifact {
|
|
80
|
+
mtimeMs: number;
|
|
81
|
+
size: number;
|
|
82
|
+
artifact: HtmlArtifact;
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
/** The repository half, held behind a TTL because assessing it spawns git. */
|
|
78
86
|
interface CachedRepo {
|
|
79
87
|
/** Wall-clock ms (from the injected clock) when this was captured. */
|
|
@@ -107,8 +115,8 @@ export type InvalidationScope = "vault" | "repo" | "none";
|
|
|
107
115
|
* whether an event is worth forwarding at all, and one implementation means
|
|
108
116
|
* the two can never disagree.
|
|
109
117
|
*
|
|
110
|
-
* - `vault`: a `*.md` under `<vaultRoot>/notes/`. Only
|
|
111
|
-
* the vault manifest does not participate in
|
|
118
|
+
* - `vault`: a `*.md`, `*.html`, or `*.htm` under `<vaultRoot>/notes/`. Only
|
|
119
|
+
* vault artifacts count — the vault manifest does not participate in graph.
|
|
112
120
|
* - `repo`: anything under `<cwd>/.okf/` (the derived index and its summary
|
|
113
121
|
* sidecars) or under `<cwd>/.git/` (HEAD moves, staged changes), plus any
|
|
114
122
|
* tracked file in the repo, since editing one makes the index stale.
|
|
@@ -141,6 +149,9 @@ function classify(
|
|
|
141
149
|
const slug = rel.slice(0, -".md".length).split(sep).join("/");
|
|
142
150
|
return { scope: "vault", slug };
|
|
143
151
|
}
|
|
152
|
+
if (rel.toLowerCase().endsWith(".html") || rel.toLowerCase().endsWith(".htm")) {
|
|
153
|
+
return { scope: "vault", slug: rel.split(sep).join("/") };
|
|
154
|
+
}
|
|
144
155
|
const base = rel.split(sep).pop() ?? rel;
|
|
145
156
|
if (base.startsWith(".") || (base.includes(".") && !base.endsWith(".md"))) {
|
|
146
157
|
return { scope: "none", slug: null };
|
|
@@ -178,12 +189,14 @@ export class WorkspaceCache {
|
|
|
178
189
|
private readonly stalenessTtlMs: number;
|
|
179
190
|
|
|
180
191
|
private notes = new Map<string, CachedNote>();
|
|
192
|
+
private artifacts = new Map<string, CachedArtifact>();
|
|
181
193
|
/**
|
|
182
194
|
* `.md` files present at the last refresh, including ones too malformed to
|
|
183
195
|
* parse — mirrors `readVault().fileCount` so the vault node's note count
|
|
184
|
-
* matches the uncached build exactly.
|
|
196
|
+
* matches the uncached build exactly. HTML artifacts have a separate count.
|
|
185
197
|
*/
|
|
186
198
|
private fileCount = 0;
|
|
199
|
+
private artifactCount = 0;
|
|
187
200
|
private folders: string[] = [];
|
|
188
201
|
private repo: CachedRepo | null = null;
|
|
189
202
|
/**
|
|
@@ -229,8 +242,8 @@ export class WorkspaceCache {
|
|
|
229
242
|
private repoDirtiedDuringBuild = false;
|
|
230
243
|
private building = false;
|
|
231
244
|
/**
|
|
232
|
-
* Whether the last {@link refreshNotes} observed any
|
|
233
|
-
* a file read, a
|
|
245
|
+
* Whether the last {@link refreshNotes} observed any vault-side movement:
|
|
246
|
+
* a file read, a file that disappeared, or a change in raw file counts.
|
|
234
247
|
* Read by {@link build} to decide whether {@link lastSnapshot} is reusable.
|
|
235
248
|
*/
|
|
236
249
|
private notesChanged = true;
|
|
@@ -299,7 +312,8 @@ export class WorkspaceCache {
|
|
|
299
312
|
invalidate(absPath: string): void {
|
|
300
313
|
const { scope, slug } = classify(absPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
|
|
301
314
|
if (scope === "vault" && slug !== null) {
|
|
302
|
-
this.
|
|
315
|
+
if (slug.toLowerCase().endsWith(".html") || slug.toLowerCase().endsWith(".htm")) this.artifacts.delete(slug);
|
|
316
|
+
else this.notes.delete(slug);
|
|
303
317
|
if (this.building) this.evictedDuringBuild.add(slug);
|
|
304
318
|
} else if (scope === "repo") {
|
|
305
319
|
this.repo = null;
|
|
@@ -310,6 +324,7 @@ export class WorkspaceCache {
|
|
|
310
324
|
/** Drop everything: a repo scan landed, or the vault root moved. */
|
|
311
325
|
invalidateAll(): void {
|
|
312
326
|
this.notes.clear();
|
|
327
|
+
this.artifacts.clear();
|
|
313
328
|
this.repo = null;
|
|
314
329
|
if (this.building) {
|
|
315
330
|
// Whatever the in-flight build writes back was read before this call,
|
|
@@ -336,7 +351,8 @@ export class WorkspaceCache {
|
|
|
336
351
|
this.allEvictedDuringBuild = false;
|
|
337
352
|
this.repoDirtiedDuringBuild = false;
|
|
338
353
|
try {
|
|
339
|
-
const
|
|
354
|
+
const refreshed = await this.refreshNotes();
|
|
355
|
+
const notes = refreshed.notes;
|
|
340
356
|
const repoFresh = this.repoNeedsRefresh();
|
|
341
357
|
const repo = await this.refreshRepo();
|
|
342
358
|
|
|
@@ -379,15 +395,21 @@ export class WorkspaceCache {
|
|
|
379
395
|
exists: true,
|
|
380
396
|
noteCount: this.fileCount,
|
|
381
397
|
...(this.folders.length > 0 ? { folders: this.folders } : {}),
|
|
398
|
+
...(this.artifactCount > 0 ? { artifactCount: this.artifactCount } : {}),
|
|
382
399
|
},
|
|
383
400
|
notes: kept,
|
|
401
|
+
artifacts: refreshed.artifacts,
|
|
384
402
|
repository: repo?.repository ?? null,
|
|
385
403
|
};
|
|
386
404
|
if (repo?.summaries !== undefined) input.summaries = repo.summaries;
|
|
387
405
|
|
|
388
406
|
this.gitCalls += gitSpawnCount() - spawnsBefore;
|
|
389
407
|
this.builtAt = this.now().toISOString();
|
|
390
|
-
const snapshot: WorkspaceSnapshot = {
|
|
408
|
+
const snapshot: WorkspaceSnapshot = {
|
|
409
|
+
model: buildGraph(input),
|
|
410
|
+
notes: Object.freeze(kept),
|
|
411
|
+
artifacts: Object.freeze(input.artifacts ?? []),
|
|
412
|
+
};
|
|
391
413
|
this.lastSnapshot = snapshot;
|
|
392
414
|
return snapshot;
|
|
393
415
|
} finally {
|
|
@@ -416,8 +438,15 @@ export class WorkspaceCache {
|
|
|
416
438
|
* something else touches it.
|
|
417
439
|
*/
|
|
418
440
|
private applyDeferredInvalidations(): void {
|
|
419
|
-
if (this.allEvictedDuringBuild)
|
|
420
|
-
|
|
441
|
+
if (this.allEvictedDuringBuild) {
|
|
442
|
+
this.notes.clear();
|
|
443
|
+
this.artifacts.clear();
|
|
444
|
+
} else {
|
|
445
|
+
for (const slug of this.evictedDuringBuild) {
|
|
446
|
+
if (/\.html?$/i.test(slug)) this.artifacts.delete(slug);
|
|
447
|
+
else this.notes.delete(slug);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
421
450
|
if (this.repoDirtiedDuringBuild) this.repo = null;
|
|
422
451
|
this.evictedDuringBuild.clear();
|
|
423
452
|
this.allEvictedDuringBuild = false;
|
|
@@ -428,18 +457,40 @@ export class WorkspaceCache {
|
|
|
428
457
|
* Stat every note; re-read only the ones whose mtime or size moved. Notes
|
|
429
458
|
* that disappeared are evicted, so the map never outgrows the vault.
|
|
430
459
|
*/
|
|
431
|
-
private async refreshNotes(): Promise<Note[]> {
|
|
460
|
+
private async refreshNotes(): Promise<{ notes: Note[]; artifacts: HtmlArtifact[] }> {
|
|
432
461
|
const previousFolders = this.folders;
|
|
433
462
|
const [stats, folders] = await Promise.all([statNotes(this.vaultRoot), listNoteFolders(this.vaultRoot)]);
|
|
434
463
|
this.folders = folders;
|
|
435
464
|
const previousCount = this.notes.size;
|
|
465
|
+
const previousArtifactCount = this.artifacts.size;
|
|
436
466
|
const previousFileCount = this.fileCount;
|
|
437
|
-
|
|
467
|
+
const previousRawArtifactCount = this.artifactCount;
|
|
468
|
+
this.fileCount = stats.filter((st) => st.path.toLowerCase().endsWith(".md")).length;
|
|
469
|
+
this.artifactCount = stats.filter((st) => /\.html?$/i.test(st.path)).length;
|
|
438
470
|
let read = 0;
|
|
439
471
|
|
|
440
472
|
const next = new Map<string, CachedNote>();
|
|
473
|
+
const nextArtifacts = new Map<string, CachedArtifact>();
|
|
441
474
|
const out: Note[] = [];
|
|
475
|
+
const artifactOut: HtmlArtifact[] = [];
|
|
442
476
|
for (const st of stats) {
|
|
477
|
+
if (/\.html?$/i.test(st.path)) {
|
|
478
|
+
const hit = this.artifacts.get(st.slug);
|
|
479
|
+
if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
480
|
+
this.notesCached += 1;
|
|
481
|
+
nextArtifacts.set(st.slug, hit);
|
|
482
|
+
artifactOut.push(hit.artifact);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
this.notesRead += 1;
|
|
486
|
+
read += 1;
|
|
487
|
+
const artifact = await getHtmlArtifact(this.vaultRoot, st.slug);
|
|
488
|
+
if (artifact === null) continue;
|
|
489
|
+
const cached = { mtimeMs: st.mtimeMs, size: st.size, artifact };
|
|
490
|
+
nextArtifacts.set(st.slug, cached);
|
|
491
|
+
artifactOut.push(artifact);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
443
494
|
const hit = this.notes.get(st.slug);
|
|
444
495
|
if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
445
496
|
this.notesCached += 1;
|
|
@@ -463,11 +514,14 @@ export class WorkspaceCache {
|
|
|
463
514
|
folders.length !== previousFolders.length ||
|
|
464
515
|
folders.some((f, i) => f !== previousFolders[i]);
|
|
465
516
|
this.notesChanged =
|
|
466
|
-
read > 0 || next.size !== previousCount ||
|
|
517
|
+
read > 0 || next.size !== previousCount || nextArtifacts.size !== previousArtifactCount ||
|
|
518
|
+
this.fileCount !== previousFileCount || this.artifactCount !== previousRawArtifactCount || foldersChanged;
|
|
467
519
|
this.notes = next;
|
|
520
|
+
this.artifacts = nextArtifacts;
|
|
521
|
+
const artifacts = artifactOut.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
468
522
|
// `statNotes` yields readdir (slug-ascending) order and sort is stable,
|
|
469
523
|
// so ties break by slug — identical to `readVault`.
|
|
470
|
-
return out.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
524
|
+
return { notes: out.sort((a, b) => b.updated.localeCompare(a.updated)), artifacts };
|
|
471
525
|
}
|
|
472
526
|
|
|
473
527
|
/** The repository half, re-assessed only when the TTL has expired. */
|
package/src/core/concurrency.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded-concurrency task runner shared by
|
|
3
|
-
* the session scan (sessions.ts).
|
|
2
|
+
* Bounded-concurrency task runner shared by repository scans.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* of the scheduler is a second place for an off-by-one or a lost
|
|
7
|
-
* cancellation check.
|
|
4
|
+
* Keeping the scheduler here gives every scan the same cancellation behavior.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
7
|
/**
|
|
@@ -33,4 +30,4 @@ export async function mapWithConcurrency<T, R>(
|
|
|
33
30
|
}
|
|
34
31
|
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
|
|
35
32
|
return results;
|
|
36
|
-
}
|
|
33
|
+
}
|
package/src/core/frontmatter.ts
CHANGED
|
@@ -289,59 +289,6 @@ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
|
|
|
289
289
|
return { fields, body, lines };
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
/**
|
|
293
|
-
* Upsert owned scalar fields into a front-matter block, preserving order.
|
|
294
|
-
*
|
|
295
|
-
* For each wanted key: the **first** line declaring it is replaced with a
|
|
296
|
-
* fresh `key: value` line in place, and any later declarations are dropped —
|
|
297
|
-
* mirroring how duplicate managed keys collapse in `replayBlock` (the
|
|
298
|
-
* subset parser keeps the last occurrence, so collapsing to one line with
|
|
299
|
-
* the fresh value is the consistent outcome). A key introducing a block
|
|
300
|
-
* construct (`scalar: false`) is replaced too: the value this function writes
|
|
301
|
-
* is a scalar, and leaving the old block head in place would orphan its
|
|
302
|
-
* indented children under a duplicated key.
|
|
303
|
-
*
|
|
304
|
-
* Wanted keys the block never declared are appended at the end, in the order
|
|
305
|
-
* given. Everything else — unknown keys, blank lines, junk — is carried
|
|
306
|
-
* through byte-identically, the same round-trip contract `serializeNote`
|
|
307
|
-
* honors for the note engine's writes.
|
|
308
|
-
*
|
|
309
|
-
* Keys arrive from pi-weave's own generated-note writers (`session_id`,
|
|
310
|
-
* `session_hash`, …); there is no escaping for the *key* because a key is
|
|
311
|
-
* caller-controlled code, not user input — `quoteField` guards the value.
|
|
312
|
-
*/
|
|
313
|
-
export function upsertFrontMatterFields(
|
|
314
|
-
lines: NoteFrontMatter,
|
|
315
|
-
fields: Record<string, string>,
|
|
316
|
-
): NoteFrontMatter {
|
|
317
|
-
const wanted = new Set(Object.keys(fields));
|
|
318
|
-
const out: string[] = [];
|
|
319
|
-
const written = new Set<string>();
|
|
320
|
-
let inDroppedBlock = false;
|
|
321
|
-
for (const line of scanFrontMatter(lines)) {
|
|
322
|
-
// Continuation lines of a block construct whose head we replaced: their
|
|
323
|
-
// parent key is gone, so carrying them would leave orphaned YAML children
|
|
324
|
-
// under a scalar. The block ends at the first non-indented line.
|
|
325
|
-
if (inDroppedBlock) {
|
|
326
|
-
if (/^\s/.test(line.text)) continue;
|
|
327
|
-
inDroppedBlock = false;
|
|
328
|
-
}
|
|
329
|
-
if (line.key !== null && wanted.has(line.key)) {
|
|
330
|
-
if (!written.has(line.key)) {
|
|
331
|
-
out.push(`${line.key}: ${quoteField(fields[line.key] ?? "")}`);
|
|
332
|
-
written.add(line.key);
|
|
333
|
-
if (!line.scalar) inDroppedBlock = true; // swallow the block body too
|
|
334
|
-
}
|
|
335
|
-
continue; // later duplicates collapse into the first occurrence
|
|
336
|
-
}
|
|
337
|
-
out.push(line.text);
|
|
338
|
-
}
|
|
339
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
340
|
-
if (!written.has(key)) out.push(`${key}: ${quoteField(value)}`);
|
|
341
|
-
}
|
|
342
|
-
return out;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
292
|
/**
|
|
346
293
|
* Parse a note file. Throws on missing/invalid front matter so callers can
|
|
347
294
|
* treat the file as malformed rather than guessing.
|
package/src/core/graph/build.ts
CHANGED
|
@@ -2,26 +2,28 @@
|
|
|
2
2
|
* Pure graph builder: knowledge workspace inputs → GraphModel.
|
|
3
3
|
*
|
|
4
4
|
* No I/O, no clock access, no harness imports (design §21,
|
|
5
|
-
* docs/
|
|
5
|
+
* docs/design.md §11). Stability contract: identical inputs produce
|
|
6
6
|
* byte-identical JSON (ids derive from slugs/paths only; `generatedAt` is
|
|
7
7
|
* derived from input timestamps, never from the wall clock) — that is what
|
|
8
8
|
* makes the page's refresh-polling cheap.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
|
|
11
|
+
import type { HtmlArtifact, Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import type { SummaryRecord } from "../summaries";
|
|
14
14
|
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
|
|
18
|
+
/** Hard cap on note nodes so a large vault cannot overwhelm the viewer. */
|
|
19
19
|
export const DEFAULT_MAX_NOTES = 500;
|
|
20
20
|
|
|
21
21
|
export interface BuildGraphInput {
|
|
22
22
|
vault: VaultStatus;
|
|
23
23
|
/** Full notes including bodies (for wiki-link extraction). */
|
|
24
24
|
notes: Note[];
|
|
25
|
+
/** Standalone HTML/HTM artifacts under the vault's notes directory. */
|
|
26
|
+
artifacts?: HtmlArtifact[];
|
|
25
27
|
/** Repository half; null when cwd is not an indexed git repository. */
|
|
26
28
|
repository: { index: RepoIndex; staleness: StalenessReport } | null;
|
|
27
29
|
/** Deep-scan summaries keyed by repo-relative path (docs/scan-modes.md). */
|
|
@@ -83,6 +85,9 @@ export function dataTimestamp(input: BuildGraphInput): string {
|
|
|
83
85
|
for (const note of input.notes) {
|
|
84
86
|
if (note.updated > max) max = note.updated;
|
|
85
87
|
}
|
|
88
|
+
for (const artifact of input.artifacts ?? []) {
|
|
89
|
+
if (artifact.updated > max) max = artifact.updated;
|
|
90
|
+
}
|
|
86
91
|
const repoStamp = input.repository?.index.updated ?? "";
|
|
87
92
|
if (repoStamp > max) max = repoStamp;
|
|
88
93
|
if (input.summaries) {
|
|
@@ -126,17 +131,22 @@ function buildVaultSide(
|
|
|
126
131
|
root: input.vault.root,
|
|
127
132
|
notes: String(input.vault.noteCount),
|
|
128
133
|
};
|
|
134
|
+
const artifacts = input.artifacts ?? [];
|
|
135
|
+
if ((input.vault.artifactCount ?? artifacts.length) > 0) {
|
|
136
|
+
vaultDetail.artifacts = String(input.vault.artifactCount ?? artifacts.length);
|
|
137
|
+
}
|
|
129
138
|
if (truncated) {
|
|
130
139
|
vaultDetail.warning = `Graph shows the ${maxNotes} most recent notes — the vault holds ${input.vault.noteCount}. Wiki-links to older notes are omitted.`;
|
|
131
140
|
}
|
|
132
141
|
nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
|
|
133
142
|
|
|
134
143
|
const keptSlugs = new Set(kept.map((n) => n.slug));
|
|
144
|
+
const artifactSlugs = new Set(artifacts.map((a) => a.slug));
|
|
145
|
+
const artifactLinks = new Map<string, number>();
|
|
135
146
|
|
|
136
|
-
// Nested notes
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
// repository module could legitimately share the path (`module:sessions`);
|
|
147
|
+
// Nested notes nest under synthesized folder nodes so the vault tree groups
|
|
148
|
+
// them the way the repository tree groups directories. Ids are prefixed
|
|
149
|
+
// `vfolder:` because a repository module could legitimately share the path;
|
|
140
150
|
// the tree renders any `contains` chain, so the kind reuse needs no client
|
|
141
151
|
// change. Deterministic: dirs sorted, parents before children.
|
|
142
152
|
const folderIds = new Map<string, string>();
|
|
@@ -144,6 +154,7 @@ function buildVaultSide(
|
|
|
144
154
|
...new Set([
|
|
145
155
|
...(input.vault.folders ?? []),
|
|
146
156
|
...kept.map((n) => n.slug.split("/").slice(0, -1).join("/")),
|
|
157
|
+
...artifacts.map((a) => a.slug.split("/").slice(0, -1).join("/")),
|
|
147
158
|
]),
|
|
148
159
|
]
|
|
149
160
|
.filter((d) => d.length > 0)
|
|
@@ -180,7 +191,7 @@ function buildVaultSide(
|
|
|
180
191
|
// The names, not just the count (§4.2). `detail` keeps carrying the count
|
|
181
192
|
// because it is what the TUI's side panel prints; the structured targets
|
|
182
193
|
// go on the model, where a UI can turn them into ghost nodes.
|
|
183
|
-
const dangling = links.filter((slug) => !keptSlugs.has(slug));
|
|
194
|
+
const dangling = links.filter((slug) => !keptSlugs.has(slug) && !artifactSlugs.has(slug));
|
|
184
195
|
if (dangling.length > 0) {
|
|
185
196
|
detail["dangling links"] = String(dangling.length);
|
|
186
197
|
danglingLinks[note.slug] = dangling;
|
|
@@ -192,6 +203,10 @@ function buildVaultSide(
|
|
|
192
203
|
for (const target of resolved) {
|
|
193
204
|
edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
|
|
194
205
|
}
|
|
206
|
+
for (const target of links.filter((slug) => artifactSlugs.has(slug))) {
|
|
207
|
+
edges.push({ source: `note:${note.slug}`, target: `artifact:${target}`, kind: "links-to" });
|
|
208
|
+
artifactLinks.set(target, (artifactLinks.get(target) ?? 0) + 1);
|
|
209
|
+
}
|
|
195
210
|
// A note body naming a repo path → `mentions` (§4.4). Emitted after the
|
|
196
211
|
// wiki-links so a note's edges read vault-ward first, then code-ward, and
|
|
197
212
|
// only for paths that are already nodes — `paths` is built from the repo
|
|
@@ -201,6 +216,21 @@ function buildVaultSide(
|
|
|
201
216
|
edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
|
|
202
217
|
}
|
|
203
218
|
}
|
|
219
|
+
for (const artifact of artifacts) {
|
|
220
|
+
const dir = artifact.slug.split("/").slice(0, -1).join("/");
|
|
221
|
+
const parent = (dir.length > 0 && folderIds.get(dir)) || "vault";
|
|
222
|
+
const detail: Record<string, string> = {
|
|
223
|
+
path: artifact.slug,
|
|
224
|
+
title: artifact.title,
|
|
225
|
+
updated: artifact.updated,
|
|
226
|
+
size: `${artifact.size} bytes`,
|
|
227
|
+
};
|
|
228
|
+
if (artifact.description) detail.description = artifact.description;
|
|
229
|
+
const links = artifactLinks.get(artifact.slug) ?? 0;
|
|
230
|
+
if (links > 0) detail["link references"] = String(links);
|
|
231
|
+
nodes.push({ id: `artifact:${artifact.slug}`, kind: "file", label: artifact.title, provenance: null, detail });
|
|
232
|
+
edges.push({ source: parent, target: `artifact:${artifact.slug}`, kind: "contains" });
|
|
233
|
+
}
|
|
204
234
|
return [...keptSlugs];
|
|
205
235
|
}
|
|
206
236
|
|
|
@@ -320,7 +350,7 @@ function buildRepositorySide(
|
|
|
320
350
|
|
|
321
351
|
/**
|
|
322
352
|
* Build the graph model for the viewer. Notes are capped at `maxNotes`
|
|
323
|
-
* (docs/
|
|
353
|
+
* (docs/design.md §11); the vault node carries a warning when truncated.
|
|
324
354
|
*/
|
|
325
355
|
export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number } = {}): GraphModel {
|
|
326
356
|
const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace assembly readers for the viewers (docs/
|
|
3
|
-
* weave-view-tui-design §3.1).
|
|
2
|
+
* Workspace assembly readers for the viewers (docs/design.md §11).
|
|
4
3
|
*
|
|
5
4
|
* These functions are *workspace assembly*, symmetric to `getWorkspaceStatus`
|
|
6
5
|
* (already in core): pure fan-out over `core/vault`, `core/repoIndex`,
|
|
@@ -92,7 +91,7 @@ export async function readRepositorySide(
|
|
|
92
91
|
|
|
93
92
|
/**
|
|
94
93
|
* Assemble the fresh graph from disk. Called on every viewer fetch
|
|
95
|
-
* (no caching
|
|
94
|
+
* (no caching). Reads the vault (capped at
|
|
96
95
|
* DEFAULT_MAX_NOTES) and, when cwd is an indexed git repository, the repo
|
|
97
96
|
* index + deep-scan summary sidecars. Degrades to a vault-only graph when
|
|
98
97
|
* the repo has no index or the index is corrupt.
|
|
@@ -102,11 +101,18 @@ export async function readRepositorySide(
|
|
|
102
101
|
* a third readdir) is now N reads and one readdir (weave-workspace §4.1).
|
|
103
102
|
*/
|
|
104
103
|
export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
|
|
105
|
-
const { notes, fileCount, folders } = await readVault(vaultRoot);
|
|
104
|
+
const { notes, fileCount, folders, artifacts, artifactCount } = await readVault(vaultRoot);
|
|
106
105
|
|
|
107
106
|
const input: BuildGraphInput = {
|
|
108
|
-
vault: {
|
|
107
|
+
vault: {
|
|
108
|
+
root: vaultRoot,
|
|
109
|
+
exists: true,
|
|
110
|
+
noteCount: fileCount,
|
|
111
|
+
...(folders ? { folders } : {}),
|
|
112
|
+
...(artifactCount ? { artifactCount } : {}),
|
|
113
|
+
},
|
|
109
114
|
notes: notes.slice(0, DEFAULT_MAX_NOTES),
|
|
115
|
+
...(artifacts ? { artifacts } : {}),
|
|
110
116
|
repository: null,
|
|
111
117
|
};
|
|
112
118
|
|
|
@@ -117,4 +123,3 @@ export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolve
|
|
|
117
123
|
}
|
|
118
124
|
return buildGraph(input);
|
|
119
125
|
}
|
|
120
|
-
|
package/src/core/graph/model.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Graph model shared between the builder (core) and the viewer (adapter).
|
|
3
|
-
* Pure data — no harness imports (design §21).
|
|
3
|
+
* Pure data — no harness imports (design §21).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { NoteSource, StalenessReport } from "../types";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Obsidian-compatible [[wiki-link]] extraction from note bodies.
|
|
3
|
-
* Pure module
|
|
3
|
+
* Pure module used by graph construction and viewer view-models.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { slugify } from "../slug";
|
|
@@ -14,8 +14,8 @@ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|[^\]]*)?\]\]/g;
|
|
|
14
14
|
* `release-plan`. Duplicates are removed, order of first appearance kept.
|
|
15
15
|
*
|
|
16
16
|
* Path separators survive: a nested note's slug is its path relative to
|
|
17
|
-
* `notes
|
|
18
|
-
*
|
|
17
|
+
* `notes/`, so links target the nested note rather than a flattened name.
|
|
18
|
+
* Each path segment is slugified independently.
|
|
19
19
|
*/
|
|
20
20
|
export function extractWikilinks(body: string): string[] {
|
|
21
21
|
const out: string[] = [];
|
|
@@ -23,7 +23,11 @@ export function extractWikilinks(body: string): string[] {
|
|
|
23
23
|
for (const match of body.matchAll(WIKILINK_RE)) {
|
|
24
24
|
const raw = (match[1] ?? "").trim();
|
|
25
25
|
if (raw.length === 0) continue;
|
|
26
|
-
|
|
26
|
+
// HTML artifacts are addressed by their vault-relative filename; unlike a
|
|
27
|
+
// Markdown note, the extension is part of the stable identity.
|
|
28
|
+
const slug = /\.html?$/i.test(raw)
|
|
29
|
+
? raw.replace(/\\/g, "/").replace(/^\.\//, "")
|
|
30
|
+
: raw.split("/").map((part) => slugify(part)).join("/");
|
|
27
31
|
if (seen.has(slug)) continue;
|
|
28
32
|
seen.add(slug);
|
|
29
33
|
out.push(slug);
|