logseq-graph-living-atlas 0.1.0

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 (41) hide show
  1. package/.env.example +29 -0
  2. package/CHANGELOG.md +38 -0
  3. package/CODE_OF_CONDUCT.md +33 -0
  4. package/CONTRIBUTING.md +116 -0
  5. package/GOVERNANCE.md +40 -0
  6. package/LICENSE +21 -0
  7. package/MAINTAINERS.md +34 -0
  8. package/README.md +370 -0
  9. package/ROADMAP.md +38 -0
  10. package/SECURITY.md +43 -0
  11. package/SUPPORT.md +31 -0
  12. package/dist/assets/AtlasCanvas-p-Pb_C3p.js +172 -0
  13. package/dist/assets/index-Cb0dgkF5.css +1 -0
  14. package/dist/assets/index-D-LUf-q7.js +12 -0
  15. package/dist/assets/three-DQCgX68K.js +3947 -0
  16. package/dist/index.html +13 -0
  17. package/docs/ADAPTERS.md +48 -0
  18. package/docs/API.md +145 -0
  19. package/docs/ARCHITECTURE.md +93 -0
  20. package/docs/CONCEPTS.md +62 -0
  21. package/docs/MCP.md +74 -0
  22. package/docs/RELEASE.md +65 -0
  23. package/docs/REPO_GUIDE.md +92 -0
  24. package/docs/TROUBLESHOOTING.md +138 -0
  25. package/docs/assets/living-atlas-demo.png +0 -0
  26. package/docs/assets/living-atlas-pathfinder.png +0 -0
  27. package/docs/assets/living-atlas-radar.png +0 -0
  28. package/docs/assets/living-atlas-source-detail.png +0 -0
  29. package/package.json +102 -0
  30. package/server/brain-service.mjs +201 -0
  31. package/server/contracts.mjs +346 -0
  32. package/server/fixture/create-fixture-graph.mjs +115 -0
  33. package/server/graph/pathfinding.mjs +155 -0
  34. package/server/graph/quality.mjs +11 -0
  35. package/server/graph/utils.mjs +25 -0
  36. package/server/graph-index.mjs +920 -0
  37. package/server/logseq/parser.mjs +121 -0
  38. package/server/logseq/source-adapter.mjs +147 -0
  39. package/server/redaction.mjs +89 -0
  40. package/server/service.mjs +777 -0
  41. package/server/source-adapter-contract.d.ts +46 -0
@@ -0,0 +1,920 @@
1
+ import path from "node:path";
2
+ import { slugify } from "./logseq/parser.mjs";
3
+ import { META_TYPES, proofDebtFor } from "./graph/quality.mjs";
4
+ import { buildAdjacency, findNode, round } from "./graph/utils.mjs";
5
+ export { pathSnapshot } from "./graph/pathfinding.mjs";
6
+
7
+ const PARENT_RELATIONS = new Set(["company", "org", "organization", "owner", "parent", "parent org", "reports to", "customer of", "part of"]);
8
+ const PUBLIC_PROPERTY_KEYS = new Set([
9
+ "type",
10
+ "tags",
11
+ "status",
12
+ "source",
13
+ "confidence",
14
+ "last-contacted",
15
+ "company",
16
+ "organization",
17
+ "owner",
18
+ "parent"
19
+ ]);
20
+ export function buildSnapshot(records, options = {}) {
21
+ const now = options.now ? new Date(options.now) : new Date();
22
+ const byId = new Map(records.map((record) => [record.id, record]));
23
+ const inDegree = new Map();
24
+ const outDegree = new Map();
25
+ const links = [];
26
+ const dangling = new Map();
27
+
28
+ for (const record of records) {
29
+ const uniqueOut = [...new Set(record.out || [])];
30
+ outDegree.set(record.id, 0);
31
+ for (const target of uniqueOut) {
32
+ if (!byId.has(target)) {
33
+ const item = dangling.get(target) || { target, refs: 0, sources: [] };
34
+ item.refs += 1;
35
+ item.sources.push(record.name);
36
+ dangling.set(target, item);
37
+ continue;
38
+ }
39
+ links.push({
40
+ id: `${record.id}->${target}`,
41
+ source: record.id,
42
+ target,
43
+ kind: "wikilink"
44
+ });
45
+ outDegree.set(record.id, (outDegree.get(record.id) || 0) + 1);
46
+ inDegree.set(target, (inDegree.get(target) || 0) + 1);
47
+ }
48
+ }
49
+
50
+ const degreeMax = Math.max(
51
+ 1,
52
+ ...records.map((record) => (inDegree.get(record.id) || 0) + (outDegree.get(record.id) || 0))
53
+ );
54
+ const clusterMap = buildClusters(records);
55
+ const nodes = records.map((record) => {
56
+ const inCount = inDegree.get(record.id) || 0;
57
+ const outCount = outDegree.get(record.id) || 0;
58
+ const total = inCount + outCount;
59
+ const cluster = clusterMap.get(record.id);
60
+ const position = stablePosition(record, cluster, total, degreeMax);
61
+ const activity = activityScore(record.mtimeMs, now);
62
+ return {
63
+ id: record.id,
64
+ name: record.name,
65
+ type: record.type,
66
+ tags: record.tags,
67
+ status: record.status,
68
+ source: record.source,
69
+ confidence: record.confidence,
70
+ updatedAt: record.updatedAt,
71
+ in: inCount,
72
+ out: outCount,
73
+ total,
74
+ cluster: cluster.id,
75
+ clusterLabel: cluster.label,
76
+ x: round(position.x),
77
+ y: round(position.y),
78
+ z: round(position.z),
79
+ size: round(3 + Math.sqrt(total + 1) * 1.9 + activity * 3),
80
+ heat: round(activity),
81
+ color: colorFor(record, cluster)
82
+ };
83
+ });
84
+ const nodeTotals = new Map(nodes.map((node) => [node.id, node.total]));
85
+ for (const link of links) {
86
+ link.weight = linkWeight(nodeTotals.get(link.source) || 0, nodeTotals.get(link.target) || 0);
87
+ }
88
+
89
+ const clusters = summarizeClusters(nodes, links);
90
+ const insights = buildInsights(nodes, links, [...dangling.values()], now);
91
+ const totals = {
92
+ pages: records.length,
93
+ nodes: nodes.length,
94
+ links: links.length,
95
+ dangling: dangling.size,
96
+ clusters: clusters.length,
97
+ active24h: nodes.filter((node) => node.heat > 0.7).length,
98
+ active7d: nodes.filter((node) => node.heat > 0.35).length
99
+ };
100
+
101
+ return {
102
+ generatedAt: now.toISOString(),
103
+ version: 1,
104
+ totals,
105
+ clusters,
106
+ nodes,
107
+ links,
108
+ insights,
109
+ health: {
110
+ source: "logseq-markdown",
111
+ layout: "stable-hybrid-topology-activity",
112
+ edgePolicy: "sparse by default; focus/path modes reveal detail"
113
+ }
114
+ };
115
+ }
116
+
117
+ export function diffSnapshots(previous, next) {
118
+ const prevNodes = new Map((previous?.nodes || []).map((node) => [node.id, node]));
119
+ const nextNodes = new Map((next?.nodes || []).map((node) => [node.id, node]));
120
+ const prevLinks = new Map((previous?.links || []).map((link) => [link.id, link]));
121
+ const nextLinks = new Map((next?.links || []).map((link) => [link.id, link]));
122
+ const changedNodes = [];
123
+ const addedNodes = [];
124
+ for (const node of next.nodes) {
125
+ const prev = prevNodes.get(node.id);
126
+ if (!prev) {
127
+ addedNodes.push(node);
128
+ continue;
129
+ }
130
+ if (
131
+ prev.updatedAt !== node.updatedAt ||
132
+ prev.total !== node.total ||
133
+ prev.cluster !== node.cluster ||
134
+ prev.status !== node.status
135
+ ) {
136
+ changedNodes.push(node);
137
+ }
138
+ }
139
+ const removedNodes = [...prevNodes.values()].filter((node) => !nextNodes.has(node.id));
140
+ const addedLinks = next.links.filter((link) => !prevLinks.has(link.id));
141
+ const removedLinks = [...prevLinks.values()].filter((link) => !nextLinks.has(link.id));
142
+ return {
143
+ type: "graph_delta",
144
+ generatedAt: next.generatedAt,
145
+ addedNodes,
146
+ changedNodes,
147
+ removedNodes,
148
+ addedLinks,
149
+ removedLinks,
150
+ insights: next.insights.slice(0, 8),
151
+ totals: next.totals
152
+ };
153
+ }
154
+
155
+ export function budgetSnapshot(snapshot, options = {}) {
156
+ const nodeBudget = Math.max(0, Math.floor(Number(options.nodeBudget || 0)));
157
+ if (!nodeBudget || snapshot.nodes.length <= nodeBudget) return snapshot;
158
+
159
+ const linkBudget = Math.max(nodeBudget, Math.floor(Number(options.linkBudget || nodeBudget * 3)));
160
+ const insightIds = new Set((snapshot.insights || []).flatMap((insight) => insight.nodeIds || []));
161
+ const selected = new Set();
162
+ const score = (node) =>
163
+ node.total + node.heat * 42 + (insightIds.has(node.id) ? 900 : 0) + (node.id === node.cluster ? 450 : 0);
164
+ const add = (node) => {
165
+ if (node && selected.size < nodeBudget) selected.add(node.id);
166
+ };
167
+
168
+ const grouped = new Map();
169
+ for (const node of snapshot.nodes) grouped.set(node.cluster, [...(grouped.get(node.cluster) || []), node]);
170
+ const perCluster = Math.max(1, Math.floor(nodeBudget * 0.58 / Math.max(1, grouped.size)));
171
+ for (const clusterNodes of grouped.values()) {
172
+ clusterNodes
173
+ .sort((a, b) => score(b) - score(a))
174
+ .slice(0, perCluster)
175
+ .forEach(add);
176
+ }
177
+
178
+ snapshot.nodes
179
+ .filter((node) => insightIds.has(node.id))
180
+ .sort((a, b) => score(b) - score(a))
181
+ .forEach(add);
182
+
183
+ [...snapshot.nodes]
184
+ .sort((a, b) => score(b) - score(a))
185
+ .some((node) => {
186
+ add(node);
187
+ return selected.size >= nodeBudget;
188
+ });
189
+
190
+ const nodes = snapshot.nodes.filter((node) => selected.has(node.id));
191
+ const nodeIds = new Set(nodes.map((node) => node.id));
192
+ const links = snapshot.links
193
+ .filter((link) => nodeIds.has(link.source) && nodeIds.has(link.target))
194
+ .sort((a, b) => {
195
+ const aScore = (nodeIds.has(a.source) ? 1 : 0) + (nodeIds.has(a.target) ? 1 : 0);
196
+ const bScore = (nodeIds.has(b.source) ? 1 : 0) + (nodeIds.has(b.target) ? 1 : 0);
197
+ return bScore - aScore || a.id.localeCompare(b.id);
198
+ })
199
+ .slice(0, linkBudget);
200
+
201
+ return {
202
+ ...snapshot,
203
+ nodes,
204
+ links,
205
+ health: {
206
+ ...snapshot.health,
207
+ renderPolicy: `overview-budget:${nodeBudget}`,
208
+ fullNodes: snapshot.totals.nodes,
209
+ fullLinks: snapshot.totals.links
210
+ }
211
+ };
212
+ }
213
+
214
+ export function createSnapshotRuntime(snapshot, records = []) {
215
+ const nodeById = new Map(snapshot.nodes.map((node) => [node.id, node]));
216
+ const recordById = new Map((records || []).map((record) => [record.id, record]));
217
+ const { adjacency, edgeLookup } = buildAdjacency(snapshot.links);
218
+ const incomingLinksById = new Map();
219
+ const outgoingLinksById = new Map();
220
+ const nodesByCluster = new Map();
221
+ for (const node of snapshot.nodes) {
222
+ nodesByCluster.set(node.cluster, [...(nodesByCluster.get(node.cluster) || []), node]);
223
+ }
224
+ for (const link of snapshot.links) {
225
+ incomingLinksById.set(link.target, [...(incomingLinksById.get(link.target) || []), link]);
226
+ outgoingLinksById.set(link.source, [...(outgoingLinksById.get(link.source) || []), link]);
227
+ }
228
+ const searchRows = snapshot.nodes.map((node) => ({
229
+ node,
230
+ name: node.name.toLowerCase(),
231
+ type: node.type.toLowerCase(),
232
+ cluster: node.clusterLabel.toLowerCase(),
233
+ tags: node.tags.map((tag) => tag.toLowerCase()),
234
+ status: node.status.toLowerCase(),
235
+ source: node.source.toLowerCase(),
236
+ confidence: node.confidence.toLowerCase()
237
+ }));
238
+ return {
239
+ adjacency,
240
+ edgeLookup,
241
+ incomingLinksById,
242
+ nodeById,
243
+ nodesByCluster,
244
+ outgoingLinksById,
245
+ recordById,
246
+ searchRows
247
+ };
248
+ }
249
+
250
+ export function searchSnapshot(snapshot, query, limit = 8, runtime = null) {
251
+ const needle = String(query || "").trim().toLowerCase();
252
+ const slugNeedle = slugify(needle);
253
+ const max = Math.max(1, Math.floor(Number(limit || 8)));
254
+ if (needle.length < 2) {
255
+ return {
256
+ ok: true,
257
+ generatedAt: snapshot.generatedAt,
258
+ query: String(query || ""),
259
+ totalMatches: 0,
260
+ omitted: 0,
261
+ results: []
262
+ };
263
+ }
264
+ const rows = runtime?.searchRows || createSnapshotRuntime(snapshot).searchRows;
265
+ const scored = rows
266
+ .map((row) => {
267
+ const { node, name, type, cluster, status, source, confidence } = row;
268
+ const tagHit = row.tags.some((tag) => tag.includes(needle));
269
+ const statusHit = status.includes(needle);
270
+ const sourceHit = source.includes(needle);
271
+ const confidenceHit = confidence.includes(needle);
272
+ const score =
273
+ (node.id === slugNeedle ? 1600 : 0) +
274
+ (name === needle ? 1400 : 0) +
275
+ (name.startsWith(needle) ? 850 : 0) +
276
+ (name.includes(needle) ? 420 : 0) +
277
+ (type === needle ? 260 : 0) +
278
+ (type.includes(needle) ? 140 : 0) +
279
+ (cluster.includes(needle) ? 180 : 0) +
280
+ (tagHit ? 150 : 0) +
281
+ (statusHit ? 80 : 0) +
282
+ (sourceHit ? 70 : 0) +
283
+ (confidenceHit ? 50 : 0) +
284
+ node.total +
285
+ node.heat * 60;
286
+ return { node, score };
287
+ })
288
+ .filter((entry) => entry.score > entry.node.total + entry.node.heat * 60)
289
+ .sort((a, b) => b.score - a.score || b.node.total - a.node.total || a.node.name.localeCompare(b.node.name));
290
+ return {
291
+ ok: true,
292
+ generatedAt: snapshot.generatedAt,
293
+ query: String(query || ""),
294
+ totalMatches: scored.length,
295
+ omitted: Math.max(0, scored.length - max),
296
+ results: scored.slice(0, max).map((entry) => entry.node)
297
+ };
298
+ }
299
+
300
+ export function focusSnapshot(snapshot, query, radius = 2, limit = 1800, runtime = null) {
301
+ const lookup = runtime || createSnapshotRuntime(snapshot);
302
+ const q = slugify(query);
303
+ const exactSeed = lookup.nodeById.get(q) || snapshot.nodes.find((node) => slugify(node.name) === q);
304
+ const cluster = snapshot.clusters.find((item) => item.id === q || slugify(item.label) === q);
305
+ const seed = exactSeed || (cluster ? null : findNode(snapshot.nodes, query));
306
+ if (!seed) {
307
+ if (cluster) return clusterFocusSnapshot(snapshot, cluster, limit, lookup);
308
+ return { ok: false, error: "not found", query };
309
+ }
310
+
311
+ const selected = new Set([seed.id]);
312
+ let frontier = new Set([seed.id]);
313
+ const maxNodes = Math.max(1, Math.floor(Number(limit || 1800)));
314
+ for (let depth = 0; depth < radius; depth += 1) {
315
+ const next = new Set();
316
+ for (const nodeId of frontier) {
317
+ const neighbors = [...(lookup.adjacency.get(nodeId) || [])].sort((a, b) => (lookup.nodeById.get(b)?.total || 0) - (lookup.nodeById.get(a)?.total || 0));
318
+ for (const neighbor of neighbors) {
319
+ if (selected.size + next.size >= maxNodes) break;
320
+ if (!selected.has(neighbor)) next.add(neighbor);
321
+ }
322
+ }
323
+ for (const nodeId of next) selected.add(nodeId);
324
+ frontier = next;
325
+ if (selected.size >= maxNodes) break;
326
+ }
327
+ const nodes = snapshot.nodes.filter((node) => selected.has(node.id));
328
+ const links = snapshot.links.filter((link) => selected.has(link.source) && selected.has(link.target)).slice(0, maxNodes * 3);
329
+ return {
330
+ ok: true,
331
+ focusKind: "page",
332
+ seed,
333
+ radius,
334
+ nodes,
335
+ links,
336
+ limited: selected.size >= maxNodes,
337
+ totalMatches: nodes.length,
338
+ insights: snapshot.insights.filter((insight) => insight.nodeIds?.includes(seed.id)).slice(0, 5)
339
+ };
340
+ }
341
+
342
+ function clusterFocusSnapshot(snapshot, cluster, limit = 1800, runtime = null) {
343
+ const maxNodes = Math.max(1, Math.floor(Number(limit || 1800)));
344
+ const matches = [...(runtime?.nodesByCluster?.get(cluster.id) || snapshot.nodes.filter((node) => node.cluster === cluster.id))]
345
+ .sort((a, b) => b.total + b.heat * 42 - (a.total + a.heat * 42));
346
+ const nodes = matches.slice(0, maxNodes);
347
+ const ids = new Set(nodes.map((node) => node.id));
348
+ const links = snapshot.links.filter((link) => ids.has(link.source) && ids.has(link.target)).slice(0, maxNodes * 3);
349
+ const seed = nodes[0] || null;
350
+ return {
351
+ ok: true,
352
+ focusKind: "cluster",
353
+ seed,
354
+ cluster,
355
+ radius: 0,
356
+ nodes,
357
+ links,
358
+ limited: matches.length > nodes.length,
359
+ totalMatches: matches.length,
360
+ insights: snapshot.insights.filter((insight) => insight.nodeIds?.some((id) => ids.has(id))).slice(0, 5)
361
+ };
362
+ }
363
+
364
+ export function connectorCandidates(snapshot, limit = 12) {
365
+ const max = Math.max(1, Math.floor(Number(limit || 12)));
366
+ const nodeById = new Map(snapshot.nodes.map((node) => [node.id, node]));
367
+ const clusterById = new Map(snapshot.clusters.map((cluster) => [cluster.id, cluster]));
368
+ const clusterPairLinks = new Map();
369
+ const nodeNeighborClusters = new Map();
370
+
371
+ for (const link of snapshot.links) {
372
+ const source = nodeById.get(link.source);
373
+ const target = nodeById.get(link.target);
374
+ if (!source || !target || source.cluster === target.cluster) continue;
375
+ const key = clusterPairKey(source.cluster, target.cluster);
376
+ clusterPairLinks.set(key, (clusterPairLinks.get(key) || 0) + 1);
377
+ addNeighborCluster(nodeNeighborClusters, source.id, target.cluster);
378
+ addNeighborCluster(nodeNeighborClusters, target.id, source.cluster);
379
+ }
380
+
381
+ const candidates = [];
382
+ const clusters = snapshot.clusters.filter((cluster) => cluster.count > 0);
383
+ for (let i = 0; i < clusters.length; i += 1) {
384
+ for (let j = i + 1; j < clusters.length; j += 1) {
385
+ const fromCluster = clusters[i];
386
+ const toCluster = clusters[j];
387
+ const pairKey = clusterPairKey(fromCluster.id, toCluster.id);
388
+ const linkCount = clusterPairLinks.get(pairKey) || 0;
389
+ const expected = Math.sqrt(fromCluster.count * toCluster.count) / 4.8;
390
+ const pressure = Math.max(0, expected - linkCount);
391
+ if (pressure <= 0.25) continue;
392
+ const fromAnchors = bridgeAnchors(snapshot.nodes, fromCluster.id, toCluster.id, nodeNeighborClusters);
393
+ const toAnchors = bridgeAnchors(snapshot.nodes, toCluster.id, fromCluster.id, nodeNeighborClusters);
394
+ if (!fromAnchors.length && !toAnchors.length) continue;
395
+ const hotness = (fromCluster.heat + toCluster.heat) / 2;
396
+ const score = Math.round(Math.min(99, Math.max(1,
397
+ pressure * 7 +
398
+ hotness * 22 +
399
+ (fromAnchors.length + toAnchors.length) * 4 +
400
+ clusterBridgePriority(fromCluster.id) +
401
+ clusterBridgePriority(toCluster.id)
402
+ )));
403
+ candidates.push({
404
+ id: `${fromCluster.id}:${toCluster.id}`,
405
+ fromCluster: clusterSummary(fromCluster),
406
+ toCluster: clusterSummary(toCluster),
407
+ linkCount,
408
+ expected: round(expected),
409
+ score,
410
+ rationale: linkCount === 0
411
+ ? "no explicit connector links despite nearby active regions"
412
+ : `${linkCount} connector links under expected relationship pressure`,
413
+ nodeIds: [...fromAnchors, ...toAnchors].slice(0, 8).map((node) => node.id),
414
+ anchors: [...fromAnchors, ...toAnchors].slice(0, 8).map((node) => ({
415
+ id: node.id,
416
+ name: node.name,
417
+ cluster: node.clusterLabel,
418
+ degree: node.total,
419
+ heat: node.heat,
420
+ debt: proofDebtFor(node).length
421
+ }))
422
+ });
423
+ }
424
+ }
425
+ return candidates
426
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
427
+ .slice(0, max);
428
+ }
429
+
430
+ export const bridgeCandidates = connectorCandidates;
431
+
432
+ export const DEFAULT_NODE_EDGE_LIMIT = 250;
433
+
434
+ export function nodeDetail(snapshot, records, query, root = "", runtime = null, options = {}) {
435
+ const lookup = runtime || createSnapshotRuntime(snapshot, records);
436
+ const node = findNode(snapshot.nodes, query);
437
+ if (!node) return { ok: false, error: "node not found", query };
438
+ const edgeLimit = boundedInteger(options.edgeLimit, DEFAULT_NODE_EDGE_LIMIT, 1, 1000);
439
+ const record = lookup.recordById.get(node.id);
440
+ const incoming = (lookup.incomingLinksById.get(node.id) || [])
441
+ .map((link) => ({ linkId: link.id, weight: link.weight, node: lookup.nodeById.get(link.source) }))
442
+ .filter((entry) => entry.node)
443
+ .sort((a, b) => b.node.total - a.node.total);
444
+ const outgoing = (lookup.outgoingLinksById.get(node.id) || [])
445
+ .map((link) => ({ linkId: link.id, weight: link.weight, node: lookup.nodeById.get(link.target) }))
446
+ .filter((entry) => entry.node)
447
+ .sort((a, b) => b.node.total - a.node.total);
448
+ const sourcePath = record?.path || "";
449
+ return {
450
+ ok: true,
451
+ node,
452
+ source: {
453
+ relativePath: graphRelativePath(root, sourcePath),
454
+ updatedAt: record?.updatedAt || node.updatedAt,
455
+ properties: publicProperties(record?.props || {}),
456
+ preview: record ? buildPreview(record) : ""
457
+ },
458
+ backlinks: incoming.slice(0, edgeLimit),
459
+ outlinks: outgoing.slice(0, edgeLimit),
460
+ backlinksTotal: incoming.length,
461
+ outlinksTotal: outgoing.length,
462
+ edgeLimit,
463
+ insights: snapshot.insights.filter((insight) => insight.nodeIds?.includes(node.id)).slice(0, 8),
464
+ xray: buildEntityXray(snapshot, record, node, incoming, outgoing)
465
+ };
466
+ }
467
+
468
+ function boundedInteger(value, defaultValue, min, max) {
469
+ const parsed = Number(value);
470
+ if (!Number.isInteger(parsed)) return defaultValue;
471
+ return Math.min(max, Math.max(min, parsed));
472
+ }
473
+
474
+ function graphRelativePath(root, sourcePath) {
475
+ if (!sourcePath) return "";
476
+ return root ? path.relative(root, sourcePath) : sourcePath;
477
+ }
478
+
479
+ function publicProperties(props) {
480
+ const publicProps = {};
481
+ for (const [key, value] of Object.entries(props || {})) {
482
+ if (PUBLIC_PROPERTY_KEYS.has(key)) publicProps[key] = value;
483
+ }
484
+ return publicProps;
485
+ }
486
+
487
+ function buildEntityXray(snapshot, record, node, incoming, outgoing) {
488
+ const cluster = snapshot.clusters.find((item) => item.id === node.cluster);
489
+ const clusterRoot = snapshot.nodes.find((item) => item.id === node.cluster);
490
+ const parent = inferParent(snapshot, record, node, incoming, outgoing, clusterRoot);
491
+ const proofDebt = proofDebtFor(node);
492
+ const strongestById = new Map();
493
+ for (const entry of incoming) {
494
+ const current = strongestById.get(entry.node.id) || { node: entry.node, directions: new Set(), relationKinds: new Set() };
495
+ current.directions.add("inbound");
496
+ strongestById.set(entry.node.id, current);
497
+ }
498
+ for (const entry of outgoing) {
499
+ const current = strongestById.get(entry.node.id) || { node: entry.node, directions: new Set(), relationKinds: new Set() };
500
+ current.directions.add("outbound");
501
+ strongestById.set(entry.node.id, current);
502
+ }
503
+ for (const relation of record?.relations || []) {
504
+ const current = strongestById.get(relation.target);
505
+ if (current) current.relationKinds.add(relation.kind);
506
+ }
507
+ const strongest = [...strongestById.values()]
508
+ .sort((a, b) => b.node.total + b.node.heat * 18 - (a.node.total + a.node.heat * 18))
509
+ .slice(0, 5)
510
+ .map((entry) => ({
511
+ id: entry.node.id,
512
+ name: entry.node.name,
513
+ type: entry.node.type,
514
+ cluster: entry.node.clusterLabel,
515
+ degree: entry.node.total,
516
+ heat: entry.node.heat,
517
+ directions: [...entry.directions],
518
+ relationKinds: [...entry.relationKinds]
519
+ }));
520
+ const staleDays = Math.max(0, Math.round((Date.now() - Date.parse(node.updatedAt || record?.updatedAt || new Date())) / 86400000));
521
+ return {
522
+ kind: "source_page_node",
523
+ parent,
524
+ cluster: cluster ? {
525
+ id: cluster.id,
526
+ label: cluster.label,
527
+ count: cluster.count,
528
+ degree: cluster.degree,
529
+ bridges: cluster.bridges,
530
+ heat: cluster.heat
531
+ } : null,
532
+ staleDays,
533
+ proofDebt,
534
+ strongest,
535
+ signalSummary: [
536
+ `${node.in} links in`,
537
+ `${node.out} links out`,
538
+ `${node.total} total links`,
539
+ `${Math.round(node.heat * 100)} heat`
540
+ ]
541
+ };
542
+ }
543
+
544
+ function inferParent(snapshot, record, node, incoming, outgoing, clusterRoot) {
545
+ const nodeById = new Map(snapshot.nodes.map((item) => [item.id, item]));
546
+ const explicit = (record?.relations || [])
547
+ .filter((relation) => PARENT_RELATIONS.has(relation.kind))
548
+ .map((relation) => ({ relation, target: nodeById.get(relation.target) }))
549
+ .filter((entry) => entry.target && entry.target.id !== node.id)
550
+ .sort((a, b) => b.target.total - a.target.total)[0];
551
+ if (explicit) {
552
+ return {
553
+ id: explicit.target.id,
554
+ name: explicit.target.name,
555
+ relation: `explicit ${explicit.relation.kind}`,
556
+ evidence: explicit.relation.evidence
557
+ };
558
+ }
559
+ if (clusterRoot && clusterRoot.id !== node.id) {
560
+ const linkedRoot = [...incoming, ...outgoing].find((entry) => entry.node?.id === clusterRoot.id);
561
+ if (linkedRoot) {
562
+ return {
563
+ id: clusterRoot.id,
564
+ name: clusterRoot.name,
565
+ relation: "linked cluster root"
566
+ };
567
+ }
568
+ }
569
+ const parentCandidate = [...incoming, ...outgoing]
570
+ .map((entry) => entry.node)
571
+ .filter(Boolean)
572
+ .filter((candidate) => candidate.id !== node.id)
573
+ .sort((a, b) => b.total - a.total)[0];
574
+ return parentCandidate ? {
575
+ id: parentCandidate.id,
576
+ name: parentCandidate.name,
577
+ relation: parentCandidate.cluster === node.cluster ? "strongest local anchor" : "strongest linked anchor"
578
+ } : null;
579
+ }
580
+
581
+ function linkWeight(sourceDegree, targetDegree) {
582
+ const topology = Math.sqrt(Math.max(1, sourceDegree) * Math.max(1, targetDegree));
583
+ return round(Math.max(0.18, Math.min(1, topology / 44)));
584
+ }
585
+
586
+ function clusterPairKey(a, b) {
587
+ return [a, b].sort().join(":");
588
+ }
589
+
590
+ function addNeighborCluster(map, nodeId, clusterId) {
591
+ const current = map.get(nodeId) || new Set();
592
+ current.add(clusterId);
593
+ map.set(nodeId, current);
594
+ }
595
+
596
+ function bridgeAnchors(nodes, sourceClusterId, targetClusterId, nodeNeighborClusters) {
597
+ return nodes
598
+ .filter((node) => node.cluster === sourceClusterId)
599
+ .map((node) => ({
600
+ node,
601
+ linkedToTarget: nodeNeighborClusters.get(node.id)?.has(targetClusterId) ? 1 : 0,
602
+ debt: proofDebtFor(node).length
603
+ }))
604
+ .filter((entry) => entry.linkedToTarget || entry.node.total <= 2 || entry.node.heat > 0.45)
605
+ .sort((a, b) => {
606
+ const aScore = a.linkedToTarget * 90 + a.node.heat * 25 + a.debt * 4 + Math.max(0, 6 - a.node.total);
607
+ const bScore = b.linkedToTarget * 90 + b.node.heat * 25 + b.debt * 4 + Math.max(0, 6 - b.node.total);
608
+ return bScore - aScore;
609
+ })
610
+ .slice(0, 4)
611
+ .map((entry) => entry.node);
612
+ }
613
+
614
+ function clusterSummary(cluster) {
615
+ return {
616
+ id: cluster.id,
617
+ label: cluster.label,
618
+ count: cluster.count,
619
+ heat: cluster.heat,
620
+ degree: cluster.degree,
621
+ bridges: cluster.bridges
622
+ };
623
+ }
624
+
625
+ function clusterBridgePriority(clusterId) {
626
+ if (clusterId === "people") return -42;
627
+ if (clusterId === "organizations") return -32;
628
+ if (clusterId === "projects") return 4;
629
+ if (clusterId === "operations") return 2;
630
+ return 18;
631
+ }
632
+
633
+ function buildPreview(record) {
634
+ const useful = [];
635
+ for (const [key, value] of Object.entries(publicProperties(record.props || {}))) {
636
+ if (["type", "tags", "last-contacted"].includes(key)) continue;
637
+ if (value) useful.push(`${key}: ${value}`);
638
+ }
639
+ return useful.slice(0, 5).join(" · ");
640
+ }
641
+
642
+ function buildClusters(records) {
643
+ const anchors = buildClusterAnchors(records);
644
+ const map = new Map();
645
+ for (const record of records) {
646
+ let found = null;
647
+ if (record.type !== "person") {
648
+ const tagIds = new Set((record.tags || []).map(slugify));
649
+ found = anchors.find((anchor) => (
650
+ anchor.dynamic &&
651
+ (record.id === anchor.id || tagIds.has(anchor.id) || (record.out || []).includes(anchor.id))
652
+ ));
653
+ }
654
+ if (!found) found = genericClusterFor(record);
655
+ map.set(record.id, { id: found.id, label: found.label });
656
+ }
657
+ return map;
658
+ }
659
+
660
+ function buildClusterAnchors(records) {
661
+ const byId = new Map(records.map((record) => [record.id, record]));
662
+ const scores = new Map();
663
+ const add = (id, label, score) => {
664
+ const normalized = slugify(id);
665
+ if (!isUsefulDynamicClusterId(normalized)) return;
666
+ const current = scores.get(normalized) || { id: normalized, label: labelFromSlug(label || normalized), score: 0 };
667
+ current.score += score;
668
+ scores.set(normalized, current);
669
+ };
670
+
671
+ for (const record of records) {
672
+ if (["project", "organization", "infrastructure", "area", "initiative", "product"].includes(record.type)) {
673
+ add(record.id, record.name, 4 + Math.min(8, (record.out || []).length));
674
+ }
675
+ for (const tag of record.tags || []) add(tag, tag, 3);
676
+ for (const target of record.out || []) {
677
+ const targetRecord = byId.get(target);
678
+ if (targetRecord && ["project", "organization", "infrastructure", "area", "initiative", "product"].includes(targetRecord.type)) {
679
+ add(targetRecord.id, targetRecord.name, 1.5);
680
+ }
681
+ }
682
+ }
683
+
684
+ return [...scores.values()]
685
+ .sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
686
+ .slice(0, 8)
687
+ .map((anchor) => ({ ...anchor, dynamic: true }));
688
+ }
689
+
690
+ function isUsefulDynamicClusterId(id) {
691
+ if (!id || id.length < 2) return false;
692
+ return !new Set([
693
+ "people",
694
+ "person",
695
+ "organizations",
696
+ "organization",
697
+ "org",
698
+ "projects",
699
+ "project",
700
+ "operations",
701
+ "notes",
702
+ "note",
703
+ "todo",
704
+ "doing",
705
+ "done",
706
+ "active",
707
+ "archive"
708
+ ]).has(id);
709
+ }
710
+
711
+ function genericClusterFor(record) {
712
+ if (record.type === "person") return { id: "people", label: "People" };
713
+ if (record.type === "organization") return { id: "organizations", label: "Organizations" };
714
+ if (record.type === "project" || record.type === "initiative" || record.type === "product") return { id: "projects", label: "Projects" };
715
+ if (record.type === "infrastructure" || record.type === "system" || record.type === "service") return { id: "infrastructure", label: "Infrastructure" };
716
+ if (record.type === "event" || record.type === "meeting") return { id: "events", label: "Events" };
717
+ if (record.type === "location" || record.type === "place") return { id: "locations", label: "Locations" };
718
+ if (record.type === "operation" || record.type === "workflow" || record.type === "process") return { id: "workflows", label: "Workflows" };
719
+ return { id: "topics", label: "Topics" };
720
+ }
721
+
722
+ function labelFromSlug(value) {
723
+ return String(value || "")
724
+ .replace(/___/g, "/")
725
+ .replace(/[-_]+/g, " ")
726
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
727
+ }
728
+
729
+ function summarizeClusters(nodes, links) {
730
+ const grouped = new Map();
731
+ const nodeCluster = new Map(nodes.map((node) => [node.id, node.cluster]));
732
+ for (const node of nodes) {
733
+ const entry = grouped.get(node.cluster) || {
734
+ id: node.cluster,
735
+ label: node.clusterLabel,
736
+ count: 0,
737
+ heat: 0,
738
+ degree: 0,
739
+ color: node.color
740
+ };
741
+ entry.count += 1;
742
+ entry.heat += node.heat;
743
+ entry.degree += node.total;
744
+ grouped.set(node.cluster, entry);
745
+ }
746
+ const linkCount = new Map();
747
+ for (const link of links) {
748
+ const sourceCluster = nodeCluster.get(link.source);
749
+ const targetCluster = nodeCluster.get(link.target);
750
+ if (!sourceCluster || !targetCluster || sourceCluster === targetCluster) continue;
751
+ linkCount.set(sourceCluster, (linkCount.get(sourceCluster) || 0) + 1);
752
+ linkCount.set(targetCluster, (linkCount.get(targetCluster) || 0) + 1);
753
+ }
754
+ return [...grouped.values()]
755
+ .map((entry) => ({
756
+ ...entry,
757
+ bridges: linkCount.get(entry.id) || 0,
758
+ heat: round(entry.heat / Math.max(1, entry.count)),
759
+ degree: Math.round(entry.degree)
760
+ }))
761
+ .sort((a, b) => b.count - a.count);
762
+ }
763
+
764
+ function buildInsights(nodes, links, dangling, now) {
765
+ const insights = [];
766
+ const hot = nodes.filter((node) => node.heat > 0.7).sort((a, b) => b.heat - a.heat).slice(0, 12);
767
+ if (hot.length) {
768
+ insights.push({
769
+ id: "recent-ignitions",
770
+ severity: "live",
771
+ title: `${hot.length} pages are actively pulsing`,
772
+ detail: "Recently touched pages are emitting high-heat particles in Today mode.",
773
+ metric: hot.length,
774
+ nodeIds: hot.map((node) => node.id),
775
+ action: {
776
+ kind: "focus_hot_pages",
777
+ label: "Open pulse",
778
+ target: "Today",
779
+ rationale: "recent file timestamps crossed the high-heat threshold",
780
+ nextStep: "Switch to Today and inspect the hot source pages"
781
+ },
782
+ provenance: hot.slice(0, 5).map((node) => ({ name: node.name, updatedAt: node.updatedAt }))
783
+ });
784
+ }
785
+
786
+ const weak = nodes
787
+ .filter((node) => node.total <= 1 && !META_TYPES.has(node.type) && node.type !== "redirect")
788
+ .sort((a, b) => b.heat - a.heat)
789
+ .slice(0, 12);
790
+ if (weak.length) {
791
+ insights.push({
792
+ id: "weak-pressure-gaps",
793
+ severity: "attention",
794
+ title: `${weak.length} weakly connected pages need connector checks`,
795
+ detail: "These pages are present in the knowledge field but have very few trusted links.",
796
+ metric: weak.length,
797
+ nodeIds: weak.map((node) => node.id),
798
+ action: {
799
+ kind: "review_weak_pages",
800
+ label: "Check connectors",
801
+ target: "Radar",
802
+ rationale: "low-link pages are easy to lose at atlas scale",
803
+ nextStep: "Review connector candidates or add explicit wikilinks"
804
+ },
805
+ provenance: weak.slice(0, 5).map((node) => ({ name: node.name, degree: node.total }))
806
+ });
807
+ }
808
+
809
+ const hubs = [...nodes].sort((a, b) => b.total - a.total).slice(0, 8);
810
+ if (hubs.length) {
811
+ insights.push({
812
+ id: "gravity-wells",
813
+ severity: "context",
814
+ title: `${hubs.length} hub pages anchor the atlas`,
815
+ detail: "Large hubs shape the living terrain and should remain explainable.",
816
+ metric: hubs[0].total,
817
+ nodeIds: hubs.map((node) => node.id),
818
+ action: {
819
+ kind: "inspect_hubs",
820
+ label: "Open hubs",
821
+ target: "Cluster Command Deck",
822
+ rationale: "high-link pages strongly shape the visual field",
823
+ nextStep: "Inspect the strongest hubs for stale or unclear provenance"
824
+ },
825
+ provenance: hubs.slice(0, 5).map((node) => ({ name: node.name, degree: node.total }))
826
+ });
827
+ }
828
+
829
+ if (dangling.length) {
830
+ const top = dangling.sort((a, b) => b.refs - a.refs).slice(0, 8);
831
+ insights.push({
832
+ id: "dangling-filaments",
833
+ severity: "attention",
834
+ title: `${dangling.length} unresolved link targets create phantom matter`,
835
+ detail: "Create stubs or rewrite references before trusting those filaments.",
836
+ metric: dangling.length,
837
+ nodeIds: [],
838
+ action: {
839
+ kind: "resolve_dangling_targets",
840
+ label: "See targets",
841
+ target: top[0]?.target || "dangling links",
842
+ rationale: "unresolved wikilinks create phantom matter in the field",
843
+ nextStep: "Create missing pages or rewrite the strongest unresolved refs"
844
+ },
845
+ provenance: top.map((item) => ({ target: item.target, refs: item.refs, sources: item.sources.slice(0, 3) }))
846
+ });
847
+ }
848
+
849
+ const staleCutoff = now.getTime() - 45 * 24 * 60 * 60 * 1000;
850
+ const staleProjects = nodes
851
+ .filter((node) => node.type === "project" && Date.parse(node.updatedAt) < staleCutoff)
852
+ .slice(0, 10);
853
+ if (staleProjects.length) {
854
+ insights.push({
855
+ id: "cooling-projects",
856
+ severity: "watch",
857
+ title: `${staleProjects.length} project regions are cooling`,
858
+ detail: "Projects dim when they have not changed in 45 days.",
859
+ metric: staleProjects.length,
860
+ nodeIds: staleProjects.map((node) => node.id),
861
+ action: {
862
+ kind: "review_project_drift",
863
+ label: "Review drift",
864
+ target: "Project pages",
865
+ rationale: "old project timestamps indicate cooling knowledge regions",
866
+ nextStep: "Review stale projects and update, archive, or reconnect them"
867
+ },
868
+ provenance: staleProjects.slice(0, 5).map((node) => ({ name: node.name, updatedAt: node.updatedAt }))
869
+ });
870
+ }
871
+
872
+ return insights;
873
+ }
874
+
875
+ function stablePosition(record, cluster, degree, degreeMax) {
876
+ const clusterHash = hash(cluster.id);
877
+ const angle = (clusterHash % 6283) / 1000;
878
+ const radius = 32 + (clusterHash % 19);
879
+ const center = {
880
+ x: Math.cos(angle) * radius,
881
+ y: Math.sin(angle) * radius * 0.68,
882
+ z: Math.sin(angle * 1.7) * 14
883
+ };
884
+ const h = hash(record.id);
885
+ const localAngle = ((h >>> 4) % 6283) / 1000;
886
+ const localRadius = 3 + (((h >>> 11) % 1000) / 1000) * (10 + Math.sqrt(Math.max(1, degree)) * 2.5);
887
+ const gravity = Math.min(1, degree / degreeMax);
888
+ return {
889
+ x: center.x + Math.cos(localAngle) * localRadius * (1.25 - gravity * 0.45),
890
+ y: center.y + Math.sin(localAngle) * localRadius * (0.9 - gravity * 0.25),
891
+ z: center.z + (((h >>> 21) % 1000) / 1000 - 0.5) * 18
892
+ };
893
+ }
894
+
895
+ function activityScore(mtimeMs, now) {
896
+ const ageHours = Math.max(0, now.getTime() - Number(mtimeMs || 0)) / 36e5;
897
+ if (ageHours <= 24) return 1;
898
+ if (ageHours <= 168) return 0.58;
899
+ if (ageHours <= 720) return 0.28;
900
+ return 0.08;
901
+ }
902
+
903
+ function colorFor(record, cluster) {
904
+ return paletteColor(cluster.id || record.type || "default");
905
+ }
906
+
907
+ function paletteColor(id) {
908
+ const colors = ["#ffd66b", "#ff8b72", "#b276ff", "#62e7ff", "#6df0aa", "#e96dae", "#f4d57e", "#7de3ff"];
909
+ return colors[Math.abs(hash(id)) % colors.length];
910
+ }
911
+
912
+ function hash(input) {
913
+ let h = 2166136261;
914
+ const text = String(input);
915
+ for (let i = 0; i < text.length; i += 1) {
916
+ h ^= text.charCodeAt(i);
917
+ h = Math.imul(h, 16777619);
918
+ }
919
+ return h >>> 0;
920
+ }