pi-weave 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -1,4 +1,4 @@
1
- import type { NoteMeta, NoteSource } from "./types";
1
+ import type { NoteFrontMatter, NoteMeta, NoteSource } from "./types";
2
2
  import { NOTE_SOURCES } from "./types";
3
3
 
4
4
  /**
@@ -7,21 +7,94 @@ import { NOTE_SOURCES } from "./types";
7
7
  * Deliberately a *subset*: notes are meant to be human-editable plain text,
8
8
  * so we write only `key: value` scalars and `[a, b]` inline arrays, and we
9
9
  * parse exactly that. Anything richer belongs in the Markdown body.
10
+ *
11
+ * ## Parsing a subset without writing a subset
12
+ *
13
+ * "We parse a subset" used to also mean "we write a subset", and those are
14
+ * not the same promise. `parseNoteFile` read five keys and `serializeNote`
15
+ * wrote five keys, so every write through core silently deleted whatever
16
+ * else the file carried — `aliases`, `cssclass`, `publish`, any property an
17
+ * Obsidian user or another tool had added. Reading a note and appending one
18
+ * line destroyed the rest of its metadata (weave-workspace §11 P5).
19
+ *
20
+ * The fix is *not* a fuller YAML parser. A vault is plain text a human edits,
21
+ * and a parser that understands more is a parser that **rewrites** more —
22
+ * every construct it learns to read is a construct it will re-emit in its own
23
+ * preferred spelling. The fix is to stop conflating "parsed" with
24
+ * "preserved":
25
+ *
26
+ * - The five keys in {@link MANAGED_FRONT_MATTER_KEYS} are **owned**: parsed
27
+ * into {@link NoteMeta}, and re-rendered from those values on write.
28
+ * - Every other line is **carried**: emitted as the exact bytes it was read
29
+ * as, in its original position. Unknown keys, blank lines, junk without a
30
+ * colon, and syntax this subset cannot represent at all — block lists,
31
+ * folded scalars, nested maps — all survive, because none of them is ever
32
+ * interpreted.
33
+ *
34
+ * The round-trip property is therefore: **owned fields round-trip by value,
35
+ * every other byte round-trips by byte, and key order is preserved.**
36
+ *
37
+ * ## Two rules that keep writes from inventing content
38
+ *
39
+ * **An owned key is never added to a file that did not have it, unless it
40
+ * carries a value the parser would not have defaulted to.** Real vaults are
41
+ * full of hand-written notes with no `created`/`updated`; `parseNoteFile`
42
+ * defaults those to `""`, and writing `created: ` back would be the engine
43
+ * fabricating metadata during an unrelated edit. Since no mutation path ever
44
+ * *sets* `created`, it stays `""` and stays absent; `updated` is appended
45
+ * only because a caller explicitly asked for a bump, which is a requested
46
+ * change rather than a silent one. See {@link isDefaulted}.
47
+ *
48
+ * **An owned key whose on-disk syntax this subset cannot represent is
49
+ * frozen**: carried verbatim, and not re-rendered or duplicated. Obsidian's
50
+ * property editor writes tags as a YAML block list —
51
+ *
52
+ * ```yaml
53
+ * tags:
54
+ * - reading
55
+ * ```
56
+ *
57
+ * — which is exactly the shape a line-oriented serializer would destroy, by
58
+ * rewriting the parent as `tags: []` and orphaning the two children under it.
59
+ * So such a construct is left alone entirely and the append pass skips the
60
+ * key, because emitting a second `tags:` further down would leave the user
61
+ * with a duplicated property. The cost is that the engine neither reads nor
62
+ * updates those tags — the same blindness it has today, minus the data loss.
63
+ * It is observable rather than silent: every mutation API returns the note as
64
+ * re-parsed from what was actually written, so a caller that sets tags on
65
+ * such a note gets back a note whose tags did not change.
10
66
  */
11
67
 
12
68
  export interface ParsedNoteFile {
13
69
  meta: NoteMeta;
14
70
  body: string;
71
+ /** The front-matter block verbatim, for lossless re-serialization. */
72
+ frontMatter: NoteFrontMatter;
15
73
  }
16
74
 
17
75
  /** Generic, tolerant front-matter parse (any fields) — for non-note OKF files. */
18
76
  export interface ParsedFrontMatter {
19
77
  fields: Map<string, string>;
20
78
  body: string;
79
+ /** The raw block lines in file order, `---` fences excluded. */
80
+ lines: readonly string[];
21
81
  }
22
82
 
23
83
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
24
84
 
85
+ /**
86
+ * The front-matter keys this module owns — parsed into {@link NoteMeta} and
87
+ * re-rendered on write. Everything else is carried verbatim.
88
+ *
89
+ * The array order is also the order in which a missing owned key is appended,
90
+ * so a note that gains `updated` gains it in a predictable place.
91
+ */
92
+ export const MANAGED_FRONT_MATTER_KEYS = ["title", "created", "updated", "tags", "source"] as const;
93
+
94
+ export type ManagedFrontMatterKey = (typeof MANAGED_FRONT_MATTER_KEYS)[number];
95
+
96
+ const MANAGED: ReadonlySet<string> = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
97
+
25
98
  export function quoteField(value: string): string {
26
99
  // Only quote when the value could confuse our subset parser.
27
100
  if (/[:#[\]]|^\s|\s$|^$/.test(value)) {
@@ -38,6 +111,48 @@ export function unquoteField(value: string): string {
38
111
  return trimmed;
39
112
  }
40
113
 
114
+ /** One classified line of a front-matter block. */
115
+ export interface FrontMatterLine {
116
+ /** The line exactly as it appears in the file. */
117
+ text: string;
118
+ /** The top-level key it declares, or null for blank/junk/continuation lines. */
119
+ key: string | null;
120
+ /**
121
+ * True when the value is a scalar or inline array this subset can both read
122
+ * and write. False for a key introducing a block construct — its value
123
+ * lives in the indented lines below it, which no line-oriented rewrite can
124
+ * safely touch.
125
+ */
126
+ scalar: boolean;
127
+ }
128
+
129
+ /**
130
+ * Classify a front-matter block line by line.
131
+ *
132
+ * The **single** rule for "which line is which key", shared by the parser and
133
+ * the serializer. A second copy of this rule is a second way for a write to
134
+ * disagree with a read about whether a line is owned — and disagreeing in
135
+ * that direction means either dropping a property or duplicating it.
136
+ *
137
+ * Block detection needs one line of lookahead, which is why this classifies
138
+ * the whole block rather than exposing a per-line predicate: `tags:` is a
139
+ * scalar with an empty value on its own, and the head of a list when the next
140
+ * line is indented.
141
+ */
142
+ export function scanFrontMatter(lines: readonly string[]): FrontMatterLine[] {
143
+ return lines.map((text, i): FrontMatterLine => {
144
+ const idx = text.indexOf(":");
145
+ // Indented lines belong to whatever is above them: treating one as a
146
+ // top-level key would let the serializer hoist it out of its parent.
147
+ if (idx <= 0 || /^\s/.test(text)) return { text, key: null, scalar: false };
148
+ const key = text.slice(0, idx).trim();
149
+ if (key.length === 0) return { text, key: null, scalar: false };
150
+ const value = text.slice(idx + 1).trim();
151
+ const blockHead = value.length === 0 && /^\s+\S/.test(lines[i + 1] ?? "");
152
+ return { text, key, scalar: !blockHead };
153
+ });
154
+ }
155
+
41
156
  function parseTags(value: string): string[] {
42
157
  const trimmed = value.trim();
43
158
  if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
@@ -58,21 +173,98 @@ function parseSource(value: string): NoteSource {
58
173
  : "human";
59
174
  }
60
175
 
61
- /** Serialize note metadata + body to the on-disk Markdown form. */
62
- export function serializeNote(meta: NoteMeta, body: string): string {
63
- const lines = [
64
- "---",
65
- `title: ${quoteField(meta.title)}`,
66
- `created: ${meta.created}`,
67
- `updated: ${meta.updated}`,
68
- `tags: [${meta.tags.map(quoteField).join(", ")}]`,
69
- `source: ${meta.source}`,
70
- "---",
71
- "",
72
- body.replace(/\s+$/, ""),
73
- "",
74
- ];
75
- return lines.join("\n");
176
+ /** Render one owned key from its parsed value. */
177
+ function renderManaged(key: ManagedFrontMatterKey, meta: NoteMeta): string {
178
+ switch (key) {
179
+ case "title":
180
+ return `title: ${quoteField(meta.title)}`;
181
+ case "created":
182
+ return `created: ${meta.created}`;
183
+ case "updated":
184
+ return `updated: ${meta.updated}`;
185
+ case "tags":
186
+ return `tags: [${meta.tags.map(quoteField).join(", ")}]`;
187
+ default:
188
+ return `source: ${meta.source}`;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * True when omitting `key` would parse back to the same value — the value is
194
+ * indistinguishable from the parser's default for an absent key.
195
+ *
196
+ * Gates **appending** an owned key that the file did not already declare, so
197
+ * an edit to a note's body cannot grow its front matter with fields the user
198
+ * never wrote. A key the file *does* declare is always re-rendered, never
199
+ * dropped: `tags: []` and `source: human` written by hand are the user's
200
+ * lines, and deleting them would be the mirror-image mutation.
201
+ *
202
+ * `title` is never defaulted: it is the one required field, and a note
203
+ * without it is malformed rather than empty.
204
+ */
205
+ function isDefaulted(key: ManagedFrontMatterKey, meta: NoteMeta): boolean {
206
+ switch (key) {
207
+ case "title":
208
+ return false;
209
+ case "created":
210
+ return meta.created.length === 0;
211
+ case "updated":
212
+ return meta.updated.length === 0;
213
+ case "tags":
214
+ return meta.tags.length === 0;
215
+ default:
216
+ return meta.source === "human";
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Serialize note metadata + body to the on-disk Markdown form.
222
+ *
223
+ * With `frontMatter` supplied — as `parseNoteFile` returns and every vault
224
+ * write path threads through — the original block is replayed line for line:
225
+ * owned scalar keys are re-rendered in place from `meta`, everything else is
226
+ * emitted as the exact bytes it was read as. Owned keys the original did not
227
+ * declare are appended at the end of the block, and only when they carry a
228
+ * non-default value.
229
+ *
230
+ * Without it, the canonical five-line block is written — the shape `addNote`
231
+ * creates for a brand-new note, where there is no prior layout to respect.
232
+ */
233
+ export function serializeNote(meta: NoteMeta, body: string, frontMatter?: NoteFrontMatter): string {
234
+ const block = frontMatter === undefined ? canonicalBlock(meta) : replayBlock(meta, frontMatter);
235
+ return ["---", ...block, "---", "", body.replace(/\s+$/, ""), ""].join("\n");
236
+ }
237
+
238
+ function canonicalBlock(meta: NoteMeta): string[] {
239
+ return MANAGED_FRONT_MATTER_KEYS.map((key) => renderManaged(key, meta));
240
+ }
241
+
242
+ function replayBlock(meta: NoteMeta, frontMatter: NoteFrontMatter): string[] {
243
+ /** Top-level keys the file declares, in any syntax — including frozen ones. */
244
+ const declared = new Set<string>();
245
+ /** Owned keys already re-rendered, so a duplicate key collapses to one line. */
246
+ const rendered = new Set<string>();
247
+ const out: string[] = [];
248
+
249
+ for (const line of scanFrontMatter(frontMatter)) {
250
+ if (line.key !== null) declared.add(line.key);
251
+ if (line.key === null || !line.scalar || !MANAGED.has(line.key)) {
252
+ out.push(line.text); // carried verbatim — never interpreted, never reformatted
253
+ continue;
254
+ }
255
+ // A repeated owned key is a typo, and `parseFrontMatter` keeps the last
256
+ // occurrence. Re-rendering the parsed value at the *first* position and
257
+ // dropping the rest preserves that value while collapsing the duplicate.
258
+ if (rendered.has(line.key)) continue;
259
+ rendered.add(line.key);
260
+ out.push(renderManaged(line.key as ManagedFrontMatterKey, meta));
261
+ }
262
+
263
+ for (const key of MANAGED_FRONT_MATTER_KEYS) {
264
+ if (declared.has(key) || isDefaulted(key, meta)) continue;
265
+ out.push(renderManaged(key, meta));
266
+ }
267
+ return out;
76
268
  }
77
269
 
78
270
  /**
@@ -85,14 +277,69 @@ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
85
277
  const match = FRONTMATTER_RE.exec(text);
86
278
  if (!match || match[1] === undefined) return null;
87
279
  const body = text.slice(match[0].length).replace(/^\n/, "").replace(/\s+$/, "");
280
+ const lines = match[1].split("\n");
88
281
  const fields = new Map<string, string>();
89
- for (const line of match[1].split("\n")) {
90
- if (line.trim().length === 0) continue;
91
- const idx = line.indexOf(":");
92
- if (idx <= 0) continue; // tolerate blank/junk lines, front matter is best-effort
93
- fields.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
282
+ for (const line of scanFrontMatter(lines)) {
283
+ // Blank/junk lines and block constructs are skipped: front matter is
284
+ // best-effort, and a value this subset cannot read is one it must not
285
+ // pretend to have read as `""`.
286
+ if (line.key === null || !line.scalar) continue;
287
+ fields.set(line.key, line.text.slice(line.text.indexOf(":") + 1).trim());
288
+ }
289
+ return { fields, body, lines };
290
+ }
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)}`);
94
341
  }
95
- return { fields, body };
342
+ return out;
96
343
  }
97
344
 
98
345
  /**
@@ -115,5 +362,5 @@ export function parseNoteFile(text: string): ParsedNoteFile {
115
362
  tags: parseTags(parsed.fields.get("tags") ?? "[]"),
116
363
  source: parseSource(parsed.fields.get("source") ?? "human"),
117
364
  };
118
- return { meta, body: parsed.body };
365
+ return { meta, body: parsed.body, frontMatter: parsed.lines };
119
366
  }
package/src/core/git.ts CHANGED
@@ -18,7 +18,26 @@ export interface GitExecOptions {
18
18
 
19
19
  const DEFAULT_TIMEOUT_MS = 5_000;
20
20
 
21
+ /**
22
+ * Process-wide count of `git` subprocesses spawned by this module.
23
+ *
24
+ * Every git call in core funnels through the `git()` helper below, so this
25
+ * is an exact spawn count rather than a model of one. `src/core/cache/
26
+ * workspace` samples it around its own work to report `CacheStats.gitCalls`,
27
+ * which is what makes "a no-change rebuild spawns zero git processes"
28
+ * (weave-workspace §4.1) an assertion about observed behaviour.
29
+ *
30
+ * Monotonic and never reset: callers take deltas.
31
+ */
32
+ let spawnCount = 0;
33
+
34
+ /** Number of git subprocesses spawned so far in this process. Monotonic. */
35
+ export function gitSpawnCount(): number {
36
+ return spawnCount;
37
+ }
38
+
21
39
  async function git(args: string[], cwd: string, timeoutMs: number): Promise<string | null> {
40
+ spawnCount += 1;
22
41
  return new Promise((resolve) => {
23
42
  execFile("git", args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
24
43
  if (err) resolve(null);
@@ -9,8 +9,10 @@
9
9
  */
10
10
 
11
11
  import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
12
+ import { createHash } from "node:crypto";
12
13
  import type { SummaryRecord } from "../summaries";
13
14
  import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
15
+ import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
14
16
  import { extractWikilinks } from "./wikilinks";
15
17
 
16
18
  /** Hard cap on note nodes (docs/weave-view.md M3 guard). */
@@ -29,6 +31,30 @@ export interface BuildGraphInput {
29
31
  const SHORT_SHA_LEN = 7;
30
32
  const PREVIEW_LEN = 240;
31
33
 
34
+ /** Hex length of {@link noteBodyDigest} and of the model's `contentDigest`. */
35
+ const DIGEST_HEX_LEN = 32;
36
+
37
+ /**
38
+ * Content fingerprint of one note body. Pure, deterministic, truncated to
39
+ * 128 bits — a change-detection key, not a security boundary.
40
+ */
41
+ export function noteBodyDigest(body: string): string {
42
+ return createHash("sha256").update(body).digest("hex").slice(0, DIGEST_HEX_LEN);
43
+ }
44
+
45
+ /**
46
+ * The model's `contentDigest`: one hash over every note's slug and body
47
+ * digest, slug-sorted so it does not depend on note order. Empty when there
48
+ * are no notes — which is still a distinct value from any non-empty vault.
49
+ */
50
+ export function noteContentDigest(notes: readonly Note[]): string {
51
+ const hash = createHash("sha256");
52
+ for (const note of [...notes].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0))) {
53
+ hash.update(`${note.slug}\u0000${noteBodyDigest(note.body)}\n`);
54
+ }
55
+ return hash.digest("hex").slice(0, DIGEST_HEX_LEN);
56
+ }
57
+
32
58
  function moduleDetail(
33
59
  path: string,
34
60
  fileCount: number,
@@ -85,7 +111,14 @@ function parseRemote(raw: string): { label: string; url: string } {
85
111
  return { label: tail.replace(/\.git$/, ""), url: s };
86
112
  }
87
113
 
88
- function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNode[], edges: GraphEdge[]): string[] {
114
+ function buildVaultSide(
115
+ input: BuildGraphInput,
116
+ maxNotes: number,
117
+ nodes: GraphNode[],
118
+ edges: GraphEdge[],
119
+ danglingLinks: Record<string, string[]>,
120
+ paths: PathIndex,
121
+ ): string[] {
89
122
  const truncated = input.notes.length > maxNotes;
90
123
  const kept = input.notes.slice(0, maxNotes);
91
124
 
@@ -99,6 +132,37 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
99
132
  nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
100
133
 
101
134
  const keptSlugs = new Set(kept.map((n) => n.slug));
135
+
136
+ // Nested notes (`sessions/foo` — session memory, docs/session-scan.md) nest
137
+ // under synthesized folder nodes so the vault tree groups them the way the
138
+ // repository tree groups directories. Ids are prefixed `vfolder:` because a
139
+ // repository module could legitimately share the path (`module:sessions`);
140
+ // the tree renders any `contains` chain, so the kind reuse needs no client
141
+ // change. Deterministic: dirs sorted, parents before children.
142
+ const folderIds = new Map<string, string>();
143
+ const noteDirs = [...new Set(kept.map((n) => n.slug.split("/").slice(0, -1).join("/")))]
144
+ .filter((d) => d.length > 0)
145
+ .sort();
146
+ const notesIn = (dir: string): number => kept.filter((n) => n.slug.startsWith(`${dir}/`)).length;
147
+ for (const dir of noteDirs) {
148
+ const id = `vfolder:${dir}`;
149
+ folderIds.set(dir, id);
150
+ nodes.push({
151
+ id,
152
+ kind: "module",
153
+ label: dir.split("/").pop() ?? dir,
154
+ provenance: null,
155
+ detail: { path: dir, notes: String(notesIn(dir)) },
156
+ });
157
+ const parentDir = dir.split("/").slice(0, -1).join("/");
158
+ const parent = folderIds.get(parentDir) ?? "vault";
159
+ edges.push({ source: parent, target: id, kind: "contains" });
160
+ }
161
+ const parentOf = (slug: string): string => {
162
+ const dir = slug.split("/").slice(0, -1).join("/");
163
+ return (dir.length > 0 && folderIds.get(dir)) || "vault";
164
+ };
165
+
102
166
  for (const note of kept) {
103
167
  const links = extractWikilinks(note.body);
104
168
  const resolved = links.filter((slug) => keptSlugs.has(slug));
@@ -108,15 +172,29 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
108
172
  updated: note.updated,
109
173
  };
110
174
  if (note.tags.length > 0) detail.tags = note.tags.join(", ");
111
- const dangling = links.length - resolved.length;
112
- if (dangling > 0) detail["dangling links"] = String(dangling);
175
+ // The names, not just the count (§4.2). `detail` keeps carrying the count
176
+ // because it is what the TUI's side panel prints; the structured targets
177
+ // go on the model, where a UI can turn them into ghost nodes.
178
+ const dangling = links.filter((slug) => !keptSlugs.has(slug));
179
+ if (dangling.length > 0) {
180
+ detail["dangling links"] = String(dangling.length);
181
+ danglingLinks[note.slug] = dangling;
182
+ }
113
183
  detail.preview = preview(note.body);
114
184
 
115
185
  nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
116
- edges.push({ source: "vault", target: `note:${note.slug}`, kind: "contains" });
186
+ edges.push({ source: parentOf(note.slug), target: `note:${note.slug}`, kind: "contains" });
117
187
  for (const target of resolved) {
118
188
  edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
119
189
  }
190
+ // A note body naming a repo path → `mentions` (§4.4). Emitted after the
191
+ // wiki-links so a note's edges read vault-ward first, then code-ward, and
192
+ // only for paths that are already nodes — `paths` is built from the repo
193
+ // index, so a mention of an unindexed file resolves to its enclosing
194
+ // module or to nothing at all. Never to a phantom node.
195
+ for (const target of resolveMentions(note.body, paths)) {
196
+ edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
197
+ }
120
198
  }
121
199
  return [...keptSlugs];
122
200
  }
@@ -243,7 +321,16 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
243
321
  const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
244
322
  const nodes: GraphNode[] = [];
245
323
  const edges: GraphEdge[] = [];
246
- buildVaultSide(input, maxNotes, nodes, edges);
324
+ const danglingLinks: Record<string, string[]> = {};
325
+ // Built before the vault side, because that is where `mentions` edges are
326
+ // emitted and they need to know which repo paths are real nodes. Derived
327
+ // from the same `structure` arrays `buildRepositorySide` walks, so the two
328
+ // cannot disagree about which ids exist. Empty when there is no repository:
329
+ // a vault-only graph has nothing to mention.
330
+ const paths = input.repository === null
331
+ ? (new Map<string, string>() as PathIndex)
332
+ : buildPathIndex(input.repository.index.structure);
333
+ buildVaultSide(input, maxNotes, nodes, edges, danglingLinks, paths);
247
334
  if (input.repository !== null) {
248
335
  buildRepositorySide(input.repository, input.summaries, nodes, edges);
249
336
  }
@@ -252,7 +339,12 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
252
339
  staleness: input.repository?.staleness ?? null,
253
340
  nodes,
254
341
  edges,
342
+ danglingLinks,
343
+ // The same slice the vault side kept, so the digest describes exactly
344
+ // the notes that have nodes. Slug-ordered inside, hence order-stable.
345
+ contentDigest: noteContentDigest(input.notes.slice(0, maxNotes)),
255
346
  };
256
347
  }
257
348
 
258
349
  export type { EdgeKind, GraphEdge, GraphModel, GraphNode, NodeKind } from "./model";
350
+ export { buildPathIndex, extractPathMentions, resolveMentions, type PathIndex } from "./mentions";
@@ -11,22 +11,17 @@
11
11
 
12
12
  import { readFile } from "node:fs/promises";
13
13
  import { join, resolve, sep } from "node:path";
14
- import {
15
- assessStaleness,
16
- buildGraph,
17
- DEFAULT_MAX_NOTES,
18
- findGitRoot,
19
- getNote,
20
- listNotes,
21
- noteCount,
22
- readRepoIndex,
23
- readSummaryMap,
24
- resolveNotePath,
25
- resolveVaultRoot,
26
- type BuildGraphInput,
27
- type GraphModel,
28
- type Note,
29
- } from "../index";
14
+ // Imported from the individual modules rather than the `../index` barrel:
15
+ // `src/core/cache/workspace` imports this file, and the barrel re-exports
16
+ // that cache, so going through the barrel would close an import cycle.
17
+ import { findGitRoot } from "../git";
18
+ import { resolveVaultRoot } from "../paths";
19
+ import { assessStaleness, readRepoIndex } from "../repoIndex";
20
+ import { readSummaryMap } from "../summaries";
21
+ import type { Note } from "../types";
22
+ import { getNote, readVault, resolveNotePath } from "../vault";
23
+ import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "./build";
24
+ import type { GraphModel } from "./model";
30
25
 
31
26
  /** A note read for the viewers (read-only; never cached). Mirrors the vault `Note` shape. */
32
27
  export interface ViewNote {
@@ -76,31 +71,49 @@ export async function readOkfFileForView(cwd: string, rel: string): Promise<{ pa
76
71
  }
77
72
  }
78
73
 
74
+ /**
75
+ * Assemble the repository half of the graph input for `cwd`, or null when
76
+ * cwd is not inside a git repository with a readable `.okf` index. Shared
77
+ * with `src/core/cache/workspace` so the cached and uncached paths cannot
78
+ * drift in what they read.
79
+ */
80
+ export async function readRepositorySide(
81
+ cwd: string,
82
+ ): Promise<Pick<BuildGraphInput, "repository" | "summaries"> | null> {
83
+ const repoRoot = await findGitRoot(cwd);
84
+ if (repoRoot === null) return null;
85
+ const index = await readRepoIndex(repoRoot);
86
+ if (index === null) return null;
87
+ return {
88
+ repository: { index, staleness: await assessStaleness(repoRoot) },
89
+ summaries: await readSummaryMap(repoRoot), // deep-scan sidecars, read live
90
+ };
91
+ }
92
+
79
93
  /**
80
94
  * Assemble the fresh graph from disk. Called on every viewer fetch
81
95
  * (no caching — docs/weave-view.md §2). Reads the vault (capped at
82
96
  * DEFAULT_MAX_NOTES) and, when cwd is an indexed git repository, the repo
83
97
  * index + deep-scan summary sidecars. Degrades to a vault-only graph when
84
98
  * the repo has no index or the index is corrupt.
99
+ *
100
+ * One read per note: `readVault` returns bodies *and* the file count, so the
101
+ * old `listNotes` → `getNote`-per-slug → `noteCount` sequence (2N reads plus
102
+ * a third readdir) is now N reads and one readdir (weave-workspace §4.1).
85
103
  */
86
104
  export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
87
- const noteSummaries = (await listNotes(vaultRoot)).slice(0, DEFAULT_MAX_NOTES);
88
- const loaded = await Promise.all(noteSummaries.map((s) => getNote(vaultRoot, s.slug)));
89
- const notes = loaded.filter((n): n is Note => n !== null);
105
+ const { notes, fileCount } = await readVault(vaultRoot);
90
106
 
91
107
  const input: BuildGraphInput = {
92
- vault: { root: vaultRoot, exists: true, noteCount: await noteCount(vaultRoot) },
93
- notes,
108
+ vault: { root: vaultRoot, exists: true, noteCount: fileCount },
109
+ notes: notes.slice(0, DEFAULT_MAX_NOTES),
94
110
  repository: null,
95
111
  };
96
112
 
97
- const repoRoot = await findGitRoot(cwd);
98
- if (repoRoot !== null) {
99
- const index = await readRepoIndex(repoRoot);
100
- if (index !== null) {
101
- input.repository = { index, staleness: await assessStaleness(repoRoot) };
102
- input.summaries = await readSummaryMap(repoRoot); // deep-scan sidecars, read live
103
- }
113
+ const repo = await readRepositorySide(cwd);
114
+ if (repo !== null) {
115
+ input.repository = repo.repository;
116
+ if (repo.summaries !== undefined) input.summaries = repo.summaries;
104
117
  }
105
118
  return buildGraph(input);
106
119
  }