pi-weave 0.1.7 → 0.1.8

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 (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * WorkspaceCache — an mtime-keyed cache over `buildCurrentGraph`
3
+ * (weave-workspace §4.1).
4
+ *
5
+ * The browser workspace pushes a graph over SSE on every file event, so the
6
+ * uncached path — N note reads plus ~5 git spawns per graph — becomes the
7
+ * bottleneck immediately. This layer keeps the parsed notes and the
8
+ * staleness report between builds and re-reads only what actually changed:
9
+ *
10
+ * - notes: one `stat` per file per build; a note is re-read only when its
11
+ * `mtimeMs` or `size` moved. A no-change rebuild costs N stats, zero
12
+ * reads, and zero git spawns — the headline target of §4.1.
13
+ * - repository side (index + staleness + summaries): held behind a short
14
+ * TTL because assessing staleness spawns git and sha1s every dirty file.
15
+ *
16
+ * Correctness contract: for the same on-disk inputs, `graph()` returns a
17
+ * model deep-equal to `buildCurrentGraph(cwd, vaultRoot)`. The cache is an
18
+ * optimisation, never a different answer — `tests/core/cache/workspace.test`
19
+ * asserts that equivalence directly.
20
+ *
21
+ * Stale-read hazard: mtime has coarse resolution on some filesystems, so a
22
+ * write landing in the same millisecond as the previous one with an
23
+ * identical size would be missed. The watcher closes that gap by calling
24
+ * `invalidate(path)`, which drops the entry unconditionally; mtime+size is
25
+ * the fallback for changes that arrive without an event (or before the
26
+ * watcher starts).
27
+ */
28
+
29
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
30
+ import { gitSpawnCount } from "../git";
31
+ import { NOTES_DIR } from "../paths";
32
+ import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "../graph/build";
33
+ import type { GraphModel } from "../graph/model";
34
+ import { readRepositorySide } from "../graph/current";
35
+ import { withMutationQueue } from "../mutex";
36
+ import { getNote, statNotes } from "../vault";
37
+ import type { Note } from "../types";
38
+
39
+ /**
40
+ * One build's outputs: the graph, and the notes it was built from.
41
+ *
42
+ * The two travel together because a caller deriving anything per-note — the
43
+ * tag index (§4.3) is the motivating case — must use *the same* list the
44
+ * graph used, including the `DEFAULT_MAX_NOTES` truncation. Reading the notes
45
+ * from a second call would let the cap fall between them and produce a tag
46
+ * pointing at a slug the graph has no node for.
47
+ */
48
+ export interface WorkspaceSnapshot {
49
+ model: GraphModel;
50
+ /**
51
+ * Exactly the notes `buildGraph` saw: newest-updated first and already
52
+ * truncated to the cap. Frozen — this is the cache's own array and a
53
+ * caller mutating it would corrupt the next build.
54
+ */
55
+ notes: readonly Note[];
56
+ }
57
+
58
+ /** Cumulative counters, from construction. Callers take deltas. */
59
+ export interface CacheStats {
60
+ /** Note files actually read and parsed. */
61
+ notesRead: number;
62
+ /** Note reads avoided because mtime+size were unchanged. */
63
+ notesCached: number;
64
+ /** git subprocesses spawned by builds this cache performed. */
65
+ gitCalls: number;
66
+ /** ISO timestamp of the last completed build; empty before the first. */
67
+ builtAt: string;
68
+ }
69
+
70
+ /** What a cached note costs to validate: its change stamp plus the parse. */
71
+ interface CachedNote {
72
+ mtimeMs: number;
73
+ size: number;
74
+ note: Note;
75
+ }
76
+
77
+ /** The repository half, held behind a TTL because assessing it spawns git. */
78
+ interface CachedRepo {
79
+ /** Wall-clock ms (from the injected clock) when this was captured. */
80
+ at: number;
81
+ value: Pick<BuildGraphInput, "repository" | "summaries"> | null;
82
+ }
83
+
84
+ /**
85
+ * How long a staleness assessment is trusted. Short enough that a `git
86
+ * commit` in another terminal shows up promptly, long enough that a burst of
87
+ * SSE-triggered rebuilds costs one assessment rather than one each.
88
+ */
89
+ export const DEFAULT_STALENESS_TTL_MS = 2_000;
90
+
91
+ export interface WorkspaceCacheOptions {
92
+ cwd: string;
93
+ vaultRoot: string;
94
+ /** Injected clock (project convention: never read the wall clock directly). */
95
+ now?: () => Date;
96
+ /** Staleness TTL in ms. 0 disables caching of the repository side. */
97
+ stalenessTtlMs?: number;
98
+ }
99
+
100
+ /** Which half of the workspace a changed path belongs to. */
101
+ export type InvalidationScope = "vault" | "repo" | "none";
102
+
103
+ /**
104
+ * Classify an absolute path into the cache scope it invalidates.
105
+ *
106
+ * Exported because the watcher wants the same classification to decide
107
+ * whether an event is worth forwarding at all, and one implementation means
108
+ * the two can never disagree.
109
+ *
110
+ * - `vault`: a `*.md` under `<vaultRoot>/notes/`. Only note files count —
111
+ * the vault manifest does not participate in the graph.
112
+ * - `repo`: anything under `<cwd>/.okf/` (the derived index and its summary
113
+ * sidecars) or under `<cwd>/.git/` (HEAD moves, staged changes), plus any
114
+ * tracked file in the repo, since editing one makes the index stale.
115
+ * - `none`: outside both trees.
116
+ *
117
+ * A vault that lives *inside* the repo is deliberately resolved vault-first:
118
+ * a note write should not force a git re-assessment.
119
+ */
120
+ export function classifyPath(
121
+ absPath: string,
122
+ opts: { cwd: string; vaultRoot: string },
123
+ ): InvalidationScope {
124
+ return classify(absPath, opts).scope;
125
+ }
126
+
127
+ /**
128
+ * The classification plus, for a vault note, the slug it identifies —
129
+ * `invalidate` needs both and deriving them separately would mean resolving
130
+ * the same path twice and re-deriving a guard the classification already
131
+ * proved.
132
+ */
133
+ function classify(
134
+ absPath: string,
135
+ opts: { cwd: string; vaultRoot: string },
136
+ ): { scope: InvalidationScope; slug: string | null } {
137
+ const path = resolve(absPath);
138
+ const rel = relative(resolve(opts.vaultRoot, NOTES_DIR), path);
139
+ // Directly inside the notes dir (flat vault: no separator in the relative
140
+ // path) and Markdown — anything else in there is not a note.
141
+ if (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel) && !rel.includes(sep)) {
142
+ if (rel.endsWith(".md")) return { scope: "vault", slug: rel.slice(0, -".md".length) };
143
+ return { scope: "none", slug: null };
144
+ }
145
+ return { scope: within(path, resolve(opts.cwd)) ? "repo" : "none", slug: null };
146
+ }
147
+
148
+ /** True when `path` is `root` itself or sits underneath it. */
149
+ function within(path: string, root: string): boolean {
150
+ if (path === root) return true;
151
+ const rel = relative(root, path);
152
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
153
+ }
154
+
155
+ /**
156
+ * A cached view of one workspace.
157
+ *
158
+ * A class rather than the plain async functions used elsewhere in core: this
159
+ * is the first thing in the tree with a lifetime and mutable identity. The
160
+ * watcher holds a reference and calls `invalidate` between reads, so the
161
+ * state *is* the object — a factory returning closures would be the same
162
+ * design wearing a different hat, with worse stack traces. Every method
163
+ * stays a plain async function over injected inputs, so it is as testable as
164
+ * the rest of core.
165
+ *
166
+ * Not safe to share across different `cwd`/`vaultRoot` pairs — construct one
167
+ * per workspace.
168
+ */
169
+ export class WorkspaceCache {
170
+ private readonly cwd: string;
171
+ private readonly vaultRoot: string;
172
+ private readonly now: () => Date;
173
+ private readonly stalenessTtlMs: number;
174
+
175
+ private notes = new Map<string, CachedNote>();
176
+ /**
177
+ * `.md` files present at the last refresh, including ones too malformed to
178
+ * parse — mirrors `readVault().fileCount` so the vault node's note count
179
+ * matches the uncached build exactly.
180
+ */
181
+ private fileCount = 0;
182
+ private repo: CachedRepo | null = null;
183
+ /**
184
+ * The last snapshot handed out, reused verbatim when a build proves nothing
185
+ * moved.
186
+ *
187
+ * `buildGraph` is pure and byte-deterministic, so rebuilding unchanged
188
+ * inputs allocates a fresh object that is deep-equal to this one and
189
+ * nothing else. Returning the *identical* object instead buys two things:
190
+ * the graph construction itself is skipped on the warm path (it was the
191
+ * only remaining per-request cost once note reads and git spawns were
192
+ * eliminated), and downstream consumers gain a stable identity they can
193
+ * memoize against — `src/web/server/routes` keys its serialized-payload
194
+ * and ETag cache off exactly this reference.
195
+ *
196
+ * Reuse is deliberately conservative: see {@link build} for the four
197
+ * conditions, all of which must hold.
198
+ */
199
+ private lastSnapshot: WorkspaceSnapshot | null = null;
200
+ /** The single in-flight build, so concurrent callers coalesce. */
201
+ private inFlight: Promise<WorkspaceSnapshot> | null = null;
202
+ /**
203
+ * The `.model` projection of {@link inFlight}, memoized.
204
+ *
205
+ * Derived once rather than per call so `graph()` keeps its documented
206
+ * contract of handing *the identical promise* to concurrent callers. A
207
+ * bare `async graph()` would allocate a fresh promise each time — the same
208
+ * build, but no longer the same object, which is a coalescing guarantee
209
+ * the tests pin directly.
210
+ */
211
+ private inFlightGraph: Promise<GraphModel> | null = null;
212
+ /**
213
+ * Slugs invalidated while a build was in flight.
214
+ *
215
+ * A build ends by replacing the note map wholesale, which would otherwise
216
+ * resurrect an entry the watcher dropped mid-flight and leave the cache
217
+ * serving a stale note indefinitely. Collecting them here and applying the
218
+ * eviction after the swap means the change is picked up by the *next*
219
+ * build instead of being lost. Same reasoning for `repoDirtiedDuringBuild`.
220
+ */
221
+ private evictedDuringBuild = new Set<string>();
222
+ private allEvictedDuringBuild = false;
223
+ private repoDirtiedDuringBuild = false;
224
+ private building = false;
225
+ /**
226
+ * Whether the last {@link refreshNotes} observed any note-side movement:
227
+ * a file read, a note that disappeared, or a change in the raw `.md` count.
228
+ * Read by {@link build} to decide whether {@link lastSnapshot} is reusable.
229
+ */
230
+ private notesChanged = true;
231
+
232
+ private notesRead = 0;
233
+ private notesCached = 0;
234
+ private gitCalls = 0;
235
+ private builtAt = "";
236
+
237
+ constructor(opts: WorkspaceCacheOptions) {
238
+ this.cwd = opts.cwd;
239
+ this.vaultRoot = opts.vaultRoot;
240
+ this.now = opts.now ?? (() => new Date());
241
+ this.stalenessTtlMs = opts.stalenessTtlMs ?? DEFAULT_STALENESS_TTL_MS;
242
+ }
243
+
244
+ /**
245
+ * The current graph, rebuilt from whatever changed since the last call.
246
+ *
247
+ * Concurrent callers share one build: a second `graph()` arriving while a
248
+ * build is in flight receives the same promise rather than starting a
249
+ * second pass over the disk.
250
+ */
251
+ graph(): Promise<GraphModel> {
252
+ if (this.inFlightGraph !== null) return this.inFlightGraph;
253
+ const projected = this.snapshot().then((s) => s.model);
254
+ // `snapshot()` may have completed synchronously-enough to have already
255
+ // cleared the slots; only claim the slot if a build is still in flight,
256
+ // so a later caller starts a fresh build rather than reusing this one.
257
+ if (this.inFlight !== null) this.inFlightGraph = projected;
258
+ return projected;
259
+ }
260
+
261
+ /**
262
+ * The graph **and** the notes it was built from, from a single build.
263
+ *
264
+ * `graph()` is this with the notes dropped. Callers that derive anything
265
+ * per-note (the tag index, §4.3) must use this instead of pairing `graph()`
266
+ * with a separate vault read, or the two can disagree about which notes
267
+ * exist — see {@link WorkspaceSnapshot}.
268
+ */
269
+ snapshot(): Promise<WorkspaceSnapshot> {
270
+ if (this.inFlight !== null) return this.inFlight;
271
+ // Serialized against note writes on the same vault (src/core/mutex), so
272
+ // a build cannot read a note file mid-rewrite and cache a torn parse.
273
+ const build = withMutationQueue(join(this.vaultRoot, NOTES_DIR), () => this.build());
274
+ this.inFlight = build;
275
+ // Clear the slots however the build ends, so a failure does not wedge the
276
+ // cache into permanently replaying a rejected promise. Both slots are
277
+ // released together: `inFlightGraph` is only ever a projection of this
278
+ // build, so outliving it would hand the next caller a stale model.
279
+ const clear = (): void => {
280
+ if (this.inFlight === build) {
281
+ this.inFlight = null;
282
+ this.inFlightGraph = null;
283
+ }
284
+ };
285
+ build.then(clear, clear);
286
+ return build;
287
+ }
288
+
289
+ /**
290
+ * Drop the cache entries a changed path affects. Cheap and synchronous:
291
+ * the watcher calls this per event, and the next `graph()` pays for it.
292
+ */
293
+ invalidate(absPath: string): void {
294
+ const { scope, slug } = classify(absPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
295
+ if (scope === "vault" && slug !== null) {
296
+ this.notes.delete(slug);
297
+ if (this.building) this.evictedDuringBuild.add(slug);
298
+ } else if (scope === "repo") {
299
+ this.repo = null;
300
+ if (this.building) this.repoDirtiedDuringBuild = true;
301
+ }
302
+ }
303
+
304
+ /** Drop everything: a repo scan landed, or the vault root moved. */
305
+ invalidateAll(): void {
306
+ this.notes.clear();
307
+ this.repo = null;
308
+ if (this.building) {
309
+ // Whatever the in-flight build writes back was read before this call,
310
+ // so none of it may count as fresh.
311
+ this.allEvictedDuringBuild = true;
312
+ this.repoDirtiedDuringBuild = true;
313
+ }
314
+ }
315
+
316
+ stats(): CacheStats {
317
+ return {
318
+ notesRead: this.notesRead,
319
+ notesCached: this.notesCached,
320
+ gitCalls: this.gitCalls,
321
+ builtAt: this.builtAt,
322
+ };
323
+ }
324
+
325
+ /** One full pass: refresh what changed, then run the pure builder. */
326
+ private async build(): Promise<WorkspaceSnapshot> {
327
+ const spawnsBefore = gitSpawnCount();
328
+ this.building = true;
329
+ this.evictedDuringBuild.clear();
330
+ this.allEvictedDuringBuild = false;
331
+ this.repoDirtiedDuringBuild = false;
332
+ try {
333
+ const notes = await this.refreshNotes();
334
+ const repoFresh = this.repoNeedsRefresh();
335
+ const repo = await this.refreshRepo();
336
+
337
+ // Nothing moved on either side, so the builder would reproduce the
338
+ // previous model byte for byte (`buildGraph` is pure and
339
+ // byte-deterministic for identical inputs). Hand back the *identical*
340
+ // object rather than an equal one — see {@link lastSnapshot}.
341
+ //
342
+ // All four conditions are required, and each one is a way the inputs
343
+ // can differ while the others look quiet:
344
+ //
345
+ // - a previous snapshot exists at all;
346
+ // - `refreshNotes` read nothing and lost nothing (`notesChanged`);
347
+ // - the repository side came from the TTL cache rather than a fresh
348
+ // assessment — a re-assessment can move `staleness` or the git node
349
+ // with no note touched;
350
+ // - no invalidation landed *while* this build was reading, which the
351
+ // deferred-eviction machinery would otherwise apply only after the
352
+ // swap, leaving this snapshot describing inputs already known stale.
353
+ const quiet =
354
+ this.lastSnapshot !== null &&
355
+ !this.notesChanged &&
356
+ !repoFresh &&
357
+ !this.allEvictedDuringBuild &&
358
+ !this.repoDirtiedDuringBuild &&
359
+ this.evictedDuringBuild.size === 0;
360
+ if (quiet && this.lastSnapshot !== null) {
361
+ this.gitCalls += gitSpawnCount() - spawnsBefore;
362
+ this.builtAt = this.now().toISOString();
363
+ return this.lastSnapshot;
364
+ }
365
+
366
+ // Truncated once, here, and then handed to *both* the builder and the
367
+ // snapshot — so a caller deriving per-note data cannot see a note the
368
+ // graph has no node for (§4.3).
369
+ const kept = notes.slice(0, DEFAULT_MAX_NOTES);
370
+ const input: BuildGraphInput = {
371
+ vault: { root: this.vaultRoot, exists: true, noteCount: this.fileCount },
372
+ notes: kept,
373
+ repository: repo?.repository ?? null,
374
+ };
375
+ if (repo?.summaries !== undefined) input.summaries = repo.summaries;
376
+
377
+ this.gitCalls += gitSpawnCount() - spawnsBefore;
378
+ this.builtAt = this.now().toISOString();
379
+ const snapshot: WorkspaceSnapshot = { model: buildGraph(input), notes: Object.freeze(kept) };
380
+ this.lastSnapshot = snapshot;
381
+ return snapshot;
382
+ } finally {
383
+ this.building = false;
384
+ this.applyDeferredInvalidations();
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Whether the next {@link refreshRepo} will actually re-assess.
390
+ *
391
+ * Sampled *before* the refresh, because the refresh overwrites the very
392
+ * timestamp the question is about. A fresh assessment can move the git node
393
+ * or the staleness report without any note changing, so it is one of the
394
+ * conditions that forbids snapshot reuse.
395
+ */
396
+ private repoNeedsRefresh(): boolean {
397
+ if (this.repo === null) return true;
398
+ return this.now().getTime() - this.repo.at >= this.stalenessTtlMs;
399
+ }
400
+
401
+ /**
402
+ * Re-apply invalidations that arrived while the build was reading, which
403
+ * the wholesale map/TTL replacement at the end of a build would otherwise
404
+ * have undone. Without this, a note written mid-build stays stale until
405
+ * something else touches it.
406
+ */
407
+ private applyDeferredInvalidations(): void {
408
+ if (this.allEvictedDuringBuild) this.notes.clear();
409
+ else for (const slug of this.evictedDuringBuild) this.notes.delete(slug);
410
+ if (this.repoDirtiedDuringBuild) this.repo = null;
411
+ this.evictedDuringBuild.clear();
412
+ this.allEvictedDuringBuild = false;
413
+ this.repoDirtiedDuringBuild = false;
414
+ }
415
+
416
+ /**
417
+ * Stat every note; re-read only the ones whose mtime or size moved. Notes
418
+ * that disappeared are evicted, so the map never outgrows the vault.
419
+ */
420
+ private async refreshNotes(): Promise<Note[]> {
421
+ const stats = await statNotes(this.vaultRoot);
422
+ const previousCount = this.notes.size;
423
+ const previousFileCount = this.fileCount;
424
+ this.fileCount = stats.length;
425
+ let read = 0;
426
+
427
+ const next = new Map<string, CachedNote>();
428
+ const out: Note[] = [];
429
+ for (const st of stats) {
430
+ const hit = this.notes.get(st.slug);
431
+ if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
432
+ this.notesCached += 1;
433
+ next.set(st.slug, hit);
434
+ out.push(hit.note);
435
+ continue;
436
+ }
437
+ this.notesRead += 1;
438
+ read += 1;
439
+ const note = await getNote(this.vaultRoot, st.slug);
440
+ // Unreadable/malformed notes are skipped but still counted in
441
+ // fileCount, exactly as `readVault` does.
442
+ if (note === null) continue;
443
+ next.set(st.slug, { mtimeMs: st.mtimeMs, size: st.size, note });
444
+ out.push(note);
445
+ }
446
+ // A note vanished if the map shrank without a compensating read; the
447
+ // file count moving covers a malformed file appearing or disappearing,
448
+ // which changes the vault node's `notes` detail without ever parsing.
449
+ this.notesChanged = read > 0 || next.size !== previousCount || this.fileCount !== previousFileCount;
450
+ this.notes = next;
451
+ // `statNotes` yields readdir (slug-ascending) order and sort is stable,
452
+ // so ties break by slug — identical to `readVault`.
453
+ return out.sort((a, b) => b.updated.localeCompare(a.updated));
454
+ }
455
+
456
+ /** The repository half, re-assessed only when the TTL has expired. */
457
+ private async refreshRepo(): Promise<Pick<BuildGraphInput, "repository" | "summaries"> | null> {
458
+ const at = this.now().getTime();
459
+ if (this.repo !== null && at - this.repo.at < this.stalenessTtlMs) {
460
+ return this.repo.value;
461
+ }
462
+ const value = await readRepositorySide(this.cwd);
463
+ this.repo = { at, value };
464
+ return value;
465
+ }
466
+ }