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,262 @@
1
+ /**
2
+ * clusterAggregate — collapse containment subtrees into their parent node
3
+ * (weave-workspace §3, §7.4).
4
+ *
5
+ * A repository graph is mostly containment: a handful of roots, then modules,
6
+ * then hundreds or thousands of files. Drawn flat that is a hairball, and
7
+ * scrolled flat it is a wall. The fix is *graph reduction*, not rendering:
8
+ * given the model and the set of ids the user has expanded, produce a smaller
9
+ * graph where every collapsed subtree is represented by the node that contains
10
+ * it, with the edges that crossed the boundary retargeted onto that node.
11
+ *
12
+ * Because it is reduction, it lives in core and is shared: the sigma graph
13
+ * panel (§7.4) consumes `nodes`/`edges` directly, and the TUI tree can consume
14
+ * the same `clusters` map for its child counts and provenance rollups. One
15
+ * implementation, so the two surfaces cannot disagree about what "collapsed"
16
+ * means.
17
+ *
18
+ * ## Relationship to the retired viewer
19
+ *
20
+ * The retired `page.ts` had a function of the same name. It computed a
21
+ * *visibility mask* — every node stayed in the graph and hidden ones were
22
+ * given `display: none` — and it never rewrote a single edge, so a link from a
23
+ * hidden file to a visible note simply vanished. Two deliberate changes:
24
+ *
25
+ * 1. **Reduction, not masking.** We return the reduced node and edge arrays.
26
+ * A renderer should never be handed nodes it is expected to hide, and
27
+ * sigma in particular wants a graph it can consume verbatim (§7.1).
28
+ * 2. **Strict collapse.** The retired `reveal` recursed into *every* child
29
+ * cluster regardless of the expand set, gating only leaves — so collapsing
30
+ * `src/core` still drew all of its sub-clusters. That contradicts both the
31
+ * word "collapse" and {@link treeRows}, which recurses only into expanded
32
+ * nodes. Here a collapsed cluster hides its entire subtree, so the TUI tree
33
+ * and the web graph answer "what is visible?" identically.
34
+ *
35
+ * Retained from the original: containment means `contains` **or**
36
+ * `anchored-at`; clusters are real model nodes rather than synthetic ones (so
37
+ * ids stay stable and selection keeps working); and the per-cluster provenance
38
+ * rollup over all descendants.
39
+ *
40
+ * The original's `expandChildren` / `collapseChildren` set builders are *not*
41
+ * ported. They only existed to walk the model a second time, which
42
+ * {@link ClusterInfo.descendants} already did, and each is now one expression
43
+ * on the caller's side:
44
+ *
45
+ * ```ts
46
+ * const under = agg.clusters.get(id)!.descendants.filter((d) => agg.clusters.has(d));
47
+ * const expandAll = new Set([...expanded, id, ...under]);
48
+ * const collapse = new Set([...expanded].filter((e) => e !== id && !under.includes(e)));
49
+ * ```
50
+ */
51
+
52
+ import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "../graph/model";
53
+ import type { NoteSource } from "../types";
54
+ import { graphRoots } from "./tree";
55
+
56
+ /** The edge kinds that form the nesting hierarchy. Matches `treeRows`. */
57
+ const CONTAINMENT: readonly EdgeKind[] = ["contains", "anchored-at"];
58
+
59
+ function isContainment(kind: EdgeKind): boolean {
60
+ return CONTAINMENT.includes(kind);
61
+ }
62
+
63
+ /** Provenance rollup over a cluster's descendants (the ring / mini-bar source). */
64
+ export interface ProvenanceSplit {
65
+ human: number;
66
+ agent: number;
67
+ generated: number;
68
+ /** Descendants with no provenance — modules, files, git state, packages. */
69
+ none: number;
70
+ }
71
+
72
+ /** What is known about one cluster: any node with at least one containment child. */
73
+ export interface ClusterInfo {
74
+ /** Direct containment children, de-duplicated, in model edge order. */
75
+ readonly children: readonly string[];
76
+ /** Every transitive containment descendant, de-duplicated, depth-first. */
77
+ readonly descendants: readonly string[];
78
+ /**
79
+ * The hidden descendants this cluster is currently standing in for — the
80
+ * cluster → members direction of the mapping, and what a "12 files" badge
81
+ * counts.
82
+ *
83
+ * Empty unless the cluster is *both* visible and collapsed: an expanded
84
+ * cluster hides nothing, and a cluster that is itself hidden is already
85
+ * represented by an ancestor. So `members` partitions {@link
86
+ * ClusterAggregate.hidden} across the visible clusters, whereas
87
+ * {@link descendants} overlaps freely between a cluster and its ancestors.
88
+ */
89
+ readonly members: readonly string[];
90
+ /** Provenance counts over {@link descendants} (the cluster itself excluded). */
91
+ readonly provenance: ProvenanceSplit;
92
+ /** Whether `expanded` contained this id. */
93
+ readonly expanded: boolean;
94
+ }
95
+
96
+ export interface ClusterAggregate {
97
+ /** The visible nodes, in model order. A subset of `model.nodes`. */
98
+ nodes: GraphNode[];
99
+ /**
100
+ * Edges between visible nodes, in model order. Endpoints inside a collapsed
101
+ * cluster are retargeted onto that cluster; the resulting duplicates and
102
+ * self-loops are dropped.
103
+ */
104
+ edges: GraphEdge[];
105
+ /**
106
+ * Every node with at least one containment child, whether or not it is
107
+ * visible or expanded — a caller implementing "expand all" filters
108
+ * `descendants` through this map.
109
+ */
110
+ clusters: Map<string, ClusterInfo>;
111
+ /** Ids not present in {@link nodes}. */
112
+ hidden: Set<string>;
113
+ /**
114
+ * Hidden id → the visible cluster representing it. The member → cluster
115
+ * direction of the mapping; the inverse of {@link ClusterInfo.members}.
116
+ */
117
+ representative: Map<string, string>;
118
+ /** Containment roots, in model order (always visible). */
119
+ roots: string[];
120
+ }
121
+
122
+ /**
123
+ * Reduce `model` to the subgraph implied by `expanded`.
124
+ *
125
+ * A node is visible when every containment ancestor between it and a root is
126
+ * expanded. Roots are always visible, so an empty `expanded` set yields the
127
+ * roots alone — the maximally collapsed view.
128
+ *
129
+ * Deterministic for a deterministic model: every output is ordered by the
130
+ * model's own node and edge order, and a node reachable through more than one
131
+ * parent is attributed to the first one encountered.
132
+ *
133
+ * Defensive on malformed input, which core cannot rule out for a hand-written
134
+ * or partially-rebuilt `.okf` index: containment cycles terminate, and an edge
135
+ * naming an id that is not in the node set is dropped rather than emitted (a
136
+ * renderer given an edge to a non-existent node throws).
137
+ */
138
+ export function clusterAggregate(model: GraphModel, expanded: ReadonlySet<string>): ClusterAggregate {
139
+ const byId = new Map<string, GraphNode>();
140
+ for (const n of model.nodes) byId.set(n.id, n);
141
+
142
+ const children = indexChildren(model, byId);
143
+ const roots = graphRoots(model);
144
+
145
+ // Visible = reachable from a root without passing through a collapsed node.
146
+ const visible = new Set<string>();
147
+ const reveal = (id: string): void => {
148
+ if (visible.has(id)) return; // also the cycle guard
149
+ visible.add(id);
150
+ if (!expanded.has(id)) return;
151
+ for (const child of children.get(id) ?? []) reveal(child);
152
+ };
153
+ for (const r of roots) reveal(r);
154
+
155
+ // Descendants, computed once per cluster and reused for members + provenance.
156
+ const descendantsOf = new Map<string, string[]>();
157
+ for (const id of children.keys()) descendantsOf.set(id, descendants(id, children));
158
+
159
+ // Hidden id → nearest visible ancestor. Visible collapsed clusters are the
160
+ // only things that can represent anything; walking them in model order and
161
+ // never overwriting makes multi-parent attribution deterministic.
162
+ const representative = new Map<string, string>();
163
+ for (const n of model.nodes) {
164
+ if (!visible.has(n.id) || expanded.has(n.id)) continue;
165
+ for (const d of descendantsOf.get(n.id) ?? []) {
166
+ if (!visible.has(d) && !representative.has(d)) representative.set(d, n.id);
167
+ }
168
+ }
169
+
170
+ // A containment cycle with no root above it is reachable from nowhere, so it
171
+ // is neither visible nor represented. Show it: dropping it silently would
172
+ // lose data the caller has no other way to see.
173
+ for (const n of model.nodes) {
174
+ if (!visible.has(n.id) && !representative.has(n.id)) visible.add(n.id);
175
+ }
176
+
177
+ const clusters = new Map<string, ClusterInfo>();
178
+ for (const [id, kids] of children) {
179
+ // Populated from `children.keys()` immediately above, so this is total.
180
+ const desc = descendantsOf.get(id)!;
181
+ clusters.set(id, {
182
+ children: kids,
183
+ descendants: desc,
184
+ members: desc.filter((d) => representative.get(d) === id),
185
+ provenance: splitOf(desc, byId),
186
+ expanded: expanded.has(id),
187
+ });
188
+ }
189
+
190
+ const hidden = new Set<string>();
191
+ const nodes: GraphNode[] = [];
192
+ for (const n of model.nodes) {
193
+ if (visible.has(n.id)) nodes.push(n);
194
+ else hidden.add(n.id);
195
+ }
196
+
197
+ return { nodes, edges: rewrite(model.edges, visible, representative), clusters, hidden, representative, roots };
198
+ }
199
+
200
+ /** Direct containment children per parent, de-duplicated, in model edge order. */
201
+ function indexChildren(model: GraphModel, byId: Map<string, GraphNode>): Map<string, string[]> {
202
+ const out = new Map<string, string[]>();
203
+ for (const e of model.edges) {
204
+ if (!isContainment(e.kind)) continue;
205
+ // An edge to an id that is not a node cannot be collapsed into anything.
206
+ if (!byId.has(e.source) || !byId.has(e.target)) continue;
207
+ const list = out.get(e.source);
208
+ if (list === undefined) out.set(e.source, [e.target]);
209
+ else if (!list.includes(e.target)) list.push(e.target);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /** Transitive containment descendants of `id`, depth-first, cycle-safe. */
215
+ function descendants(id: string, children: Map<string, string[]>): string[] {
216
+ const out: string[] = [];
217
+ const seen = new Set<string>([id]);
218
+ const walk = (at: string): void => {
219
+ for (const child of children.get(at) ?? []) {
220
+ if (seen.has(child)) continue;
221
+ seen.add(child);
222
+ out.push(child);
223
+ walk(child);
224
+ }
225
+ };
226
+ walk(id);
227
+ return out;
228
+ }
229
+
230
+ function splitOf(ids: readonly string[], byId: Map<string, GraphNode>): ProvenanceSplit {
231
+ const split: ProvenanceSplit = { human: 0, agent: 0, generated: 0, none: 0 };
232
+ for (const id of ids) {
233
+ const provenance: NoteSource | null = byId.get(id)?.provenance ?? null;
234
+ if (provenance === null) split.none++;
235
+ else split[provenance]++;
236
+ }
237
+ return split;
238
+ }
239
+
240
+ /**
241
+ * Retarget each edge onto visible endpoints, then drop the self-loops (both
242
+ * ends fell into the same cluster) and the duplicates (many crossings of the
243
+ * same boundary collapse to one). Kind is preserved and participates in the
244
+ * de-duplication, so a `contains` and a `links-to` between the same pair both
245
+ * survive.
246
+ */
247
+ function rewrite(edges: readonly GraphEdge[], visible: ReadonlySet<string>, representative: ReadonlyMap<string, string>): GraphEdge[] {
248
+ const resolve = (id: string): string | undefined => (visible.has(id) ? id : representative.get(id));
249
+ const out: GraphEdge[] = [];
250
+ const seen = new Set<string>();
251
+ for (const e of edges) {
252
+ const source = resolve(e.source);
253
+ const target = resolve(e.target);
254
+ if (source === undefined || target === undefined) continue;
255
+ if (source === target) continue;
256
+ const key = `${source}\u0000${target}\u0000${e.kind}`;
257
+ if (seen.has(key)) continue;
258
+ seen.add(key);
259
+ out.push({ source, target, kind: e.kind });
260
+ }
261
+ return out;
262
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * detailModel — a selected node's meta + links + backlinks (weave-workspace §3).
3
+ *
4
+ * The note body is *not* part of this model: it is loaded async by whichever
5
+ * renderer needs it (`readNoteForView` / `readOkfFileForView`).
6
+ */
7
+
8
+ import type { GraphModel, GraphNode, NodeKind } from "../graph/model";
9
+ import type { NoteSource } from "../types";
10
+ import { listLabel } from "./tree";
11
+ import type { SelectableRow } from "./types";
12
+
13
+ export interface DetailMetaRow extends SelectableRow {
14
+ label: string;
15
+ value: string;
16
+ }
17
+
18
+ export interface DetailLinkRow extends SelectableRow {
19
+ label: string;
20
+ kind: NodeKind;
21
+ provenance: NoteSource | null;
22
+ /** "link" (outgoing) or "backlink" (incoming). */
23
+ direction: "link" | "backlink";
24
+ }
25
+
26
+ export interface DetailModel {
27
+ id: string;
28
+ label: string;
29
+ kind: NodeKind;
30
+ provenance: NoteSource | null;
31
+ meta: DetailMetaRow[];
32
+ links: DetailLinkRow[];
33
+ backlinks: DetailLinkRow[];
34
+ }
35
+
36
+ /** Ordered meta keys shown in the detail header (weave-view-tui-design §5.2). */
37
+ const META_ORDER = [
38
+ "path",
39
+ "slug",
40
+ "source",
41
+ "updated",
42
+ "created",
43
+ "tags",
44
+ "files",
45
+ "languages",
46
+ "branch",
47
+ "commit",
48
+ "uncommitted changes",
49
+ "captured",
50
+ "manifest",
51
+ "kind",
52
+ "url",
53
+ "summarized files",
54
+ "summarized by",
55
+ "summarized at",
56
+ "summary",
57
+ "dangling links",
58
+ "warning",
59
+ "stale",
60
+ "preview",
61
+ "root",
62
+ "notes",
63
+ "state",
64
+ ] as const;
65
+
66
+ export function detailModel(model: GraphModel, id: string): DetailModel | null {
67
+ const byId = new Map<string, GraphNode>();
68
+ for (const n of model.nodes) byId.set(n.id, n);
69
+ const node = byId.get(id);
70
+ if (!node) return null;
71
+
72
+ const meta: DetailMetaRow[] = [];
73
+ for (const key of META_ORDER) {
74
+ const v = node.detail[key];
75
+ if (v === undefined || v === "") continue;
76
+ meta.push({ id: `meta:${key}`, label: key, value: v });
77
+ }
78
+
79
+ const links: DetailLinkRow[] = [];
80
+ for (const e of model.edges) {
81
+ if (e.source !== id) continue;
82
+ const t = byId.get(e.target);
83
+ if (!t) continue;
84
+ links.push({
85
+ id: `link:${e.kind}:${e.target}`,
86
+ target: e.target,
87
+ label: `${e.kind} → ${listLabel(t)}`,
88
+ kind: t.kind,
89
+ provenance: t.provenance,
90
+ direction: "link",
91
+ });
92
+ }
93
+
94
+ const backlinks: DetailLinkRow[] = [];
95
+ for (const e of model.edges) {
96
+ if (e.kind !== "links-to" || e.target !== id) continue;
97
+ const s = byId.get(e.source);
98
+ if (!s) continue;
99
+ backlinks.push({
100
+ id: `backlink:${e.source}`,
101
+ target: e.source,
102
+ label: `← ${listLabel(s)}`,
103
+ kind: s.kind,
104
+ provenance: s.provenance,
105
+ direction: "backlink",
106
+ });
107
+ }
108
+
109
+ return {
110
+ id,
111
+ label: listLabel(node),
112
+ kind: node.kind,
113
+ provenance: node.provenance,
114
+ meta,
115
+ links,
116
+ backlinks,
117
+ };
118
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * focusModel — the 1-hop neighborhood of a node, grouped (weave-workspace §3).
3
+ *
4
+ * The TUI renders this as the Focus surface; the browser workspace uses
5
+ * `focusNeighborhood` for the graph's neighborhood highlight.
6
+ */
7
+
8
+ import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../graph/model";
9
+ import type { NoteSource } from "../types";
10
+ import { listLabel } from "./tree";
11
+ import type { SelectableRow } from "./types";
12
+
13
+ /** 1-hop neighborhood of `id` (the node itself + direct neighbors). 2-hop excluded. */
14
+ export function focusNeighborhood(id: string, edges: readonly GraphEdge[]): Set<string> {
15
+ const out = new Set<string>([id]);
16
+ for (const e of edges) {
17
+ if (e.source === id) out.add(e.target);
18
+ if (e.target === id) out.add(e.source);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /** Incident-edge degree (both directions, all kinds). */
24
+ export function degreeOf(id: string, edges: readonly GraphEdge[]): number {
25
+ let d = 0;
26
+ for (const e of edges) if (e.source === id || e.target === id) d++;
27
+ return d;
28
+ }
29
+
30
+ export interface FocusRow extends SelectableRow {
31
+ label: string;
32
+ kind: NodeKind;
33
+ provenance: NoteSource | null;
34
+ }
35
+
36
+ export interface FocusGroup {
37
+ /** Heading label, e.g. "links to →", "← linked from", "contains", "contained by". */
38
+ heading: string;
39
+ rows: FocusRow[];
40
+ }
41
+
42
+ export interface FocusModel {
43
+ center: FocusRow;
44
+ groups: FocusGroup[];
45
+ }
46
+
47
+ /** The outgoing edge kinds that get their own group, in display order. */
48
+ const OUTGOING_KINDS = ["links-to", "contains", "anchored-at"] as const;
49
+
50
+ const OUTGOING_HEADINGS: Record<(typeof OUTGOING_KINDS)[number], string> = {
51
+ "links-to": "links to →",
52
+ contains: "contains",
53
+ "anchored-at": "anchored at",
54
+ };
55
+
56
+ /** Group the 1-hop neighborhood of `id` (outgoing by kind, then incoming). */
57
+ export function focusModel(model: GraphModel, id: string): FocusModel {
58
+ const byId = new Map<string, GraphNode>();
59
+ for (const n of model.nodes) byId.set(n.id, n);
60
+
61
+ const centerNode = byId.get(id);
62
+ const center: FocusRow = centerNode
63
+ ? rowFromNode(centerNode)
64
+ : { id, label: id, kind: "note", provenance: null };
65
+
66
+ const outByKind = new Map<string, string[]>();
67
+ const incomingContains = new Map<string, string[]>();
68
+ const backlinks = new Map<string, string[]>();
69
+ for (const e of model.edges) {
70
+ if (e.source === id) {
71
+ const list = outByKind.get(e.kind);
72
+ if (list) list.push(e.target);
73
+ else outByKind.set(e.kind, [e.target]);
74
+ } else if (e.target === id) {
75
+ if (e.kind === "links-to") {
76
+ const list = backlinks.get(e.kind);
77
+ if (list) list.push(e.source);
78
+ else backlinks.set(e.kind, [e.source]);
79
+ } else if (e.kind === "contains" || e.kind === "anchored-at") {
80
+ const list = incomingContains.get(e.kind);
81
+ if (list) list.push(e.source);
82
+ else incomingContains.set(e.kind, [e.source]);
83
+ }
84
+ }
85
+ }
86
+
87
+ const toRows = (ids: string[]): FocusRow[] =>
88
+ ids
89
+ .map((x) => byId.get(x))
90
+ .filter((n): n is GraphNode => n !== undefined)
91
+ .map(rowFromNode);
92
+
93
+ const groups: FocusGroup[] = [];
94
+ for (const kind of OUTGOING_KINDS) {
95
+ const ids = outByKind.get(kind) ?? [];
96
+ if (ids.length === 0) continue;
97
+ groups.push({ heading: OUTGOING_HEADINGS[kind], rows: toRows(ids) });
98
+ }
99
+ const bl = backlinks.get("links-to") ?? [];
100
+ if (bl.length > 0) groups.push({ heading: "← linked from", rows: toRows(bl) });
101
+ const containedBy = incomingContains.get("contains") ?? incomingContains.get("anchored-at") ?? [];
102
+ if (containedBy.length > 0) groups.push({ heading: "contained by", rows: toRows(containedBy) });
103
+
104
+ return { center, groups };
105
+ }
106
+
107
+ function rowFromNode(n: GraphNode): FocusRow {
108
+ return { id: n.id, target: n.id, label: listLabel(n), kind: n.kind, provenance: n.provenance };
109
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * healthModel — staleness + link health, derived exclusively from the
3
+ * GraphModel (weave-view-tui-design §5.4 / weave-workspace §3).
4
+ *
5
+ * Zero new server/core fields: everything here is a projection of nodes,
6
+ * edges, and `model.staleness`.
7
+ */
8
+
9
+ import type { GraphModel, GraphNode } from "../graph/model";
10
+ import { deriveBacklinks } from "./links";
11
+ import { listLabel } from "./tree";
12
+ import type { SelectableRow } from "./types";
13
+
14
+ export interface HealthRow extends SelectableRow {
15
+ text: string;
16
+ }
17
+
18
+ export interface HealthSection {
19
+ heading: string;
20
+ rows: HealthRow[];
21
+ }
22
+
23
+ export interface HealthModel {
24
+ sections: HealthSection[];
25
+ }
26
+
27
+ const HEALTH_LIST_CAP = 10;
28
+
29
+ export function healthModel(model: GraphModel): HealthModel {
30
+ const byId = new Map<string, GraphNode>();
31
+ for (const n of model.nodes) byId.set(n.id, n);
32
+ const sections: HealthSection[] = [];
33
+
34
+ // Repository section
35
+ const repo = byId.get("repository");
36
+ if (repo) {
37
+ const rows: HealthRow[] = [];
38
+ const staleness = model.staleness;
39
+ if (staleness) {
40
+ rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
41
+ for (let i = 0; i < staleness.reasons.length; i++) {
42
+ rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
43
+ }
44
+ }
45
+ if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
46
+ if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
47
+ let summarized = 0;
48
+ for (const n of model.nodes) {
49
+ if (n.kind !== "module") continue;
50
+ const count = n.detail["summarized files"];
51
+ if (count) summarized += Number(count);
52
+ }
53
+ if (summarized > 0) {
54
+ rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
55
+ }
56
+ sections.push({ heading: "Repository", rows });
57
+ }
58
+
59
+ // Vault section
60
+ const vault = byId.get("vault");
61
+ if (vault) {
62
+ const rows: HealthRow[] = [];
63
+ const noteCount = Number(vault.detail.notes ?? "0");
64
+ rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
65
+ const prov = countProvenance(model.nodes);
66
+ rows.push({
67
+ id: "health:vault:provenance",
68
+ text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
69
+ });
70
+ if (vault.detail.warning) {
71
+ rows.push({ id: "health:vault:warning", text: vault.detail.warning });
72
+ }
73
+ sections.push({ heading: "Vault", rows });
74
+ }
75
+
76
+ // Link health
77
+ const backlinks = deriveBacklinks(model.edges);
78
+ const orphans: GraphNode[] = [];
79
+ const dangling: { node: GraphNode; count: number }[] = [];
80
+ for (const n of model.nodes) {
81
+ if (n.kind !== "note") continue;
82
+ if (!backlinks.has(n.id)) orphans.push(n);
83
+ const dl = n.detail["dangling links"];
84
+ if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
85
+ }
86
+ const degree = new Map<string, number>();
87
+ for (const e of model.edges) {
88
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
89
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
90
+ }
91
+ const hubs = [...model.nodes]
92
+ .map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
93
+ .filter((x) => x.d > 0)
94
+ .sort((a, b) => b.d - a.d)
95
+ .slice(0, HEALTH_LIST_CAP);
96
+
97
+ const linkRows: HealthRow[] = [];
98
+ if (orphans.length > 0) {
99
+ linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
100
+ const shown = orphans.slice(0, HEALTH_LIST_CAP);
101
+ for (let i = 0; i < shown.length; i++) {
102
+ linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
103
+ }
104
+ if (orphans.length > HEALTH_LIST_CAP) {
105
+ linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
106
+ }
107
+ } else {
108
+ linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
109
+ }
110
+ if (dangling.length > 0) {
111
+ linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
112
+ const shown = dangling.slice(0, HEALTH_LIST_CAP);
113
+ for (let i = 0; i < shown.length; i++) {
114
+ linkRows.push({
115
+ id: `health:link:dangling:${shown[i]!.node.id}`,
116
+ text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
117
+ target: shown[i]!.node.id,
118
+ });
119
+ }
120
+ if (dangling.length > HEALTH_LIST_CAP) {
121
+ linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
122
+ }
123
+ }
124
+ if (hubs.length > 0) {
125
+ linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
126
+ for (let i = 0; i < hubs.length; i++) {
127
+ linkRows.push({
128
+ id: `health:link:hub:${hubs[i]!.n.id}`,
129
+ text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
130
+ target: hubs[i]!.n.id,
131
+ });
132
+ }
133
+ }
134
+ sections.push({ heading: "Link health", rows: linkRows });
135
+
136
+ return { sections };
137
+ }
138
+
139
+ export interface ProvenanceCounts {
140
+ total: number;
141
+ human: number;
142
+ agent: number;
143
+ generated: number;
144
+ structural: number;
145
+ }
146
+
147
+ export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
148
+ const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
149
+ for (const n of nodes) {
150
+ if (n.provenance === "human") c.human++;
151
+ else if (n.provenance === "agent") c.agent++;
152
+ else if (n.provenance === "generated") c.generated++;
153
+ else c.structural++;
154
+ }
155
+ return c;
156
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * src/core/view — portable, renderer-agnostic view-models (weave-workspace §3).
3
+ *
4
+ * One implementation of the tree, detail, focus, and health projections, shared
5
+ * by the pi TUI and the browser workspace so they can never drift. Pure
6
+ * functions over `GraphModel`; no harness, no DOM, no terminal concepts.
7
+ */
8
+ export * from "./types";
9
+ export * from "./time";
10
+ export * from "./tree";
11
+ export * from "./detail";
12
+ export * from "./focus";
13
+ export * from "./links";
14
+ export * from "./health";
15
+ export * from "./cluster";