portable-agent-layer 0.41.1 → 0.42.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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Knowledge graph — associative navigation over the markdown-per-entity store.
3
+ *
4
+ * Builds an in-memory graph from the files written by ./lib.ts. Three edge
5
+ * types, weighted, no persistence:
6
+ *
7
+ * related weight 5 typed pointer from frontmatter `related:` array
8
+ * wikilink weight 3 [[slug]] reference anywhere in the body
9
+ * tag weight 1 two entities share a tag (bidirectional, capped at
10
+ * 50 nodes per tag to prevent O(n²) on popular tags)
11
+ *
12
+ * Ported from PAI's KnowledgeGraph.ts. Computed fresh on every call — no
13
+ * graph state lives on disk.
14
+ */
15
+
16
+ import { type Domain, type Entity, list } from "./lib";
17
+
18
+ // --- Constants --------------------------------------------------------------
19
+
20
+ const WEIGHT_RELATED = 5;
21
+ const WEIGHT_WIKILINK = 3;
22
+ const WEIGHT_TAG = 1;
23
+ const TAG_GROUP_CAP = 50;
24
+ const WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
25
+
26
+ // --- Types ------------------------------------------------------------------
27
+
28
+ /** @lintignore — consumed by Phase 4 query CLI (renders nodes by domain/title) */
29
+ export interface GraphNode {
30
+ slug: string;
31
+ domain: Domain;
32
+ title: string;
33
+ type: string;
34
+ tags: string[];
35
+ }
36
+
37
+ /** @lintignore — consumed by Phase 4 query CLI for edge-type filtering */
38
+ export type EdgeType = "related" | "wikilink" | "tag";
39
+
40
+ export interface GraphEdge {
41
+ from: string;
42
+ to: string;
43
+ weight: number;
44
+ edgeType: EdgeType;
45
+ label?: string;
46
+ }
47
+
48
+ export interface KnowledgeGraph {
49
+ nodes: Map<string, GraphNode>;
50
+ edges: GraphEdge[];
51
+ /** Outgoing edges keyed by `edge.from` slug. */
52
+ adjacency: Map<string, GraphEdge[]>;
53
+ /**
54
+ * Incoming edges keyed by `edge.to` slug. Lets BFS walk against edge
55
+ * direction (e.g. a company reached from its referencing people). The
56
+ * GraphEdge objects are SHARED with `adjacency` — direction info is
57
+ * preserved, not mirrored into reversed copies.
58
+ */
59
+ reverseAdjacency: Map<string, GraphEdge[]>;
60
+ }
61
+
62
+ export interface TraversalNode {
63
+ node: GraphNode;
64
+ hop: number;
65
+ cumulativeWeight: number;
66
+ viaEdge?: GraphEdge;
67
+ }
68
+
69
+ export interface GraphStats {
70
+ nodes: number;
71
+ nodesByDomain: Record<Domain, number>;
72
+ edges: number;
73
+ edgesByType: Record<EdgeType, number>;
74
+ avgConnections: number;
75
+ isolatedNodes: number;
76
+ mostConnected: { slug: string; count: number } | null;
77
+ }
78
+
79
+ // --- Wikilink extraction ----------------------------------------------------
80
+
81
+ /**
82
+ * Extract wikilink targets from a body string.
83
+ *
84
+ * - `[[slug]]` → slug
85
+ * - `[[domain/slug]]` → slug (drop the path prefix, match PAI)
86
+ * - `[[slug|display text]]` → slug
87
+ * - leading-underscore slugs (`_index`, `_log`) are filtered out
88
+ */
89
+ export function extractWikilinks(body: string): string[] {
90
+ const out: string[] = [];
91
+ const matches = body.matchAll(WIKILINK_REGEX);
92
+ for (const m of matches) {
93
+ const raw = m[1].trim();
94
+ const slug = raw.includes("/") ? (raw.split("/").pop() ?? raw) : raw;
95
+ if (slug && !slug.startsWith("_")) out.push(slug);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // --- Graph construction -----------------------------------------------------
101
+
102
+ function pushEdge(graph: KnowledgeGraph, edge: GraphEdge, bidirectional = false): void {
103
+ graph.edges.push(edge);
104
+ const adj = graph.adjacency.get(edge.from);
105
+ if (adj) {
106
+ adj.push(edge);
107
+ } else {
108
+ graph.adjacency.set(edge.from, [edge]);
109
+ }
110
+ // Mirror into reverse adjacency so traverse can walk against direction.
111
+ // Same edge object — no copies, no direction info lost.
112
+ const radj = graph.reverseAdjacency.get(edge.to);
113
+ if (radj) {
114
+ radj.push(edge);
115
+ } else {
116
+ graph.reverseAdjacency.set(edge.to, [edge]);
117
+ }
118
+ if (!bidirectional) return;
119
+ const back: GraphEdge = {
120
+ from: edge.to,
121
+ to: edge.from,
122
+ weight: edge.weight,
123
+ edgeType: edge.edgeType,
124
+ label: edge.label,
125
+ };
126
+ graph.edges.push(back);
127
+ const backAdj = graph.adjacency.get(back.from);
128
+ if (backAdj) {
129
+ backAdj.push(back);
130
+ } else {
131
+ graph.adjacency.set(back.from, [back]);
132
+ }
133
+ const backRadj = graph.reverseAdjacency.get(back.to);
134
+ if (backRadj) {
135
+ backRadj.push(back);
136
+ } else {
137
+ graph.reverseAdjacency.set(back.to, [back]);
138
+ }
139
+ }
140
+
141
+ function buildNodes(entities: Entity[]): Map<string, GraphNode> {
142
+ const nodes = new Map<string, GraphNode>();
143
+ for (const e of entities) {
144
+ nodes.set(e.slug, {
145
+ slug: e.slug,
146
+ domain: e.domain,
147
+ title: e.frontmatter.title,
148
+ type: e.frontmatter.type,
149
+ tags: e.frontmatter.tags.map((t) => t.toLowerCase()),
150
+ });
151
+ }
152
+ return nodes;
153
+ }
154
+
155
+ function buildExplicitEdges(graph: KnowledgeGraph, entities: Entity[]): void {
156
+ for (const e of entities) {
157
+ // Wikilinks
158
+ const wikilinks = extractWikilinks(e.body);
159
+ for (const target of wikilinks) {
160
+ if (!graph.nodes.has(target) || target === e.slug) continue;
161
+ pushEdge(graph, {
162
+ from: e.slug,
163
+ to: target,
164
+ weight: WEIGHT_WIKILINK,
165
+ edgeType: "wikilink",
166
+ });
167
+ }
168
+
169
+ // Related (typed)
170
+ for (const rel of e.frontmatter.related) {
171
+ if (!graph.nodes.has(rel.slug) || rel.slug === e.slug) continue;
172
+ pushEdge(graph, {
173
+ from: e.slug,
174
+ to: rel.slug,
175
+ weight: WEIGHT_RELATED,
176
+ edgeType: "related",
177
+ label: rel.type,
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ function buildTagEdges(graph: KnowledgeGraph): void {
184
+ const tagIndex = new Map<string, string[]>();
185
+ for (const node of graph.nodes.values()) {
186
+ for (const tag of node.tags) {
187
+ // ISC-18: topic:* tags are facet filters, not structural links.
188
+ // Skip them here so two unrelated entities that merely share a
189
+ // broad topic (e.g. "ai") don't get a phantom navigation edge.
190
+ if (tag.startsWith("topic:")) continue;
191
+ const list = tagIndex.get(tag);
192
+ if (list) list.push(node.slug);
193
+ else tagIndex.set(tag, [node.slug]);
194
+ }
195
+ }
196
+
197
+ const seen = new Set<string>();
198
+ for (const [tag, slugs] of tagIndex) {
199
+ if (slugs.length < 2) continue;
200
+ // Deterministic ordering before capping so behavior doesn't depend on
201
+ // `list()` iteration order across platforms.
202
+ const sorted = [...slugs].sort();
203
+ const group = sorted.length > TAG_GROUP_CAP ? sorted.slice(0, TAG_GROUP_CAP) : sorted;
204
+ for (let i = 0; i < group.length; i++) {
205
+ for (let j = i + 1; j < group.length; j++) {
206
+ const a = group[i];
207
+ const b = group[j];
208
+ const key = `${a}|${b}|${tag}`;
209
+ if (seen.has(key)) continue;
210
+ seen.add(key);
211
+ pushEdge(
212
+ graph,
213
+ {
214
+ from: a,
215
+ to: b,
216
+ weight: WEIGHT_TAG,
217
+ edgeType: "tag",
218
+ label: tag,
219
+ },
220
+ true
221
+ );
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ export function buildGraph(rootDir?: string): KnowledgeGraph {
228
+ const entities = list(undefined, rootDir);
229
+ const nodes = buildNodes(entities);
230
+ const graph: KnowledgeGraph = {
231
+ nodes,
232
+ edges: [],
233
+ adjacency: new Map(),
234
+ reverseAdjacency: new Map(),
235
+ };
236
+ buildExplicitEdges(graph, entities);
237
+ buildTagEdges(graph);
238
+ return graph;
239
+ }
240
+
241
+ // --- Slug resolution --------------------------------------------------------
242
+
243
+ /**
244
+ * Resolve a user query to a slug.
245
+ * exact match → that slug
246
+ * one substring match → that slug
247
+ * multiple substring → shortest (most specific) wins
248
+ * none → null
249
+ */
250
+ export function resolveSlug(graph: KnowledgeGraph, query: string): string | null {
251
+ const q = query.toLowerCase();
252
+ if (graph.nodes.has(q)) return q;
253
+
254
+ const candidates: string[] = [];
255
+ for (const slug of graph.nodes.keys()) {
256
+ if (slug.includes(q)) candidates.push(slug);
257
+ }
258
+ if (candidates.length === 0) return null;
259
+ candidates.sort((a, b) => a.length - b.length || a.localeCompare(b));
260
+ return candidates[0];
261
+ }
262
+
263
+ // --- Traversal --------------------------------------------------------------
264
+
265
+ /**
266
+ * BFS from `startSlug` up to `maxHops`. For each frontier node, we pick the
267
+ * highest-weight edge per target so traversal favors `related` (5) over
268
+ * `wikilink` (3) over `tag` (1). Visited tracking prevents re-enqueueing.
269
+ */
270
+ export function traverse(
271
+ graph: KnowledgeGraph,
272
+ startSlug: string,
273
+ maxHops: number
274
+ ): TraversalNode[] {
275
+ const out: TraversalNode[] = [];
276
+ const start = graph.nodes.get(startSlug);
277
+ if (!start) return out;
278
+
279
+ const visited = new Set<string>([startSlug]);
280
+ type QueueEntry = {
281
+ slug: string;
282
+ hop: number;
283
+ cumWeight: number;
284
+ via?: GraphEdge;
285
+ };
286
+ const queue: QueueEntry[] = [{ slug: startSlug, hop: 0, cumWeight: 0 }];
287
+
288
+ while (queue.length > 0) {
289
+ const entry = queue.shift();
290
+ if (!entry) break;
291
+ const node = graph.nodes.get(entry.slug);
292
+ if (!node) continue;
293
+ out.push({
294
+ node,
295
+ hop: entry.hop,
296
+ cumulativeWeight: entry.cumWeight,
297
+ viaEdge: entry.via,
298
+ });
299
+ if (entry.hop >= maxHops) continue;
300
+
301
+ const outgoing = graph.adjacency.get(entry.slug) ?? [];
302
+ const incoming = graph.reverseAdjacency.get(entry.slug) ?? [];
303
+ const bestPerTarget = new Map<string, GraphEdge>();
304
+ for (const edge of outgoing) {
305
+ if (visited.has(edge.to)) continue;
306
+ const existing = bestPerTarget.get(edge.to);
307
+ if (!existing || edge.weight > existing.weight) {
308
+ bestPerTarget.set(edge.to, edge);
309
+ }
310
+ }
311
+ for (const edge of incoming) {
312
+ // edge.to is the current node; edge.from is the "other" endpoint.
313
+ const other = edge.from;
314
+ if (visited.has(other) || other === entry.slug) continue;
315
+ const existing = bestPerTarget.get(other);
316
+ if (!existing || edge.weight > existing.weight) {
317
+ bestPerTarget.set(other, edge);
318
+ }
319
+ }
320
+
321
+ const sorted = [...bestPerTarget.entries()].sort(
322
+ (a, b) => b[1].weight - a[1].weight || a[0].localeCompare(b[0])
323
+ );
324
+ for (const [target, edge] of sorted) {
325
+ if (visited.has(target)) continue;
326
+ visited.add(target);
327
+ queue.push({
328
+ slug: target,
329
+ hop: entry.hop + 1,
330
+ cumWeight: entry.cumWeight + edge.weight,
331
+ via: edge,
332
+ });
333
+ }
334
+ }
335
+
336
+ return out;
337
+ }
338
+
339
+ // --- Stats ------------------------------------------------------------------
340
+
341
+ export function stats(graph: KnowledgeGraph): GraphStats {
342
+ const nodesByDomain: Record<Domain, number> = {
343
+ People: 0,
344
+ Companies: 0,
345
+ Ideas: 0,
346
+ Research: 0,
347
+ };
348
+ for (const node of graph.nodes.values()) {
349
+ nodesByDomain[node.domain]++;
350
+ }
351
+
352
+ const edgesByType: Record<EdgeType, number> = {
353
+ related: 0,
354
+ wikilink: 0,
355
+ tag: 0,
356
+ };
357
+ for (const edge of graph.edges) {
358
+ edgesByType[edge.edgeType]++;
359
+ }
360
+
361
+ const connections = new Map<string, Set<string>>();
362
+ for (const edge of graph.edges) {
363
+ const a = connections.get(edge.from) ?? new Set<string>();
364
+ a.add(edge.to);
365
+ connections.set(edge.from, a);
366
+ const b = connections.get(edge.to) ?? new Set<string>();
367
+ b.add(edge.from);
368
+ connections.set(edge.to, b);
369
+ }
370
+
371
+ let isolated = 0;
372
+ let mostConnected: { slug: string; count: number } | null = null;
373
+ let totalConnCount = 0;
374
+ for (const slug of graph.nodes.keys()) {
375
+ const peers = connections.get(slug);
376
+ const count = peers ? peers.size : 0;
377
+ totalConnCount += count;
378
+ if (count === 0) isolated++;
379
+ if (!mostConnected || count > mostConnected.count) {
380
+ mostConnected = { slug, count };
381
+ }
382
+ }
383
+
384
+ const avg = graph.nodes.size > 0 ? totalConnCount / graph.nodes.size : 0;
385
+
386
+ return {
387
+ nodes: graph.nodes.size,
388
+ nodesByDomain,
389
+ edges: graph.edges.length,
390
+ edgesByType,
391
+ avgConnections: Math.round(avg * 10) / 10,
392
+ isolatedNodes: isolated,
393
+ mostConnected: mostConnected && mostConnected.count > 0 ? mostConnected : null,
394
+ };
395
+ }