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,668 +1,21 @@
1
1
  /**
2
- * PURE view-model for the weave-view TUI (weave-view-tui-design §5, §10.1).
2
+ * TUI-specific view-model for the weave-view explorer (weave-view-tui-design
3
+ * §5, §10.1).
3
4
  *
4
- * Ports the browser page's `listTree` / `focusNeighborhood` / `deriveBacklinks`
5
- * semantics into TypeScript. Imports ONLY core types — no pi-tui imports —
6
- * so the bulk of the logic is unit-tested like the page's listTree tests.
5
+ * The portable projections (`treeRows`, `detailModel`, `focusModel`,
6
+ * `healthModel`, …) now live in `src/core/view` so the browser workspace can
7
+ * share them (weave-workspace §3). What stays here is what is genuinely about
8
+ * a terminal: the explorer's `ExplorerState`/`Action`/`reduce` state machine,
9
+ * terminal-escape sanitising, and the theme glue.
7
10
  *
8
- * Every interesting behavior is an exported pure function: `treeRows`,
9
- * `focusModel`, `detailModel`, `healthModel`, `reduce`, `sanitizeTerminalText`.
11
+ * Everything moved is re-exported below, so TUI files keep importing from
12
+ * `./model`.
10
13
  */
11
14
 
12
- import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
13
15
  import type { NoteSource } from "../../../core/types";
16
+ import type { SelectableRow, TreeRow } from "../../../core/view";
14
17
  import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
15
18
 
16
- // ---------------------------------------------------------------------------
17
- // Row types
18
- // ---------------------------------------------------------------------------
19
-
20
- /** A selectable row in any surface. `target` (when set) is the node id a row
21
- * jumps to on `enter` (a link / neighbor). */
22
- export interface SelectableRow {
23
- id: string;
24
- /** Node id this row jumps to on enter (links/backlinks/neighbors). */
25
- target?: string;
26
- }
27
-
28
- /** One row in the Explore tree. */
29
- export interface TreeRow extends SelectableRow {
30
- depth: number;
31
- hasKids: boolean;
32
- expanded: boolean;
33
- label: string;
34
- kind: NodeKind;
35
- provenance: NoteSource | null;
36
- /** Dim meta tail (note: relative updated; module: files=N; …), pre-truncated. */
37
- meta: string;
38
- }
39
-
40
- // ---------------------------------------------------------------------------
41
- // Shared pure helpers (mirror the page's `focusNeighborhood` / `deriveBacklinks`)
42
- // ---------------------------------------------------------------------------
43
-
44
- /** 1-hop neighborhood of `id` (the node itself + direct neighbors). 2-hop excluded. */
45
- export function focusNeighborhood(id: string, edges: readonly GraphEdge[]): Set<string> {
46
- const out = new Set<string>([id]);
47
- for (const e of edges) {
48
- if (e.source === id) out.add(e.target);
49
- if (e.target === id) out.add(e.source);
50
- }
51
- return out;
52
- }
53
-
54
- /** Map each node id → its incoming `links-to` sources (backlinks). */
55
- export function deriveBacklinks(edges: readonly GraphEdge[]): Map<string, string[]> {
56
- const out = new Map<string, string[]>();
57
- for (const e of edges) {
58
- if (e.kind !== "links-to") continue;
59
- const list = out.get(e.target);
60
- if (list) list.push(e.source);
61
- else out.set(e.target, [e.source]);
62
- }
63
- return out;
64
- }
65
-
66
- /** Incident-edge degree (both directions, all kinds). */
67
- export function degreeOf(id: string, edges: readonly GraphEdge[]): number {
68
- let d = 0;
69
- for (const e of edges) if (e.source === id || e.target === id) d++;
70
- return d;
71
- }
72
-
73
- /** Relative human time, mirroring the page's `relTime`. `now` is epoch ms. */
74
- export function relTime(iso: string | undefined, now: number): string {
75
- if (!iso) return "";
76
- const t = Date.parse(iso);
77
- if (Number.isNaN(t)) return "";
78
- const s = Math.max(0, Math.floor((now - t) / 1000));
79
- if (s < 60) return "just now";
80
- const m = Math.floor(s / 60);
81
- if (m < 60) return `${m}m ago`;
82
- const h = Math.floor(m / 60);
83
- if (h < 24) return `${h}h ago`;
84
- const d = Math.floor(h / 24);
85
- if (d < 30) return `${d}d ago`;
86
- const mo = Math.floor(d / 30);
87
- if (mo < 12) return `${mo}mo ago`;
88
- return `${Math.floor(mo / 12)}y ago`;
89
- }
90
-
91
- // ---------------------------------------------------------------------------
92
- // treeRows — the Explore surface (port of the page's listTree, 1:1)
93
- // ---------------------------------------------------------------------------
94
-
95
- export interface TreeState {
96
- /** Expanded node ids (when no filter is active). */
97
- expanded: ReadonlySet<string>;
98
- /** Reveal repo plumbing (gitState/external/package/entryPoint). */
99
- showInternals: boolean;
100
- /** Provenance filter (null = all). */
101
- provFilter: NoteSource | null;
102
- /** Substring filter on node labels (case-insensitive). */
103
- query: string;
104
- /** Reference time for relative-time meta (epoch ms). */
105
- now: number;
106
- }
107
-
108
- /** Disambiguate labels that would otherwise read as the same entry twice
109
- * (a remote URL whose tail matches the repo name, an npm package named
110
- * after the repo). Mirrors the page's `listLabel`. */
111
- export function listLabel(node: GraphNode): string {
112
- if (node.kind === "external" && node.detail.url) {
113
- const u = node.detail.url
114
- .replace(/^[a-z]+:\/\//, "")
115
- .replace(/^[^@\/]+@/, "")
116
- .replace(/\.git$/, "")
117
- .replace(/\/+$/, "");
118
- if (u) return u;
119
- }
120
- if (node.kind === "package" && node.detail.manifest) {
121
- return `${node.label} (${node.detail.manifest})`;
122
- }
123
- return node.label;
124
- }
125
-
126
- /** Build the contains/anchored-at nesting + entry-point-to-module map. */
127
- interface TreeIndex {
128
- byId: Map<string, GraphNode>;
129
- /** strict contains (used for entry-point → module placement). */
130
- contains: Map<string, string[]>;
131
- /** contains + anchored-at (the nesting hierarchy). */
132
- tree: Map<string, string[]>;
133
- /** node id → true when it has an incoming contains/anchored-at edge. */
134
- incoming: Set<string>;
135
- /** module id → entry-point ids nested under it (by path prefix). */
136
- moduleEntries: Map<string, string[]>;
137
- /** roots (nodes with no incoming edge), in node-input order. */
138
- roots: string[];
139
- }
140
-
141
- function indexTree(model: GraphModel): TreeIndex {
142
- const byId = new Map<string, GraphNode>();
143
- for (const n of model.nodes) byId.set(n.id, n);
144
-
145
- const contains = new Map<string, string[]>();
146
- const tree = new Map<string, string[]>();
147
- const incoming = new Set<string>();
148
- for (const e of model.edges) {
149
- if (e.kind === "contains") {
150
- const list = contains.get(e.source);
151
- if (list) list.push(e.target);
152
- else contains.set(e.source, [e.target]);
153
- }
154
- if (e.kind === "contains" || e.kind === "anchored-at") {
155
- const list = tree.get(e.source);
156
- if (list) list.push(e.target);
157
- else tree.set(e.source, [e.target]);
158
- incoming.add(e.target);
159
- }
160
- }
161
-
162
- // Entry points nest under the module whose path is the entry's directory prefix.
163
- // The caller only invokes this for entryPoint nodes, so the path guard suffices.
164
- const moduleFor = (entry: GraphNode): string | null => {
165
- if (!entry.detail.path) return null;
166
- const p = entry.detail.path;
167
- let best: string | null = null;
168
- let bestLen = 0;
169
- const repoKids = contains.get("repository") ?? [];
170
- for (const cid of repoKids) {
171
- const c = byId.get(cid);
172
- if (c && c.kind === "module" && c.detail.path) {
173
- const mp = c.detail.path;
174
- if (p.startsWith(mp + "/") && mp.length > bestLen) {
175
- best = cid;
176
- bestLen = mp.length;
177
- }
178
- }
179
- }
180
- return best;
181
- };
182
-
183
- const moduleEntries = new Map<string, string[]>();
184
- for (const n of model.nodes) {
185
- if (n.kind !== "entryPoint") continue;
186
- const m = moduleFor(n);
187
- if (m) {
188
- const list = moduleEntries.get(m);
189
- if (list) list.push(n.id);
190
- else moduleEntries.set(m, [n.id]);
191
- }
192
- }
193
-
194
- const roots = model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
195
- return { byId, contains, tree, incoming, moduleEntries, roots };
196
- }
197
-
198
- /** Knowledge-first default: hide repo plumbing unless showInternals. */
199
- function hiddenInternalKind(kind: NodeKind): boolean {
200
- return kind === "gitState" || kind === "external" || kind === "package" || kind === "entryPoint";
201
- }
202
-
203
- function sortKids(ids: string[], byId: Map<string, GraphNode>): string[] {
204
- return ids
205
- .slice()
206
- .sort((a, b) => {
207
- const la = listLabel(byId.get(a)!);
208
- const lb = listLabel(byId.get(b)!);
209
- return la.localeCompare(lb);
210
- });
211
- }
212
-
213
- function metaFor(node: GraphNode, now: number): string {
214
- switch (node.kind) {
215
- case "note":
216
- return node.detail.updated ? relTime(node.detail.updated, now) : "";
217
- case "module":
218
- return node.detail.files ? `files=${node.detail.files}` : "";
219
- case "gitState":
220
- return node.detail.commit ? node.detail.commit.slice(0, 7) : "";
221
- case "repository":
222
- return node.detail.files ? `${node.detail.files} files` : "";
223
- case "package":
224
- return node.detail.kind ?? "";
225
- case "entryPoint":
226
- return node.detail.summary ? "summary" : "";
227
- default:
228
- return "";
229
- }
230
- }
231
-
232
- /**
233
- * Build the Explore tree rows (port of the page's `listTree`).
234
- *
235
- * - Roots: `vault`, `repository` (nodes with no incoming containment edge).
236
- * - Entry points nest under their prefix module.
237
- * - Internals hidden by default; `i` toggles.
238
- * - Filter (query and/or provenance): prune to matches + ancestors, auto-expanding ancestors.
239
- * - Default expansion (no filter): controlled by `state.expanded`.
240
- */
241
- export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
242
- const idx = indexTree(model);
243
- const { byId, tree, moduleEntries, roots } = idx;
244
-
245
- const children = (id: string): string[] => {
246
- const kids = tree.get(id) ?? [];
247
- // Drop entry points that have a claiming module (they re-nest under it).
248
- const filtered = kids.filter((kid) => {
249
- const n = byId.get(kid);
250
- if (!n || n.kind !== "entryPoint") return true;
251
- for (const [, entries] of moduleEntries) {
252
- if (entries.includes(kid)) return false;
253
- }
254
- return true;
255
- });
256
- let all = filtered.concat(moduleEntries.get(id) ?? []);
257
- // Skip kids absent from the node set (corrupt/hand-built graphs): the page
258
- // never produces these, but a defensive filter keeps the tree robust.
259
- all = all.filter((k) => byId.has(k));
260
- if (!state.showInternals) {
261
- all = all.filter((k) => {
262
- const n = byId.get(k);
263
- return !(n && hiddenInternalKind(n.kind));
264
- });
265
- }
266
- return sortKids(all, byId);
267
- };
268
-
269
- const filtering = state.provFilter !== null || state.query.length > 0;
270
-
271
- const matches = (node: GraphNode): boolean => {
272
- if (state.provFilter !== null && node.provenance !== state.provFilter) return false;
273
- if (state.query.length > 0 && !listLabel(node).toLowerCase().includes(state.query.toLowerCase())) return false;
274
- return true;
275
- };
276
-
277
- // Pass 1: mark visible nodes (self-match or a visible descendant).
278
- const visible = new Set<string>();
279
- const mark = (id: string): boolean => {
280
- const node = byId.get(id)!;
281
- let any = false;
282
- for (const k of children(id)) if (mark(k)) any = true;
283
- const show = matches(node) || any;
284
- if (show) visible.add(id);
285
- return show;
286
- };
287
- for (const r of roots) mark(r);
288
-
289
- const rows: TreeRow[] = [];
290
- const walk = (id: string, depth: number): void => {
291
- if (!visible.has(id)) return;
292
- const node = byId.get(id)!;
293
- const kids = children(id);
294
- const visibleKids = kids.filter((k) => visible.has(k));
295
- const expanded = filtering ? visibleKids.length > 0 : state.expanded.has(id);
296
- rows.push({
297
- id,
298
- depth,
299
- hasKids: kids.length > 0,
300
- expanded,
301
- label: listLabel(node),
302
- kind: node.kind,
303
- provenance: node.provenance,
304
- meta: metaFor(node, state.now),
305
- });
306
- if (expanded) for (const k of visibleKids) walk(k, depth + 1);
307
- };
308
- for (const r of roots) walk(r, 0);
309
- return rows;
310
- }
311
-
312
- /** Empty-state hint rows for the tree surface (design §5.1). */
313
- export function treeEmptyHint(model: GraphModel): string | null {
314
- const vault = model.nodes.find((n) => n.kind === "vault");
315
- if (!vault) return null;
316
- // no notes and no repository
317
- const hasNotes = model.nodes.some((n) => n.kind === "note");
318
- const hasRepo = model.nodes.some((n) => n.kind === "repository");
319
- if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
320
- return null;
321
- }
322
-
323
- // ---------------------------------------------------------------------------
324
- // focusModel — the 1-hop neighborhood (port of focusNeighborhood, grouped)
325
- // ---------------------------------------------------------------------------
326
-
327
- export interface FocusRow extends SelectableRow {
328
- label: string;
329
- kind: NodeKind;
330
- provenance: NoteSource | null;
331
- }
332
-
333
- export interface FocusGroup {
334
- /** Heading label, e.g. "links to →", "← linked from", "contains", "contained by". */
335
- heading: string;
336
- rows: FocusRow[];
337
- }
338
-
339
- export interface FocusModel {
340
- center: FocusRow;
341
- groups: FocusGroup[];
342
- }
343
-
344
- const OUTGOING_HEADINGS: Record<string, string> = {
345
- "links-to": "links to →",
346
- contains: "contains",
347
- "anchored-at": "anchored at",
348
- };
349
-
350
- /** Group the 1-hop neighborhood of `id` (outgoing by kind, then incoming). */
351
- export function focusModel(model: GraphModel, id: string): FocusModel {
352
- const byId = new Map<string, GraphNode>();
353
- for (const n of model.nodes) byId.set(n.id, n);
354
-
355
- const centerNode = byId.get(id);
356
- const center: FocusRow = centerNode
357
- ? rowFromNode(centerNode)
358
- : { id, label: id, kind: "note", provenance: null };
359
-
360
- const outByKind = new Map<string, string[]>();
361
- const incomingContains = new Map<string, string[]>();
362
- const backlinks = new Map<string, string[]>();
363
- for (const e of model.edges) {
364
- if (e.source === id) {
365
- const list = outByKind.get(e.kind);
366
- if (list) list.push(e.target);
367
- else outByKind.set(e.kind, [e.target]);
368
- } else if (e.target === id) {
369
- if (e.kind === "links-to") {
370
- const list = backlinks.get(e.kind);
371
- if (list) list.push(e.source);
372
- else backlinks.set(e.kind, [e.source]);
373
- } else if (e.kind === "contains" || e.kind === "anchored-at") {
374
- const list = incomingContains.get(e.kind);
375
- if (list) list.push(e.source);
376
- else incomingContains.set(e.kind, [e.source]);
377
- }
378
- }
379
- }
380
-
381
- const toRows = (ids: string[]): FocusRow[] =>
382
- ids
383
- .map((x) => byId.get(x))
384
- .filter((n): n is GraphNode => n !== undefined)
385
- .map(rowFromNode);
386
-
387
- const groups: FocusGroup[] = [];
388
- for (const kind of ["links-to", "contains", "anchored-at"] as const) {
389
- const ids = outByKind.get(kind) ?? [];
390
- if (ids.length === 0) continue;
391
- groups.push({ heading: OUTGOING_HEADINGS[kind] ?? kind, rows: toRows(ids) });
392
- }
393
- const bl = backlinks.get("links-to") ?? [];
394
- if (bl.length > 0) groups.push({ heading: "← linked from", rows: toRows(bl) });
395
- const containedBy = incomingContains.get("contains") ?? incomingContains.get("anchored-at") ?? [];
396
- if (containedBy.length > 0) groups.push({ heading: "contained by", rows: toRows(containedBy) });
397
-
398
- return { center, groups };
399
- }
400
-
401
- function rowFromNode(n: GraphNode): FocusRow {
402
- return { id: n.id, target: n.id, label: listLabel(n), kind: n.kind, provenance: n.provenance };
403
- }
404
-
405
- // ---------------------------------------------------------------------------
406
- // detailModel — selected node meta + links + backlinks (body loaded async)
407
- // ---------------------------------------------------------------------------
408
-
409
- export interface DetailMetaRow extends SelectableRow {
410
- label: string;
411
- value: string;
412
- }
413
-
414
- export interface DetailLinkRow extends SelectableRow {
415
- label: string;
416
- kind: NodeKind;
417
- provenance: NoteSource | null;
418
- /** "link" (outgoing) or "backlink" (incoming). */
419
- direction: "link" | "backlink";
420
- }
421
-
422
- export interface DetailModel {
423
- id: string;
424
- label: string;
425
- kind: NodeKind;
426
- provenance: NoteSource | null;
427
- meta: DetailMetaRow[];
428
- links: DetailLinkRow[];
429
- backlinks: DetailLinkRow[];
430
- }
431
-
432
- /** Ordered meta keys shown in the detail header (design §5.2). */
433
- const META_ORDER = [
434
- "path",
435
- "slug",
436
- "source",
437
- "updated",
438
- "created",
439
- "tags",
440
- "files",
441
- "languages",
442
- "branch",
443
- "commit",
444
- "uncommitted changes",
445
- "captured",
446
- "manifest",
447
- "kind",
448
- "url",
449
- "summarized files",
450
- "summarized by",
451
- "summarized at",
452
- "summary",
453
- "dangling links",
454
- "warning",
455
- "stale",
456
- "preview",
457
- "root",
458
- "notes",
459
- "state",
460
- ] as const;
461
-
462
- export function detailModel(model: GraphModel, id: string): DetailModel | null {
463
- const byId = new Map<string, GraphNode>();
464
- for (const n of model.nodes) byId.set(n.id, n);
465
- const node = byId.get(id);
466
- if (!node) return null;
467
-
468
- const meta: DetailMetaRow[] = [];
469
- for (const key of META_ORDER) {
470
- const v = node.detail[key];
471
- if (v === undefined || v === "") continue;
472
- meta.push({ id: `meta:${key}`, label: key, value: v });
473
- }
474
-
475
- const links: DetailLinkRow[] = [];
476
- for (const e of model.edges) {
477
- if (e.source !== id) continue;
478
- const t = byId.get(e.target);
479
- if (!t) continue;
480
- links.push({
481
- id: `link:${e.kind}:${e.target}`,
482
- target: e.target,
483
- label: `${e.kind} → ${listLabel(t)}`,
484
- kind: t.kind,
485
- provenance: t.provenance,
486
- direction: "link",
487
- });
488
- }
489
-
490
- const backlinks: DetailLinkRow[] = [];
491
- for (const e of model.edges) {
492
- if (e.kind !== "links-to" || e.target !== id) continue;
493
- const s = byId.get(e.source);
494
- if (!s) continue;
495
- backlinks.push({
496
- id: `backlink:${e.source}`,
497
- target: e.source,
498
- label: `← ${listLabel(s)}`,
499
- kind: s.kind,
500
- provenance: s.provenance,
501
- direction: "backlink",
502
- });
503
- }
504
-
505
- return {
506
- id,
507
- label: listLabel(node),
508
- kind: node.kind,
509
- provenance: node.provenance,
510
- meta,
511
- links,
512
- backlinks,
513
- };
514
- }
515
-
516
- // ---------------------------------------------------------------------------
517
- // healthModel — staleness + link health, derived exclusively from GraphModel
518
- // ---------------------------------------------------------------------------
519
-
520
- export interface HealthRow extends SelectableRow {
521
- text: string;
522
- }
523
-
524
- export interface HealthSection {
525
- heading: string;
526
- rows: HealthRow[];
527
- }
528
-
529
- export interface HealthModel {
530
- sections: HealthSection[];
531
- }
532
-
533
- const HEALTH_LIST_CAP = 10;
534
-
535
- /** Staleness + link health, derived exclusively from the GraphModel
536
- * (design §5.4 — mirrors v2 §7: zero new server/core fields). */
537
- export function healthModel(model: GraphModel): HealthModel {
538
- const byId = new Map<string, GraphNode>();
539
- for (const n of model.nodes) byId.set(n.id, n);
540
- const sections: HealthSection[] = [];
541
-
542
- // Repository section
543
- const repo = byId.get("repository");
544
- if (repo) {
545
- const rows: HealthRow[] = [];
546
- const staleness = model.staleness;
547
- if (staleness) {
548
- rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
549
- for (let i = 0; i < staleness.reasons.length; i++) {
550
- rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
551
- }
552
- }
553
- if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
554
- if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
555
- const summarized = model.nodes
556
- .filter((n) => n.kind === "module" && n.detail["summarized files"])
557
- .reduce((acc, n) => acc + Number(n.detail["summarized files"] ?? 0), 0);
558
- if (summarized > 0) {
559
- rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
560
- }
561
- if (repo.detail.state === "stale") {
562
- // already covered by staleness reasons; no extra row
563
- }
564
- sections.push({ heading: "Repository", rows });
565
- }
566
-
567
- // Vault section
568
- const vault = byId.get("vault");
569
- if (vault) {
570
- const rows: HealthRow[] = [];
571
- const noteCount = Number(vault.detail.notes ?? "0");
572
- rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
573
- const prov = countProvenance(model.nodes);
574
- rows.push({
575
- id: "health:vault:provenance",
576
- text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
577
- });
578
- if (vault.detail.warning) {
579
- rows.push({ id: "health:vault:warning", text: vault.detail.warning });
580
- }
581
- sections.push({ heading: "Vault", rows });
582
- }
583
-
584
- // Link health
585
- const backlinks = deriveBacklinks(model.edges);
586
- const orphans: GraphNode[] = [];
587
- const dangling: { node: GraphNode; count: number }[] = [];
588
- for (const n of model.nodes) {
589
- if (n.kind !== "note") continue;
590
- if (!backlinks.has(n.id)) orphans.push(n);
591
- const dl = n.detail["dangling links"];
592
- if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
593
- }
594
- const degree = new Map<string, number>();
595
- for (const e of model.edges) {
596
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
597
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
598
- }
599
- const hubs = [...model.nodes]
600
- .map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
601
- .filter((x) => x.d > 0)
602
- .sort((a, b) => b.d - a.d)
603
- .slice(0, HEALTH_LIST_CAP);
604
-
605
- const linkRows: HealthRow[] = [];
606
- if (orphans.length > 0) {
607
- linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
608
- const shown = orphans.slice(0, HEALTH_LIST_CAP);
609
- for (let i = 0; i < shown.length; i++) {
610
- linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
611
- }
612
- if (orphans.length > HEALTH_LIST_CAP) {
613
- linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
614
- }
615
- } else {
616
- linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
617
- }
618
- if (dangling.length > 0) {
619
- linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
620
- const shown = dangling.slice(0, HEALTH_LIST_CAP);
621
- for (let i = 0; i < shown.length; i++) {
622
- linkRows.push({
623
- id: `health:link:dangling:${shown[i]!.node.id}`,
624
- text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
625
- target: shown[i]!.node.id,
626
- });
627
- }
628
- if (dangling.length > HEALTH_LIST_CAP) {
629
- linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
630
- }
631
- }
632
- if (hubs.length > 0) {
633
- linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
634
- for (let i = 0; i < hubs.length; i++) {
635
- linkRows.push({
636
- id: `health:link:hub:${hubs[i]!.n.id}`,
637
- text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
638
- target: hubs[i]!.n.id,
639
- });
640
- }
641
- }
642
- sections.push({ heading: "Link health", rows: linkRows });
643
-
644
- return { sections };
645
- }
646
-
647
- export interface ProvenanceCounts {
648
- total: number;
649
- human: number;
650
- agent: number;
651
- generated: number;
652
- structural: number;
653
- }
654
-
655
- export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
656
- const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
657
- for (const n of nodes) {
658
- if (n.provenance === "human") c.human++;
659
- else if (n.provenance === "agent") c.agent++;
660
- else if (n.provenance === "generated") c.generated++;
661
- else c.structural++;
662
- }
663
- return c;
664
- }
665
-
666
19
  // ---------------------------------------------------------------------------
667
20
  // sanitizeTerminalText — the TUI's XSS analog (design §9.3)
668
21
  // ---------------------------------------------------------------------------
@@ -725,15 +78,6 @@ export function initialState(roots: readonly string[]): ExplorerState {
725
78
  };
726
79
  }
727
80
 
728
- /** The roots of a graph (nodes with no incoming contains/anchored-at edge). */
729
- export function graphRoots(model: GraphModel): string[] {
730
- const incoming = new Set<string>();
731
- for (const e of model.edges) {
732
- if (e.kind === "contains" || e.kind === "anchored-at") incoming.add(e.target);
733
- }
734
- return model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
735
- }
736
-
737
81
  export type Action =
738
82
  | { type: "up" }
739
83
  | { type: "down" }
@@ -1007,5 +351,40 @@ export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly strin
1007
351
  };
1008
352
  }
1009
353
 
354
+ // ---------------------------------------------------------------------------
355
+ // Re-exports — the portable view-models, kept importable from `./model` so no
356
+ // TUI file needs to know they moved to src/core/view.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export {
360
+ countProvenance,
361
+ degreeOf,
362
+ deriveBacklinks,
363
+ detailModel,
364
+ focusModel,
365
+ focusNeighborhood,
366
+ formatTreeMeta,
367
+ graphRoots,
368
+ healthModel,
369
+ listLabel,
370
+ relTime,
371
+ treeEmptyHint,
372
+ treeRows,
373
+ type DetailLinkRow,
374
+ type DetailMetaRow,
375
+ type DetailModel,
376
+ type FocusGroup,
377
+ type FocusModel,
378
+ type FocusRow,
379
+ type HealthModel,
380
+ type HealthRow,
381
+ type HealthSection,
382
+ type ProvenanceCounts,
383
+ type SelectableRow,
384
+ type TreeMeta,
385
+ type TreeRow,
386
+ type TreeState,
387
+ } from "../../../core/view";
388
+
1010
389
  // Re-export theme helpers some tests reach for.
1011
- export { provenanceStyle, type ProvenanceStyle };
390
+ export { provenanceStyle, type ProvenanceStyle };