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
@@ -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,16 @@ 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());
94
288
  }
95
- return { fields, body };
289
+ return { fields, body, lines };
96
290
  }
97
291
 
98
292
  /**
@@ -115,5 +309,5 @@ export function parseNoteFile(text: string): ParsedNoteFile {
115
309
  tags: parseTags(parsed.fields.get("tags") ?? "[]"),
116
310
  source: parseSource(parsed.fields.get("source") ?? "human"),
117
311
  };
118
- return { meta, body: parsed.body };
312
+ return { meta, body: parsed.body, frontMatter: parsed.lines };
119
313
  }
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);
@@ -11,6 +11,7 @@
11
11
  import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
12
12
  import type { SummaryRecord } from "../summaries";
13
13
  import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
14
+ import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
14
15
  import { extractWikilinks } from "./wikilinks";
15
16
 
16
17
  /** Hard cap on note nodes (docs/weave-view.md M3 guard). */
@@ -85,7 +86,14 @@ function parseRemote(raw: string): { label: string; url: string } {
85
86
  return { label: tail.replace(/\.git$/, ""), url: s };
86
87
  }
87
88
 
88
- function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNode[], edges: GraphEdge[]): string[] {
89
+ function buildVaultSide(
90
+ input: BuildGraphInput,
91
+ maxNotes: number,
92
+ nodes: GraphNode[],
93
+ edges: GraphEdge[],
94
+ danglingLinks: Record<string, string[]>,
95
+ paths: PathIndex,
96
+ ): string[] {
89
97
  const truncated = input.notes.length > maxNotes;
90
98
  const kept = input.notes.slice(0, maxNotes);
91
99
 
@@ -108,8 +116,14 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
108
116
  updated: note.updated,
109
117
  };
110
118
  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);
119
+ // The names, not just the count (§4.2). `detail` keeps carrying the count
120
+ // because it is what the TUI's side panel prints; the structured targets
121
+ // go on the model, where a UI can turn them into ghost nodes.
122
+ const dangling = links.filter((slug) => !keptSlugs.has(slug));
123
+ if (dangling.length > 0) {
124
+ detail["dangling links"] = String(dangling.length);
125
+ danglingLinks[note.slug] = dangling;
126
+ }
113
127
  detail.preview = preview(note.body);
114
128
 
115
129
  nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
@@ -117,6 +131,14 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
117
131
  for (const target of resolved) {
118
132
  edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
119
133
  }
134
+ // A note body naming a repo path → `mentions` (§4.4). Emitted after the
135
+ // wiki-links so a note's edges read vault-ward first, then code-ward, and
136
+ // only for paths that are already nodes — `paths` is built from the repo
137
+ // index, so a mention of an unindexed file resolves to its enclosing
138
+ // module or to nothing at all. Never to a phantom node.
139
+ for (const target of resolveMentions(note.body, paths)) {
140
+ edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
141
+ }
120
142
  }
121
143
  return [...keptSlugs];
122
144
  }
@@ -243,7 +265,16 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
243
265
  const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
244
266
  const nodes: GraphNode[] = [];
245
267
  const edges: GraphEdge[] = [];
246
- buildVaultSide(input, maxNotes, nodes, edges);
268
+ const danglingLinks: Record<string, string[]> = {};
269
+ // Built before the vault side, because that is where `mentions` edges are
270
+ // emitted and they need to know which repo paths are real nodes. Derived
271
+ // from the same `structure` arrays `buildRepositorySide` walks, so the two
272
+ // cannot disagree about which ids exist. Empty when there is no repository:
273
+ // a vault-only graph has nothing to mention.
274
+ const paths = input.repository === null
275
+ ? (new Map<string, string>() as PathIndex)
276
+ : buildPathIndex(input.repository.index.structure);
277
+ buildVaultSide(input, maxNotes, nodes, edges, danglingLinks, paths);
247
278
  if (input.repository !== null) {
248
279
  buildRepositorySide(input.repository, input.summaries, nodes, edges);
249
280
  }
@@ -252,7 +283,9 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
252
283
  staleness: input.repository?.staleness ?? null,
253
284
  nodes,
254
285
  edges,
286
+ danglingLinks,
255
287
  };
256
288
  }
257
289
 
258
290
  export type { EdgeKind, GraphEdge, GraphModel, GraphNode, NodeKind } from "./model";
291
+ 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
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `mentions` edges: a note body that names a repository path → note → node
3
+ * (weave-workspace §4.4, §15.5).
4
+ *
5
+ * `EdgeKind` has declared `"mentions"` since the graph model was written and
6
+ * `buildGraph` had never emitted one, so every consumer carried a branch that
7
+ * could not be taken and the legend advertised a relationship the product did
8
+ * not have. This module is the implementation side of that choice.
9
+ *
10
+ * Pure and I/O-free, like its sibling `wikilinks.ts`: a regex over a string
11
+ * and a lookup in a map built from the repo index. No parsing, no LLM, no
12
+ * new dependency (§4.4 is explicit about all three).
13
+ */
14
+
15
+ import type { RepoStructure } from "../types";
16
+
17
+ /**
18
+ * A repo-relative path candidate: two or more `/`-joined segments of word
19
+ * characters, dots and dashes.
20
+ *
21
+ * The lookbehind is the part doing real work. `(?<![\w/.@:-])` refuses to
22
+ * start a match immediately after a character that would make this the *tail*
23
+ * of something longer, which is what keeps `https://github.com/org/repo/src`
24
+ * and `user@host.com/path` from being read as repo paths. Without it every
25
+ * URL in every note becomes a candidate, and since candidates are only kept
26
+ * when they hit a real node the damage would be invisible until the day
27
+ * someone's repo happened to contain `org/repo`.
28
+ *
29
+ * At least one slash is required, so prose words are not candidates. Trailing
30
+ * sentence punctuation is stripped by {@link extractPathMentions} rather than
31
+ * excluded here, because `src/core.` at the end of a sentence should match
32
+ * `src/core` and a regex that refuses the dot cannot also accept
33
+ * `src/core/vault.ts`.
34
+ */
35
+ const PATH_RE = /(?<![\w/.@:-])([\w.-]+(?:\/[\w.-]+)+)/g;
36
+
37
+ /** Punctuation that ends a sentence rather than a path. */
38
+ const TRAILING_PUNCT_RE = /[.,;:!?)\]}'"]+$/;
39
+
40
+ /**
41
+ * Repo path → graph node id, for every path-addressable node the builder
42
+ * emits.
43
+ *
44
+ * Deliberately built from {@link RepoStructure} rather than by scanning the
45
+ * finished node list: both are derived from the same arrays, so mirroring the
46
+ * id construction here keeps the invariant that *every* value in this map is
47
+ * a node that exists, while reading it back off `GraphNode.detail.path` would
48
+ * mean re-parsing a display-only field (§4.2) to recover something structured
49
+ * we already had.
50
+ */
51
+ export type PathIndex = ReadonlyMap<string, string>;
52
+
53
+ /**
54
+ * Index the path-bearing nodes of a repository, exactly mirroring the ids
55
+ * `buildRepositorySide` emits.
56
+ *
57
+ * Insertion order is modules, then entry points, then packages, then `.okf`
58
+ * files. Later kinds do **not** overwrite earlier ones: a path that is both a
59
+ * module and an entry point resolves to the module, which is the coarser and
60
+ * more navigable of the two. The choice only has to be *stable*, and pinning
61
+ * it here is what makes it so.
62
+ *
63
+ * `(root)` is skipped. It is a display label for "files directly in the repo
64
+ * root", not a path, and no note body can spell it in a way that reaches
65
+ * here anyway.
66
+ */
67
+ export function buildPathIndex(structure: RepoStructure): PathIndex {
68
+ const index = new Map<string, string>();
69
+ const put = (path: string, id: string): void => {
70
+ if (path.length > 0 && !index.has(path)) index.set(path, id);
71
+ };
72
+ for (const mod of structure.modules) {
73
+ if (mod.path === "(root)") continue;
74
+ put(mod.path, `module:${mod.path}`);
75
+ }
76
+ for (const entry of structure.entryPoints) put(entry, `entryPoint:${entry}`);
77
+ for (const pkg of structure.packages) put(pkg.manifestPath, `package:${pkg.manifestPath}`);
78
+ // `.okf` files are addressed by their repo-relative path (".okf/…"), while
79
+ // their node ids drop the prefix — the same rename `buildRepositorySide`
80
+ // performs.
81
+ for (const file of structure.okFiles ?? []) {
82
+ put(file, `okf:${file.replace(/^\.okf\//, "")}`);
83
+ }
84
+ return index;
85
+ }
86
+
87
+ /**
88
+ * Every distinct path-like token in a note body, in order of first
89
+ * appearance, with trailing sentence punctuation and trailing slashes
90
+ * removed.
91
+ *
92
+ * Exported for its own tests: the regex is the part of this feature most
93
+ * likely to be wrong, and testing it through `buildGraph` alone would mean
94
+ * every false-positive case needed a whole repo fixture.
95
+ */
96
+ export function extractPathMentions(body: string): string[] {
97
+ const out: string[] = [];
98
+ const seen = new Set<string>();
99
+ for (const match of body.matchAll(PATH_RE)) {
100
+ // `?? ""` rather than an `undefined` guard: the group is mandatory, so it
101
+ // always participates and a guard would be a branch no input can take.
102
+ // An empty string falls out at the `includes("/")` check just below.
103
+ const path = (match[1] ?? "").replace(TRAILING_PUNCT_RE, "").replace(/\/+$/, "");
104
+ if (!path.includes("/") || seen.has(path)) continue;
105
+ seen.add(path);
106
+ out.push(path);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Resolve the repo nodes a note body mentions, as node ids in first-appearance
113
+ * order, deduplicated.
114
+ *
115
+ * ## Granularity: exact match, else the longest **enclosing** module
116
+ *
117
+ * This is the decision that keeps the edge count sane, and it runs in exactly
118
+ * one direction.
119
+ *
120
+ * *Never downward.* A note saying "see `src/core`" produces **one** edge, to
121
+ * `module:src/core`. It does not fan out to the files beneath it. Expanding a
122
+ * prefix downward is how a three-word sentence turns into forty edges and the
123
+ * graph column becomes unreadable — the explicit failure mode §4.4 warns
124
+ * about.
125
+ *
126
+ * *Upward, at most one step.* A note saying `src/core/vault.ts` is making a
127
+ * genuine reference, but most repository files are not graph nodes (only
128
+ * modules, entry points, packages and `.okf` files are), so an exact-match-only
129
+ * rule would silently drop the majority of real mentions and leave the feature
130
+ * looking broken. The fallback walks the path's ancestors longest-first and
131
+ * takes the first that is a **module**, so the mention lands on the nearest
132
+ * indexed container: `src/core/graph/build.ts` → `module:src/core`.
133
+ *
134
+ * The asymmetry is what makes this safe. Each distinct mentioned path resolves
135
+ * to **at most one** node, so the edges a note can contribute are bounded by
136
+ * the number of distinct paths it names, and the collapse only ever reduces
137
+ * that further — two files in the same module fold into a single edge because
138
+ * the result is deduplicated by target.
139
+ *
140
+ * The ancestor walk is restricted to modules on purpose: an entry point or a
141
+ * manifest is a *file*, and "this note mentions a path inside `package.json`"
142
+ * is not a relationship that exists.
143
+ */
144
+ export function resolveMentions(body: string, paths: PathIndex): string[] {
145
+ const out: string[] = [];
146
+ const seen = new Set<string>();
147
+ for (const path of extractPathMentions(body)) {
148
+ const id = paths.get(path) ?? enclosingModule(path, paths);
149
+ if (id === null || id === undefined || seen.has(id)) continue;
150
+ seen.add(id);
151
+ out.push(id);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * The nearest ancestor directory of `path` that is an indexed module, or
158
+ * `null`. Longest first, so `src/core/graph/x.ts` prefers `src/core/graph`
159
+ * over `src/core` when both are modules.
160
+ */
161
+ function enclosingModule(path: string, paths: PathIndex): string | null {
162
+ const segments = path.split("/");
163
+ for (let cut = segments.length - 1; cut > 0; cut--) {
164
+ const ancestor = segments.slice(0, cut).join("/");
165
+ const id = paths.get(ancestor);
166
+ // Only a module may absorb a mention of something inside it.
167
+ if (id !== undefined && id.startsWith("module:")) return id;
168
+ }
169
+ return null;
170
+ }