@dreamtree-org/graphify 1.3.0 → 1.5.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.
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  globToRegExp,
15
15
  graphForDirectory,
16
16
  loadResults,
17
+ mergeExtractionsInto,
17
18
  mergeGraphs,
18
19
  renderLessons,
19
20
  renderReport,
@@ -24,7 +25,7 @@ import {
24
25
  saveResult,
25
26
  validateExtraction,
26
27
  validateRules
27
- } from "./chunk-UBXYMMXJ.js";
28
+ } from "./chunk-EHMBINRV.js";
28
29
  import {
29
30
  LocalFileGraphStore,
30
31
  affectedBy,
@@ -33,6 +34,7 @@ import {
33
34
  explainNode,
34
35
  isTestFile,
35
36
  loadGraph,
37
+ nodeTokenCost,
36
38
  queryGraph,
37
39
  rankForTask,
38
40
  renderContextPack,
@@ -42,11 +44,445 @@ import {
42
44
  shortestPath,
43
45
  testsForChangedFiles,
44
46
  testsForNode
45
- } from "./chunk-K322XB4U.js";
47
+ } from "./chunk-7LTO76UD.js";
46
48
  import {
47
49
  extractMysql
48
50
  } from "./chunk-ZPB37LLQ.js";
49
51
  import "./chunk-6JLEILYF.js";
52
+
53
+ // src/ingest/document.ts
54
+ import { createHash } from "crypto";
55
+ import * as posix from "path/posix";
56
+ var DEFAULT_TARGET_TOKENS = 400;
57
+ var DEFAULT_MAX_TOKENS = 512;
58
+ var CHUNK_LABEL_LEN = 60;
59
+ function estimateTokens(text) {
60
+ return Math.ceil(text.length / 4);
61
+ }
62
+ function contentHash(content) {
63
+ const normalized = content.replace(/\r\n/g, "\n").trim();
64
+ return createHash("sha256").update(normalized, "utf8").digest("hex");
65
+ }
66
+ function chunkLabel(content) {
67
+ const collapsed = content.replace(/\s+/g, " ").trim();
68
+ return collapsed.length > CHUNK_LABEL_LEN ? `${collapsed.slice(0, CHUNK_LABEL_LEN - 1)}\u2026` : collapsed;
69
+ }
70
+ function parseBlocks(text, honorHeadings) {
71
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
72
+ const blocks = [];
73
+ let paragraph = [];
74
+ let paragraphLine = 0;
75
+ let inFence = false;
76
+ const flush = () => {
77
+ if (paragraph.length === 0) return;
78
+ blocks.push({ type: "paragraph", text: paragraph.join("\n"), depth: 0, line: paragraphLine });
79
+ paragraph = [];
80
+ };
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ if (/^(```|~~~)/.test(line.trim())) {
84
+ if (paragraph.length === 0) paragraphLine = i + 1;
85
+ paragraph.push(line);
86
+ inFence = !inFence;
87
+ continue;
88
+ }
89
+ if (inFence) {
90
+ paragraph.push(line);
91
+ continue;
92
+ }
93
+ const heading = honorHeadings ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line) : null;
94
+ if (heading) {
95
+ flush();
96
+ blocks.push({
97
+ type: "heading",
98
+ text: heading[2].trim(),
99
+ depth: heading[1].length,
100
+ line: i + 1
101
+ });
102
+ continue;
103
+ }
104
+ if (line.trim() === "") {
105
+ flush();
106
+ continue;
107
+ }
108
+ if (paragraph.length === 0) paragraphLine = i + 1;
109
+ paragraph.push(line);
110
+ }
111
+ flush();
112
+ return blocks;
113
+ }
114
+ function splitLongParagraph(text, maxTokens) {
115
+ const maxChars = maxTokens * 4;
116
+ const pieces = [];
117
+ let rest = text;
118
+ while (rest.length > maxChars) {
119
+ let cut = rest.lastIndexOf(" ", maxChars);
120
+ const newlineCut = rest.lastIndexOf("\n", maxChars);
121
+ cut = Math.max(cut, newlineCut);
122
+ if (cut <= 0) cut = maxChars;
123
+ pieces.push(rest.slice(0, cut));
124
+ rest = rest.slice(cut).trimStart();
125
+ }
126
+ if (rest.length > 0) pieces.push(rest);
127
+ return pieces;
128
+ }
129
+ function extractLinkTargets(content, sourceRef) {
130
+ const targets = /* @__PURE__ */ new Set();
131
+ const linkPattern = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
132
+ const dir = posix.dirname(sourceRef);
133
+ for (const match of content.matchAll(linkPattern)) {
134
+ const raw = match[1].split("#")[0];
135
+ if (raw === "" || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;
136
+ targets.add(posix.normalize(posix.join(dir === "." ? "" : dir, raw)));
137
+ }
138
+ return [...targets].sort((a, b) => a.localeCompare(b));
139
+ }
140
+ async function ingestDocument(input, options = {}) {
141
+ if (input.sourceRef.trim() === "") throw new Error("ingestDocument: sourceRef must be non-empty");
142
+ const targetTokens = options.chunking?.targetTokens ?? DEFAULT_TARGET_TOKENS;
143
+ const maxTokens = Math.max(options.chunking?.maxTokens ?? DEFAULT_MAX_TOKENS, targetTokens);
144
+ const overlapTokens = options.chunking?.overlapTokens ?? 0;
145
+ const metadata = input.metadata ?? {};
146
+ const sourceRef = input.sourceRef;
147
+ const honorHeadings = !/^text\/plain\b/i.test(input.mimeType ?? "");
148
+ const blocks = parseBlocks(input.text, honorHeadings);
149
+ const nodes = [];
150
+ const edges = [];
151
+ const documentId = sourceRef;
152
+ nodes.push({
153
+ id: documentId,
154
+ label: input.title ?? posix.basename(sourceRef),
155
+ sourceFile: sourceRef,
156
+ sourceLocation: "L1",
157
+ kind: "document"
158
+ });
159
+ const sectionStack = [];
160
+ const sectionIdCounts = /* @__PURE__ */ new Map();
161
+ let sectionCount = 0;
162
+ const chunks = [];
163
+ let buffer = [];
164
+ let bufferLine = 0;
165
+ let bufferSection = null;
166
+ let previousChunkId = null;
167
+ const flushChunk = () => {
168
+ const content = buffer.join("\n\n");
169
+ buffer = [];
170
+ if (content.trim() === "") return;
171
+ const index = chunks.length;
172
+ const nodeId = `${sourceRef}::chunk:${String(index).padStart(4, "0")}`;
173
+ chunks.push({
174
+ nodeId,
175
+ content,
176
+ tokenEstimate: estimateTokens(content),
177
+ index,
178
+ sectionPath: bufferSection?.path ?? [],
179
+ contentHash: contentHash(content),
180
+ metadata: { ...metadata }
181
+ });
182
+ nodes.push({
183
+ id: nodeId,
184
+ label: chunkLabel(content),
185
+ sourceFile: sourceRef,
186
+ sourceLocation: `L${bufferLine}`,
187
+ kind: "chunk"
188
+ });
189
+ edges.push({
190
+ source: bufferSection?.id ?? documentId,
191
+ target: nodeId,
192
+ relation: "contains",
193
+ confidence: "EXTRACTED"
194
+ });
195
+ if (previousChunkId !== null) {
196
+ edges.push({ source: previousChunkId, target: nodeId, relation: "follows", confidence: "EXTRACTED" });
197
+ }
198
+ previousChunkId = nodeId;
199
+ for (const target of extractLinkTargets(content, sourceRef)) {
200
+ if (target === sourceRef) continue;
201
+ edges.push({ source: nodeId, target, relation: "references", confidence: "INFERRED" });
202
+ }
203
+ if (overlapTokens > 0) {
204
+ const tailChars = overlapTokens * 4;
205
+ const tail = content.length > tailChars ? content.slice(content.indexOf(" ", content.length - tailChars) + 1) : "";
206
+ if (tail.trim() !== "") {
207
+ buffer.push(tail);
208
+ }
209
+ }
210
+ };
211
+ for (const block of blocks) {
212
+ if (block.type === "heading") {
213
+ flushChunk();
214
+ buffer = [];
215
+ while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].depth >= block.depth) {
216
+ sectionStack.pop();
217
+ }
218
+ const parent = sectionStack[sectionStack.length - 1];
219
+ const path = [...parent?.path ?? [], block.text];
220
+ const baseId = `${sourceRef}::sec:${path.join("/")}`;
221
+ const seen = sectionIdCounts.get(baseId) ?? 0;
222
+ sectionIdCounts.set(baseId, seen + 1);
223
+ const id = seen === 0 ? baseId : `${baseId}#${seen + 1}`;
224
+ nodes.push({
225
+ id,
226
+ label: block.text,
227
+ sourceFile: sourceRef,
228
+ sourceLocation: `L${block.line}`,
229
+ kind: "section"
230
+ });
231
+ edges.push({ source: parent?.id ?? documentId, target: id, relation: "contains", confidence: "EXTRACTED" });
232
+ sectionStack.push({ id, depth: block.depth, path });
233
+ sectionCount += 1;
234
+ bufferSection = sectionStack[sectionStack.length - 1];
235
+ bufferLine = block.line + 1;
236
+ continue;
237
+ }
238
+ const pieces = estimateTokens(block.text) > maxTokens ? splitLongParagraph(block.text, maxTokens) : [block.text];
239
+ for (const piece of pieces) {
240
+ const bufferTokens = estimateTokens(buffer.join("\n\n"));
241
+ if (buffer.length > 0 && bufferTokens + estimateTokens(piece) > targetTokens) {
242
+ flushChunk();
243
+ }
244
+ if (buffer.length === 0) bufferLine = block.line;
245
+ buffer.push(piece);
246
+ }
247
+ }
248
+ flushChunk();
249
+ let entities = 0;
250
+ if (options.semanticExtractor) {
251
+ const semantic = validateExtraction(
252
+ await options.semanticExtractor.extractSemantic(sourceRef, input.text),
253
+ `semantic pass for ${sourceRef}`
254
+ );
255
+ entities = semantic.nodes.length;
256
+ nodes.push(...semantic.nodes);
257
+ edges.push(...semantic.edges);
258
+ }
259
+ const extraction = validateExtraction({ nodes, edges }, `ingestDocument(${sourceRef})`);
260
+ return {
261
+ chunks,
262
+ extraction,
263
+ stats: {
264
+ sections: sectionCount,
265
+ entities,
266
+ tokenTotal: chunks.reduce((sum, chunk) => sum + chunk.tokenEstimate, 0)
267
+ }
268
+ };
269
+ }
270
+
271
+ // src/ingest/corpus.ts
272
+ import Graph from "graphology";
273
+ var CLUSTER_STALE_ATTR = "graphifyClusterStale";
274
+ var DEFAULT_RECLUSTER_RATIO = 0.1;
275
+ function assignLocalCommunities(graph) {
276
+ const communityMeta = /* @__PURE__ */ new Map();
277
+ let maxCommunity = -1;
278
+ graph.forEachNode((_id, attrs) => {
279
+ const community = attrs.community;
280
+ if (typeof community !== "number") return;
281
+ if (community > maxCommunity) maxCommunity = community;
282
+ if (!communityMeta.has(community)) {
283
+ communityMeta.set(community, {
284
+ label: attrs.communityLabel,
285
+ hash: attrs.communityHash
286
+ });
287
+ }
288
+ });
289
+ const unassigned = graph.filterNodes((_id, attrs) => attrs.community === void 0).sort((a, b) => a.localeCompare(b));
290
+ for (const id of unassigned) {
291
+ const votes = /* @__PURE__ */ new Map();
292
+ graph.forEachNeighbor(id, (_neighbor, attrs) => {
293
+ const community = attrs.community;
294
+ if (typeof community === "number") votes.set(community, (votes.get(community) ?? 0) + 1);
295
+ });
296
+ let adopted = null;
297
+ let bestVotes = 0;
298
+ for (const [community, count] of votes) {
299
+ if (count > bestVotes || count === bestVotes && adopted !== null && community < adopted) {
300
+ adopted = community;
301
+ bestVotes = count;
302
+ }
303
+ }
304
+ if (adopted === null) {
305
+ adopted = ++maxCommunity;
306
+ communityMeta.set(adopted, {
307
+ label: graph.getNodeAttribute(id, "label") ?? id
308
+ });
309
+ }
310
+ const meta = communityMeta.get(adopted);
311
+ graph.mergeNodeAttributes(id, {
312
+ community: adopted,
313
+ communityLabel: meta?.label ?? `community-${adopted}`,
314
+ ...meta?.hash !== void 0 ? { communityHash: meta.hash } : {}
315
+ });
316
+ }
317
+ }
318
+ function updateCorpusGraph(graph, extraction, options = {}) {
319
+ const validated = validateExtraction(extraction, "updateCorpusGraph");
320
+ const target = graph ?? new Graph({ type: "directed", multi: true, allowSelfLoops: true });
321
+ const sourceFiles = new Set(validated.nodes.map((node) => node.sourceFile));
322
+ const stale = target.filterNodes((_id, attrs) => sourceFiles.has(attrs.sourceFile));
323
+ for (const id of stale) target.dropNode(id);
324
+ const extractionIds = new Set(validated.nodes.map((node) => node.id));
325
+ const addedReal = validated.nodes.filter((node) => !target.hasNode(node.id)).length;
326
+ const newPlaceholders = /* @__PURE__ */ new Set();
327
+ for (const edge of validated.edges) {
328
+ for (const endpoint of [edge.source, edge.target]) {
329
+ if (!extractionIds.has(endpoint) && !target.hasNode(endpoint)) newPlaceholders.add(endpoint);
330
+ }
331
+ }
332
+ mergeExtractionsInto(target, [validated]);
333
+ const orphaned = target.filterNodes(
334
+ (id, attrs) => attrs.sourceFile === "<unknown>" && target.degree(id) === 0
335
+ );
336
+ for (const id of orphaned) target.dropNode(id);
337
+ const churn = stale.length + addedReal + newPlaceholders.size + orphaned.length;
338
+ const staleCount = (Number(target.getAttribute(CLUSTER_STALE_ATTR)) || 0) + churn;
339
+ const mode = options.recluster ?? "auto";
340
+ const ratio = options.reclusterRatio ?? DEFAULT_RECLUSTER_RATIO;
341
+ const neverClustered = target.order > 0 && target.findNode((_id, attrs) => attrs.community !== void 0) === void 0;
342
+ if (mode === true || mode === "auto" && (neverClustered || staleCount > target.order * ratio)) {
343
+ cluster(target, { algorithm: options.algorithm });
344
+ target.setAttribute(CLUSTER_STALE_ATTR, 0);
345
+ } else {
346
+ if (mode === "auto") assignLocalCommunities(target);
347
+ target.setAttribute(CLUSTER_STALE_ATTR, staleCount);
348
+ }
349
+ return target;
350
+ }
351
+
352
+ // src/retrieval.ts
353
+ var DEFAULT_TOKEN_BUDGET = 2e3;
354
+ var DEFAULT_MAX_HOPS = 2;
355
+ function resolveSeeds(graph, hits) {
356
+ const seeds = /* @__PURE__ */ new Map();
357
+ for (const hit of hits) {
358
+ let id = null;
359
+ if (hit.nodeId !== void 0 && graph.hasNode(hit.nodeId)) {
360
+ id = hit.nodeId;
361
+ } else if (hit.text !== void 0) {
362
+ id = resolveNode(graph, hit.text)?.id ?? null;
363
+ }
364
+ const key = id ?? hit.nodeId ?? hit.text ?? "";
365
+ if (key === "") continue;
366
+ const existing = seeds.get(key);
367
+ if (existing) {
368
+ if (hit.score !== void 0 && (existing.score === void 0 || hit.score > existing.score)) {
369
+ existing.score = hit.score;
370
+ }
371
+ continue;
372
+ }
373
+ seeds.set(key, { id: key, score: hit.score, inGraph: id !== null, text: hit.text });
374
+ }
375
+ return [...seeds.values()];
376
+ }
377
+ function expandRetrieval(graph, hits, options = {}) {
378
+ const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
379
+ const maxHops = options.maxHops ?? DEFAULT_MAX_HOPS;
380
+ const relations = options.relations ? new Set(options.relations) : null;
381
+ const seeds = resolveSeeds(graph, hits);
382
+ const graphSeeds = seeds.filter((s) => s.inGraph);
383
+ const seedScore = new Map(graphSeeds.map((s) => [s.id, s.score]));
384
+ const hops = /* @__PURE__ */ new Map();
385
+ const via = /* @__PURE__ */ new Map();
386
+ const frontier = [];
387
+ let spentTokens = 0;
388
+ for (const seed of graphSeeds) {
389
+ hops.set(seed.id, 0);
390
+ frontier.push(seed.id);
391
+ spentTokens += nodeTokenCost(graph, seed.id);
392
+ }
393
+ let truncated = false;
394
+ while (frontier.length > 0) {
395
+ const current = frontier.shift();
396
+ const currentHops = hops.get(current);
397
+ if (currentHops >= maxHops) continue;
398
+ const candidates = [];
399
+ graph.forEachEdge(current, (_key, attrs, source, target) => {
400
+ const relation = String(attrs.relation);
401
+ if (relations && !relations.has(relation)) return;
402
+ const neighbor = source === current ? target : source;
403
+ candidates.push({
404
+ neighbor,
405
+ edge: { source, target, relation, confidence: String(attrs.confidence) }
406
+ });
407
+ });
408
+ candidates.sort((a, b) => a.neighbor.localeCompare(b.neighbor));
409
+ for (const { neighbor, edge } of candidates) {
410
+ if (hops.has(neighbor)) continue;
411
+ const cost = nodeTokenCost(graph, neighbor);
412
+ if (spentTokens + cost > tokenBudget) {
413
+ truncated = true;
414
+ continue;
415
+ }
416
+ spentTokens += cost;
417
+ hops.set(neighbor, currentHops + 1);
418
+ via.set(neighbor, edge);
419
+ frontier.push(neighbor);
420
+ }
421
+ }
422
+ const degree = (id) => graph.degree(id);
423
+ const nodes = [...hops.entries()].map(([id, hopCount]) => {
424
+ const attrs = graph.getNodeAttributes(id);
425
+ return {
426
+ id,
427
+ label: attrs.label ?? id,
428
+ ...attrs.kind !== void 0 ? { kind: String(attrs.kind) } : {},
429
+ sourceFile: attrs.sourceFile ?? "",
430
+ sourceLocation: attrs.sourceLocation ?? "",
431
+ hops: hopCount,
432
+ ...via.has(id) ? { via: via.get(id) } : {},
433
+ ...hopCount === 0 && seedScore.get(id) !== void 0 ? { seedScore: seedScore.get(id) } : {}
434
+ };
435
+ });
436
+ for (const seed of seeds) {
437
+ if (seed.inGraph) continue;
438
+ nodes.push({
439
+ id: seed.id,
440
+ label: seed.text ?? seed.id,
441
+ sourceFile: "",
442
+ sourceLocation: "",
443
+ hops: 0,
444
+ ...seed.score !== void 0 ? { seedScore: seed.score } : {}
445
+ });
446
+ }
447
+ nodes.sort((a, b) => {
448
+ if (a.hops !== b.hops) return a.hops - b.hops;
449
+ if (a.hops === 0) {
450
+ return (b.seedScore ?? -Infinity) - (a.seedScore ?? -Infinity) || a.id.localeCompare(b.id);
451
+ }
452
+ const degreeDiff = (graph.hasNode(b.id) ? degree(b.id) : 0) - (graph.hasNode(a.id) ? degree(a.id) : 0);
453
+ return degreeDiff || a.id.localeCompare(b.id);
454
+ });
455
+ const included = /* @__PURE__ */ new Set([...hops.keys()]);
456
+ const edges = [];
457
+ graph.forEachEdge((_key, attrs, source, target) => {
458
+ if (!included.has(source) || !included.has(target)) return;
459
+ const relation = String(attrs.relation);
460
+ if (relations && !relations.has(relation)) return;
461
+ edges.push({ source, target, relation, confidence: String(attrs.confidence) });
462
+ });
463
+ edges.sort(
464
+ (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation)
465
+ );
466
+ const communities = /* @__PURE__ */ new Map();
467
+ for (const id of included) {
468
+ const attrs = graph.getNodeAttributes(id);
469
+ const community = attrs.community;
470
+ if (community === void 0) continue;
471
+ const entry = communities.get(community) ?? {
472
+ id: community,
473
+ label: attrs.communityLabel ?? String(community),
474
+ seedCount: 0
475
+ };
476
+ if (hops.get(id) === 0) entry.seedCount += 1;
477
+ communities.set(community, entry);
478
+ }
479
+ return {
480
+ nodes,
481
+ edges,
482
+ communities: [...communities.values()].sort((a, b) => b.seedCount - a.seedCount || a.id - b.id),
483
+ truncated
484
+ };
485
+ }
50
486
  export {
51
487
  EXTRACTOR_REGISTRY,
52
488
  ExtractionCache,
@@ -62,15 +498,18 @@ export {
62
498
  collectFiles,
63
499
  deserializeGraph,
64
500
  diffGraphs,
501
+ expandRetrieval,
65
502
  explainNode,
66
503
  exportGraph,
67
504
  extract,
68
505
  extractMysql,
69
506
  globToRegExp,
70
507
  graphForDirectory,
508
+ ingestDocument,
71
509
  isTestFile,
72
510
  loadGraph,
73
511
  loadResults,
512
+ mergeExtractionsInto,
74
513
  mergeGraphs,
75
514
  queryGraph,
76
515
  rankForTask,
@@ -88,6 +527,7 @@ export {
88
527
  shortestPath,
89
528
  testsForChangedFiles,
90
529
  testsForNode,
530
+ updateCorpusGraph,
91
531
  validateExtraction,
92
532
  validateRules
93
533
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/ingest/document.ts","../src/ingest/corpus.ts","../src/retrieval.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport * as posix from 'node:path/posix';\nimport type { SemanticExtractor } from '../llm/index.js';\nimport { validateExtraction } from '../schema.js';\nimport type { ExtractionResult, GraphEdge, GraphNode } from '../types.js';\n\n/**\n * Smart document ingestion (docs/KB-PROVIDER.md §5): replaces the flat\n * `text -> string[]` chunker contract with `document -> { chunks + graph\n * fragment }`. One document per call — providers ingest from queue workers\n * a page at a time; updateCorpusGraph() (corpus.ts) folds each fragment\n * into the corpus graph. Structural pass only: sections, chunks,\n * contains/follows edges, and explicit relative markdown links — fully\n * offline and deterministic. Entities in prose need the optional\n * caller-injected SemanticExtractor.\n */\n\nexport interface DocumentInput {\n /**\n * Plain text or markdown. Binary parsing (PDF/DOCX/...) is the caller's\n * job — this API takes extracted text.\n */\n text: string;\n /**\n * Stable caller-side identity — becomes GraphNode.sourceFile and the id\n * prefix for every node from this document. Re-ingesting the same\n * sourceRef through updateCorpusGraph() replaces that document's subgraph.\n */\n sourceRef: string;\n /** Routes the structural pass. Markdown headings are honored unless this says text/plain. */\n mimeType?: string;\n title?: string;\n /** Echoed onto every chunk, never interpreted. */\n metadata?: Record<string, string>;\n}\n\nexport interface ChunkOptions {\n /** Soft target per chunk, in estimated tokens (~4 chars each). Default 400. */\n targetTokens?: number;\n /** Hard cap — over-long paragraphs are split to fit. Default 512. */\n maxTokens?: number;\n /**\n * Overlap is OFF by default: `follows` edges + expandRetrieval() replace\n * what sliding-window overlap approximates. Callers migrating from\n * window-chunkers can turn it back on.\n */\n overlapTokens?: number;\n}\n\nexport interface IngestOptions {\n chunking?: ChunkOptions;\n /**\n * Optional semantic pass for entities/relationships in prose. Injected,\n * never built-in — same stance as embeddings (docs/KB-PROVIDER.md §6).\n * Omitted = structural-only, no LLM, no API key.\n */\n semanticExtractor?: SemanticExtractor;\n}\n\nexport interface IngestedChunk {\n /**\n * Graph node id of this chunk — store it next to the chunk row: it is\n * what makes expandRetrieval() hits O(1) instead of lexical.\n */\n nodeId: string;\n /** What the caller embeds and stores. */\n content: string;\n tokenEstimate: number;\n /** Reading order within the document. */\n index: number;\n /** Heading trail, e.g. [\"Q3 Results\", \"Revenue\"]. Empty for plain text. */\n sectionPath: string[];\n /** sha256 of normalized content — caller-side dedup across sources. */\n contentHash: string;\n metadata: Record<string, string>;\n}\n\nexport interface IngestResult {\n chunks: IngestedChunk[];\n /** Graph fragment for this document — feed to updateCorpusGraph() or runPipeline's extraExtractions. */\n extraction: ExtractionResult;\n stats: { sections: number; entities: number; tokenTotal: number };\n}\n\nconst DEFAULT_TARGET_TOKENS = 400;\nconst DEFAULT_MAX_TOKENS = 512;\nconst CHUNK_LABEL_LEN = 60;\n\n/** Chars/4 — the same serviceable approximation the query layer uses. */\nfunction estimateTokens(text: string): number {\n return Math.ceil(text.length / 4);\n}\n\nfunction contentHash(content: string): string {\n const normalized = content.replace(/\\r\\n/g, '\\n').trim();\n return createHash('sha256').update(normalized, 'utf8').digest('hex');\n}\n\nfunction chunkLabel(content: string): string {\n const collapsed = content.replace(/\\s+/g, ' ').trim();\n return collapsed.length > CHUNK_LABEL_LEN ? `${collapsed.slice(0, CHUNK_LABEL_LEN - 1)}…` : collapsed;\n}\n\ninterface Block {\n type: 'heading' | 'paragraph';\n text: string;\n depth: number; // headings only\n line: number; // 1-based line of the block's first line\n}\n\n/**\n * Split the document into heading and paragraph blocks. Fenced code blocks\n * are one paragraph each (blank lines and `#` lines inside a fence are\n * content, not structure). Plain-text mode skips heading detection\n * entirely, so a shell comment at column 0 can't become a section.\n */\nfunction parseBlocks(text: string, honorHeadings: boolean): Block[] {\n const lines = text.replace(/\\r\\n/g, '\\n').split('\\n');\n const blocks: Block[] = [];\n let paragraph: string[] = [];\n let paragraphLine = 0;\n let inFence = false;\n\n const flush = (): void => {\n if (paragraph.length === 0) return;\n blocks.push({ type: 'paragraph', text: paragraph.join('\\n'), depth: 0, line: paragraphLine });\n paragraph = [];\n };\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i] as string;\n\n if (/^(```|~~~)/.test(line.trim())) {\n if (paragraph.length === 0) paragraphLine = i + 1;\n paragraph.push(line);\n inFence = !inFence;\n continue;\n }\n if (inFence) {\n paragraph.push(line);\n continue;\n }\n\n const heading = honorHeadings ? /^(#{1,6})\\s+(.+?)\\s*#*\\s*$/.exec(line) : null;\n if (heading) {\n flush();\n blocks.push({\n type: 'heading',\n text: (heading[2] as string).trim(),\n depth: (heading[1] as string).length,\n line: i + 1,\n });\n continue;\n }\n\n if (line.trim() === '') {\n flush();\n continue;\n }\n if (paragraph.length === 0) paragraphLine = i + 1;\n paragraph.push(line);\n }\n flush();\n return blocks;\n}\n\n/** Split one over-long paragraph into pieces of at most maxTokens, preferring whitespace boundaries. */\nfunction splitLongParagraph(text: string, maxTokens: number): string[] {\n const maxChars = maxTokens * 4;\n const pieces: string[] = [];\n let rest = text;\n while (rest.length > maxChars) {\n let cut = rest.lastIndexOf(' ', maxChars);\n const newlineCut = rest.lastIndexOf('\\n', maxChars);\n cut = Math.max(cut, newlineCut);\n if (cut <= 0) cut = maxChars; // one unbroken run — hard cut\n pieces.push(rest.slice(0, cut));\n rest = rest.slice(cut).trimStart();\n }\n if (rest.length > 0) pieces.push(rest);\n return pieces;\n}\n\n/**\n * Explicit relative markdown links become INFERRED `references` edges to\n * the linked document's id (its sourceRef, resolved against this\n * document's directory) — auto-vivified as a placeholder until that\n * document is ingested, then upgraded in place by updateCorpusGraph().\n * External (http/mailto) and same-document (#anchor) links are skipped.\n */\nfunction extractLinkTargets(content: string, sourceRef: string): string[] {\n const targets = new Set<string>();\n const linkPattern = /\\[[^\\]]*\\]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)/g;\n const dir = posix.dirname(sourceRef);\n for (const match of content.matchAll(linkPattern)) {\n const raw = (match[1] as string).split('#')[0] as string;\n if (raw === '' || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue; // #anchor or scheme'd (http:, mailto:, ...)\n targets.add(posix.normalize(posix.join(dir === '.' ? '' : dir, raw)));\n }\n return [...targets].sort((a, b) => a.localeCompare(b));\n}\n\ninterface Section {\n id: string;\n depth: number;\n path: string[];\n}\n\n/**\n * Turn one document into structure-aware chunks plus a typed graph\n * fragment: a `document` node, `section` nodes nested via `contains`,\n * `chunk` nodes under their section (`contains`), chained in reading order\n * (`follows`), with relative markdown links as `references` edges. The\n * chunks are graph nodes — the caller stores each chunk's nodeId, which is\n * what fuses their vector search with expandRetrieval() at query time.\n */\nexport async function ingestDocument(input: DocumentInput, options: IngestOptions = {}): Promise<IngestResult> {\n if (input.sourceRef.trim() === '') throw new Error('ingestDocument: sourceRef must be non-empty');\n const targetTokens = options.chunking?.targetTokens ?? DEFAULT_TARGET_TOKENS;\n const maxTokens = Math.max(options.chunking?.maxTokens ?? DEFAULT_MAX_TOKENS, targetTokens);\n const overlapTokens = options.chunking?.overlapTokens ?? 0;\n const metadata = input.metadata ?? {};\n const sourceRef = input.sourceRef;\n\n const honorHeadings = !/^text\\/plain\\b/i.test(input.mimeType ?? '');\n const blocks = parseBlocks(input.text, honorHeadings);\n\n const nodes: GraphNode[] = [];\n const edges: GraphEdge[] = [];\n const documentId = sourceRef;\n nodes.push({\n id: documentId,\n label: input.title ?? posix.basename(sourceRef),\n sourceFile: sourceRef,\n sourceLocation: 'L1',\n kind: 'document',\n });\n\n const sectionStack: Section[] = [];\n const sectionIdCounts = new Map<string, number>();\n let sectionCount = 0;\n\n const chunks: IngestedChunk[] = [];\n let buffer: string[] = [];\n let bufferLine = 0;\n let bufferSection: Section | null = null;\n let previousChunkId: string | null = null;\n\n const flushChunk = (): void => {\n const content = buffer.join('\\n\\n');\n buffer = [];\n if (content.trim() === '') return;\n\n const index = chunks.length;\n const nodeId = `${sourceRef}::chunk:${String(index).padStart(4, '0')}`;\n chunks.push({\n nodeId,\n content,\n tokenEstimate: estimateTokens(content),\n index,\n sectionPath: bufferSection?.path ?? [],\n contentHash: contentHash(content),\n metadata: { ...metadata },\n });\n nodes.push({\n id: nodeId,\n label: chunkLabel(content),\n sourceFile: sourceRef,\n sourceLocation: `L${bufferLine}`,\n kind: 'chunk',\n });\n edges.push({\n source: bufferSection?.id ?? documentId,\n target: nodeId,\n relation: 'contains',\n confidence: 'EXTRACTED',\n });\n if (previousChunkId !== null) {\n edges.push({ source: previousChunkId, target: nodeId, relation: 'follows', confidence: 'EXTRACTED' });\n }\n previousChunkId = nodeId;\n\n for (const target of extractLinkTargets(content, sourceRef)) {\n if (target === sourceRef) continue;\n edges.push({ source: nodeId, target, relation: 'references', confidence: 'INFERRED' });\n }\n\n if (overlapTokens > 0) {\n const tailChars = overlapTokens * 4;\n const tail = content.length > tailChars ? content.slice(content.indexOf(' ', content.length - tailChars) + 1) : '';\n if (tail.trim() !== '') {\n buffer.push(tail);\n // The overlap tail belongs to the next chunk, which starts wherever\n // the next paragraph does — keep the current line for reference.\n }\n }\n };\n\n for (const block of blocks) {\n if (block.type === 'heading') {\n flushChunk();\n buffer = []; // an overlap tail never crosses a section boundary\n while (sectionStack.length > 0 && (sectionStack[sectionStack.length - 1] as Section).depth >= block.depth) {\n sectionStack.pop();\n }\n const parent = sectionStack[sectionStack.length - 1];\n const path = [...(parent?.path ?? []), block.text];\n const baseId = `${sourceRef}::sec:${path.join('/')}`;\n const seen = sectionIdCounts.get(baseId) ?? 0;\n sectionIdCounts.set(baseId, seen + 1);\n const id = seen === 0 ? baseId : `${baseId}#${seen + 1}`;\n\n nodes.push({\n id,\n label: block.text,\n sourceFile: sourceRef,\n sourceLocation: `L${block.line}`,\n kind: 'section',\n });\n edges.push({ source: parent?.id ?? documentId, target: id, relation: 'contains', confidence: 'EXTRACTED' });\n sectionStack.push({ id, depth: block.depth, path });\n sectionCount += 1;\n bufferSection = sectionStack[sectionStack.length - 1] as Section;\n bufferLine = block.line + 1;\n continue;\n }\n\n const pieces =\n estimateTokens(block.text) > maxTokens ? splitLongParagraph(block.text, maxTokens) : [block.text];\n for (const piece of pieces) {\n const bufferTokens = estimateTokens(buffer.join('\\n\\n'));\n if (buffer.length > 0 && bufferTokens + estimateTokens(piece) > targetTokens) {\n flushChunk();\n }\n if (buffer.length === 0) bufferLine = block.line;\n buffer.push(piece);\n }\n }\n flushChunk();\n\n let entities = 0;\n if (options.semanticExtractor) {\n const semantic = validateExtraction(\n await options.semanticExtractor.extractSemantic(sourceRef, input.text),\n `semantic pass for ${sourceRef}`,\n );\n entities = semantic.nodes.length;\n nodes.push(...semantic.nodes);\n edges.push(...semantic.edges);\n }\n\n const extraction = validateExtraction({ nodes, edges }, `ingestDocument(${sourceRef})`);\n return {\n chunks,\n extraction,\n stats: {\n sections: sectionCount,\n entities,\n tokenTotal: chunks.reduce((sum, chunk) => sum + chunk.tokenEstimate, 0),\n },\n };\n}\n","import Graph from 'graphology';\nimport { mergeExtractionsInto } from '../build.js';\nimport { cluster, type ClusterAlgorithm } from '../cluster.js';\nimport { validateExtraction } from '../schema.js';\nimport type { ExtractionResult } from '../types.js';\n\n/**\n * Graph-level attribute tracking node churn (adds/drops/prunes) since the\n * last full clustering pass. Serialized with the graph, so the auto\n * re-cluster decision survives store round-trips between worker runs.\n */\nconst CLUSTER_STALE_ATTR = 'graphifyClusterStale';\n\nconst DEFAULT_RECLUSTER_RATIO = 0.1;\n\nexport interface UpdateCorpusOptions {\n algorithm?: ClusterAlgorithm;\n /**\n * Clustering strategy per update. `'auto'` (default) keeps bulk loading\n * safe without the caller thinking about it: new nodes adopt the majority\n * community of their neighbors (cheap, local, deterministic), and a full\n * global re-cluster runs only when accumulated churn since the last full\n * pass exceeds `reclusterRatio` of the graph — or on a never-clustered\n * graph. `true` forces a full re-cluster now (e.g. once after a batch);\n * `false` skips community maintenance entirely for this call (churn is\n * still tracked, so a later `'auto'` call sees the backlog).\n */\n recluster?: boolean | 'auto';\n /**\n * Auto mode only: fraction of the graph's nodes that must have churned\n * since the last full clustering before one is triggered. Default 0.1.\n */\n reclusterRatio?: number;\n}\n\n/**\n * New nodes adopt the majority community among their already-assigned\n * neighbors (ties -> lowest community id); a node with no assigned\n * neighbors starts a fresh community labeled after itself. Processed in\n * sorted-id order so a new document's own nodes cascade deterministically\n * (document node founds the community, its sections/chunks adopt it).\n * communityHash is NOT recomputed here — hashes refresh on the next full\n * cluster() pass; between passes they identify the community's membership\n * as of that last pass.\n */\nfunction assignLocalCommunities(graph: Graph): void {\n const communityMeta = new Map<number, { label?: string; hash?: string }>();\n let maxCommunity = -1;\n graph.forEachNode((_id, attrs) => {\n const community = attrs.community;\n if (typeof community !== 'number') return;\n if (community > maxCommunity) maxCommunity = community;\n if (!communityMeta.has(community)) {\n communityMeta.set(community, {\n label: attrs.communityLabel as string | undefined,\n hash: attrs.communityHash as string | undefined,\n });\n }\n });\n\n const unassigned = graph\n .filterNodes((_id, attrs) => attrs.community === undefined)\n .sort((a, b) => a.localeCompare(b));\n\n for (const id of unassigned) {\n const votes = new Map<number, number>();\n graph.forEachNeighbor(id, (_neighbor, attrs) => {\n const community = attrs.community;\n if (typeof community === 'number') votes.set(community, (votes.get(community) ?? 0) + 1);\n });\n\n let adopted: number | null = null;\n let bestVotes = 0;\n for (const [community, count] of votes) {\n if (count > bestVotes || (count === bestVotes && adopted !== null && community < adopted)) {\n adopted = community;\n bestVotes = count;\n }\n }\n if (adopted === null) {\n adopted = ++maxCommunity;\n communityMeta.set(adopted, {\n label: (graph.getNodeAttribute(id, 'label') as string | undefined) ?? id,\n });\n }\n\n const meta = communityMeta.get(adopted);\n graph.mergeNodeAttributes(id, {\n community: adopted,\n communityLabel: meta?.label ?? `community-${adopted}`,\n ...(meta?.hash !== undefined ? { communityHash: meta.hash } : {}),\n });\n }\n}\n\n/**\n * Fold one document's extraction into an existing corpus graph — the\n * streaming-ingestion companion to ingestDocument() (docs/KB-PROVIDER.md\n * §5). Pass null for the store.load() no-graph-yet case. Re-ingesting a\n * document is replace-not-duplicate: every node whose sourceFile matches\n * one of the extraction's sourceFiles is dropped (with its edges) before\n * the fragment is merged. Placeholder nodes that earlier documents' link\n * edges auto-vivified are upgraded in place when this document is the real\n * thing. Community upkeep is incremental by default and self-tunes to bulk\n * loads — see UpdateCorpusOptions.recluster. Mutates and returns the graph.\n */\nexport function updateCorpusGraph(\n graph: Graph | null,\n extraction: ExtractionResult,\n options: UpdateCorpusOptions = {},\n): Graph {\n const validated = validateExtraction(extraction, 'updateCorpusGraph');\n const target = graph ?? new Graph({ type: 'directed', multi: true, allowSelfLoops: true });\n\n const sourceFiles = new Set(validated.nodes.map((node) => node.sourceFile));\n const stale = target.filterNodes((_id, attrs) => sourceFiles.has(attrs.sourceFile as string));\n for (const id of stale) target.dropNode(id); // drops incident edges too\n\n // Churn accounting for the auto re-cluster decision: dropped + added real\n // nodes + auto-vivified placeholders (edge endpoints in no nodes array).\n const extractionIds = new Set(validated.nodes.map((node) => node.id));\n const addedReal = validated.nodes.filter((node) => !target.hasNode(node.id)).length;\n const newPlaceholders = new Set<string>();\n for (const edge of validated.edges) {\n for (const endpoint of [edge.source, edge.target]) {\n if (!extractionIds.has(endpoint) && !target.hasNode(endpoint)) newPlaceholders.add(endpoint);\n }\n }\n\n mergeExtractionsInto(target, [validated]);\n\n // Placeholders exist only to be edge endpoints (auto-vivified link\n // targets). Replacing a document can orphan the ones only it pointed at —\n // prune any left with no edges.\n const orphaned = target.filterNodes(\n (id, attrs) => attrs.sourceFile === '<unknown>' && target.degree(id) === 0,\n );\n for (const id of orphaned) target.dropNode(id);\n\n const churn = stale.length + addedReal + newPlaceholders.size + orphaned.length;\n const staleCount = (Number(target.getAttribute(CLUSTER_STALE_ATTR)) || 0) + churn;\n\n const mode = options.recluster ?? 'auto';\n const ratio = options.reclusterRatio ?? DEFAULT_RECLUSTER_RATIO;\n const neverClustered =\n target.order > 0 && target.findNode((_id, attrs) => attrs.community !== undefined) === undefined;\n\n if (mode === true || (mode === 'auto' && (neverClustered || staleCount > target.order * ratio))) {\n cluster(target, { algorithm: options.algorithm });\n target.setAttribute(CLUSTER_STALE_ATTR, 0);\n } else {\n if (mode === 'auto') assignLocalCommunities(target);\n target.setAttribute(CLUSTER_STALE_ATTR, staleCount);\n }\n return target;\n}\n","import type Graph from 'graphology';\nimport { nodeTokenCost, resolveNode, type TraversalEdge } from './query.js';\nimport type { Relation } from './types.js';\n\n/**\n * Graph-augmented retrieval (docs/KB-PROVIDER.md §4): the caller has\n * already done similarity search (vector, keyword, hybrid — their\n * pipeline, their embeddings); expandRetrieval() takes those hits and adds\n * what similarity can't see — the nodes *structurally connected* to them.\n * Pure and synchronous like every query function: Graph in, data out; no\n * store access, no embedding calls.\n */\n\n/**\n * One hit from the caller's own search. `nodeId` when the caller stored\n * graph node ids alongside its records at ingest time; otherwise `text`,\n * resolved against the graph lexically (same resolver queryGraph uses).\n */\nexport interface RetrievalHit {\n nodeId?: string;\n text?: string;\n /** The caller's similarity score, echoed through to rank seeds. */\n score?: number;\n}\n\nexport interface ExpandOptions {\n /** Approximate cap, in tokens (~4 chars each), on the result. Default 2000. */\n tokenBudget?: number;\n maxHops?: number;\n /** Restrict traversal (and reported edges) to these relations, e.g. ['contains', 'references']. */\n relations?: Relation[];\n}\n\nexport interface ExpandedNode {\n id: string;\n label: string;\n /** Node kind attr (chunk|section|entity|document|...) — absent on classic code nodes. */\n kind?: string;\n sourceFile: string;\n sourceLocation: string;\n /** 0 = was a seed (or an unresolvable hit echoed through). */\n hops: number;\n /** The edge that first led the traversal here (absent for seeds). */\n via?: TraversalEdge;\n /** The caller's similarity score — seeds only. */\n seedScore?: number;\n}\n\nexport interface ExpandedContext {\n /**\n * Union of seed nodes and discovered neighbors, ranked: seeds first (by\n * caller score desc), then by (hops asc, degree desc). Deduped across\n * seeds. Hits that resolve to no graph node are echoed through with\n * hops 0 and no expansion, so adopting this on a partially-ingested\n * corpus never does worse than flat retrieval.\n */\n nodes: ExpandedNode[];\n /** Every edge between included nodes — provenance for citations. */\n edges: TraversalEdge[];\n communities: Array<{ id: number; label: string; seedCount: number }>;\n /** True when the token budget stopped the traversal before the frontier was exhausted. */\n truncated: boolean;\n}\n\nconst DEFAULT_TOKEN_BUDGET = 2000;\nconst DEFAULT_MAX_HOPS = 2;\n\ninterface Seed {\n id: string;\n score: number | undefined;\n inGraph: boolean;\n /** Fallback label for hits that resolve to no graph node. */\n text?: string;\n}\n\n/**\n * Resolve hits to seed nodes: exact id when it exists in the graph, else\n * lexical resolution of `text`. Duplicate resolutions collapse into one\n * seed keeping the best caller score. Hits that resolve nowhere are kept\n * as out-of-graph seeds so the caller sees every hit accounted for.\n */\nfunction resolveSeeds(graph: Graph, hits: RetrievalHit[]): Seed[] {\n const seeds = new Map<string, Seed>();\n for (const hit of hits) {\n let id: string | null = null;\n if (hit.nodeId !== undefined && graph.hasNode(hit.nodeId)) {\n id = hit.nodeId;\n } else if (hit.text !== undefined) {\n id = resolveNode(graph, hit.text)?.id ?? null;\n }\n\n const key = id ?? hit.nodeId ?? hit.text ?? '';\n if (key === '') continue; // an entirely empty hit carries nothing to resolve or echo\n const existing = seeds.get(key);\n if (existing) {\n if (hit.score !== undefined && (existing.score === undefined || hit.score > existing.score)) {\n existing.score = hit.score;\n }\n continue;\n }\n seeds.set(key, { id: key, score: hit.score, inGraph: id !== null, text: hit.text });\n }\n return [...seeds.values()];\n}\n\n/**\n * Expand the caller's top-K hits through the graph: one joint,\n * token-budgeted BFS seeded from every hit at once, so overlapping\n * neighborhoods dedupe and the budget is shared across seeds. Deterministic\n * (lexical neighbor order, stable ranking) like the rest of the query layer.\n */\nexport function expandRetrieval(graph: Graph, hits: RetrievalHit[], options: ExpandOptions = {}): ExpandedContext {\n const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;\n const maxHops = options.maxHops ?? DEFAULT_MAX_HOPS;\n const relations = options.relations ? new Set<string>(options.relations) : null;\n\n const seeds = resolveSeeds(graph, hits);\n const graphSeeds = seeds.filter((s) => s.inGraph);\n const seedScore = new Map(graphSeeds.map((s) => [s.id, s.score]));\n\n const hops = new Map<string, number>(); // id -> hop distance\n const via = new Map<string, TraversalEdge>();\n const frontier: string[] = [];\n let spentTokens = 0;\n for (const seed of graphSeeds) {\n hops.set(seed.id, 0);\n frontier.push(seed.id);\n spentTokens += nodeTokenCost(graph, seed.id);\n }\n\n let truncated = false;\n while (frontier.length > 0) {\n const current = frontier.shift() as string;\n const currentHops = hops.get(current) as number;\n if (currentHops >= maxHops) continue;\n\n // Collect candidate edges (not bare neighbors) so the relations filter\n // applies and `via` can record how each node was reached.\n const candidates: Array<{ neighbor: string; edge: TraversalEdge }> = [];\n graph.forEachEdge(current, (_key, attrs, source, target) => {\n const relation = String(attrs.relation);\n if (relations && !relations.has(relation)) return;\n const neighbor = source === current ? target : source;\n candidates.push({\n neighbor,\n edge: { source, target, relation, confidence: String(attrs.confidence) },\n });\n });\n candidates.sort((a, b) => a.neighbor.localeCompare(b.neighbor));\n\n for (const { neighbor, edge } of candidates) {\n if (hops.has(neighbor)) continue;\n const cost = nodeTokenCost(graph, neighbor);\n if (spentTokens + cost > tokenBudget) {\n truncated = true;\n continue;\n }\n spentTokens += cost;\n hops.set(neighbor, currentHops + 1);\n via.set(neighbor, edge);\n frontier.push(neighbor);\n }\n }\n\n const degree = (id: string): number => graph.degree(id);\n const nodes: ExpandedNode[] = [...hops.entries()].map(([id, hopCount]) => {\n const attrs = graph.getNodeAttributes(id);\n return {\n id,\n label: (attrs.label as string | undefined) ?? id,\n ...(attrs.kind !== undefined ? { kind: String(attrs.kind) } : {}),\n sourceFile: (attrs.sourceFile as string | undefined) ?? '',\n sourceLocation: (attrs.sourceLocation as string | undefined) ?? '',\n hops: hopCount,\n ...(via.has(id) ? { via: via.get(id) as TraversalEdge } : {}),\n ...(hopCount === 0 && seedScore.get(id) !== undefined ? { seedScore: seedScore.get(id) as number } : {}),\n };\n });\n\n // Out-of-graph hits: echoed through so the result never covers less than\n // the caller's own flat retrieval did.\n for (const seed of seeds) {\n if (seed.inGraph) continue;\n nodes.push({\n id: seed.id,\n label: seed.text ?? seed.id,\n sourceFile: '',\n sourceLocation: '',\n hops: 0,\n ...(seed.score !== undefined ? { seedScore: seed.score } : {}),\n });\n }\n\n nodes.sort((a, b) => {\n if (a.hops !== b.hops) return a.hops - b.hops;\n if (a.hops === 0) {\n return (b.seedScore ?? -Infinity) - (a.seedScore ?? -Infinity) || a.id.localeCompare(b.id);\n }\n const degreeDiff = (graph.hasNode(b.id) ? degree(b.id) : 0) - (graph.hasNode(a.id) ? degree(a.id) : 0);\n return degreeDiff || a.id.localeCompare(b.id);\n });\n\n const included = new Set([...hops.keys()]);\n const edges: TraversalEdge[] = [];\n graph.forEachEdge((_key, attrs, source, target) => {\n if (!included.has(source) || !included.has(target)) return;\n const relation = String(attrs.relation);\n if (relations && !relations.has(relation)) return;\n edges.push({ source, target, relation, confidence: String(attrs.confidence) });\n });\n edges.sort(\n (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation),\n );\n\n const communities = new Map<number, { id: number; label: string; seedCount: number }>();\n for (const id of included) {\n const attrs = graph.getNodeAttributes(id);\n const community = attrs.community as number | undefined;\n if (community === undefined) continue;\n const entry = communities.get(community) ?? {\n id: community,\n label: (attrs.communityLabel as string | undefined) ?? String(community),\n seedCount: 0,\n };\n if ((hops.get(id) as number) === 0) entry.seedCount += 1;\n communities.set(community, entry);\n }\n\n return {\n nodes,\n edges,\n communities: [...communities.values()].sort((a, b) => b.seedCount - a.seedCount || a.id - b.id),\n truncated,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,kBAAkB;AAC3B,YAAY,WAAW;AAmFvB,IAAM,wBAAwB;AAC9B,IAAM,qBAAqB;AAC3B,IAAM,kBAAkB;AAGxB,SAAS,eAAe,MAAsB;AAC5C,SAAO,KAAK,KAAK,KAAK,SAAS,CAAC;AAClC;AAEA,SAAS,YAAY,SAAyB;AAC5C,QAAM,aAAa,QAAQ,QAAQ,SAAS,IAAI,EAAE,KAAK;AACvD,SAAO,WAAW,QAAQ,EAAE,OAAO,YAAY,MAAM,EAAE,OAAO,KAAK;AACrE;AAEA,SAAS,WAAW,SAAyB;AAC3C,QAAM,YAAY,QAAQ,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACpD,SAAO,UAAU,SAAS,kBAAkB,GAAG,UAAU,MAAM,GAAG,kBAAkB,CAAC,CAAC,WAAM;AAC9F;AAeA,SAAS,YAAY,MAAc,eAAiC;AAClE,QAAM,QAAQ,KAAK,QAAQ,SAAS,IAAI,EAAE,MAAM,IAAI;AACpD,QAAM,SAAkB,CAAC;AACzB,MAAI,YAAsB,CAAC;AAC3B,MAAI,gBAAgB;AACpB,MAAI,UAAU;AAEd,QAAM,QAAQ,MAAY;AACxB,QAAI,UAAU,WAAW,EAAG;AAC5B,WAAO,KAAK,EAAE,MAAM,aAAa,MAAM,UAAU,KAAK,IAAI,GAAG,OAAO,GAAG,MAAM,cAAc,CAAC;AAC5F,gBAAY,CAAC;AAAA,EACf;AAEA,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,UAAM,OAAO,MAAM,CAAC;AAEpB,QAAI,aAAa,KAAK,KAAK,KAAK,CAAC,GAAG;AAClC,UAAI,UAAU,WAAW,EAAG,iBAAgB,IAAI;AAChD,gBAAU,KAAK,IAAI;AACnB,gBAAU,CAAC;AACX;AAAA,IACF;AACA,QAAI,SAAS;AACX,gBAAU,KAAK,IAAI;AACnB;AAAA,IACF;AAEA,UAAM,UAAU,gBAAgB,6BAA6B,KAAK,IAAI,IAAI;AAC1E,QAAI,SAAS;AACX,YAAM;AACN,aAAO,KAAK;AAAA,QACV,MAAM;AAAA,QACN,MAAO,QAAQ,CAAC,EAAa,KAAK;AAAA,QAClC,OAAQ,QAAQ,CAAC,EAAa;AAAA,QAC9B,MAAM,IAAI;AAAA,MACZ,CAAC;AACD;AAAA,IACF;AAEA,QAAI,KAAK,KAAK,MAAM,IAAI;AACtB,YAAM;AACN;AAAA,IACF;AACA,QAAI,UAAU,WAAW,EAAG,iBAAgB,IAAI;AAChD,cAAU,KAAK,IAAI;AAAA,EACrB;AACA,QAAM;AACN,SAAO;AACT;AAGA,SAAS,mBAAmB,MAAc,WAA6B;AACrE,QAAM,WAAW,YAAY;AAC7B,QAAM,SAAmB,CAAC;AAC1B,MAAI,OAAO;AACX,SAAO,KAAK,SAAS,UAAU;AAC7B,QAAI,MAAM,KAAK,YAAY,KAAK,QAAQ;AACxC,UAAM,aAAa,KAAK,YAAY,MAAM,QAAQ;AAClD,UAAM,KAAK,IAAI,KAAK,UAAU;AAC9B,QAAI,OAAO,EAAG,OAAM;AACpB,WAAO,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC;AAC9B,WAAO,KAAK,MAAM,GAAG,EAAE,UAAU;AAAA,EACnC;AACA,MAAI,KAAK,SAAS,EAAG,QAAO,KAAK,IAAI;AACrC,SAAO;AACT;AASA,SAAS,mBAAmB,SAAiB,WAA6B;AACxE,QAAM,UAAU,oBAAI,IAAY;AAChC,QAAM,cAAc;AACpB,QAAM,MAAY,cAAQ,SAAS;AACnC,aAAW,SAAS,QAAQ,SAAS,WAAW,GAAG;AACjD,UAAM,MAAO,MAAM,CAAC,EAAa,MAAM,GAAG,EAAE,CAAC;AAC7C,QAAI,QAAQ,MAAM,uBAAuB,KAAK,GAAG,EAAG;AACpD,YAAQ,IAAU,gBAAgB,WAAK,QAAQ,MAAM,KAAK,KAAK,GAAG,CAAC,CAAC;AAAA,EACtE;AACA,SAAO,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AACvD;AAgBA,eAAsB,eAAe,OAAsB,UAAyB,CAAC,GAA0B;AAC7G,MAAI,MAAM,UAAU,KAAK,MAAM,GAAI,OAAM,IAAI,MAAM,6CAA6C;AAChG,QAAM,eAAe,QAAQ,UAAU,gBAAgB;AACvD,QAAM,YAAY,KAAK,IAAI,QAAQ,UAAU,aAAa,oBAAoB,YAAY;AAC1F,QAAM,gBAAgB,QAAQ,UAAU,iBAAiB;AACzD,QAAM,WAAW,MAAM,YAAY,CAAC;AACpC,QAAM,YAAY,MAAM;AAExB,QAAM,gBAAgB,CAAC,kBAAkB,KAAK,MAAM,YAAY,EAAE;AAClE,QAAM,SAAS,YAAY,MAAM,MAAM,aAAa;AAEpD,QAAM,QAAqB,CAAC;AAC5B,QAAM,QAAqB,CAAC;AAC5B,QAAM,aAAa;AACnB,QAAM,KAAK;AAAA,IACT,IAAI;AAAA,IACJ,OAAO,MAAM,SAAe,eAAS,SAAS;AAAA,IAC9C,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,MAAM;AAAA,EACR,CAAC;AAED,QAAM,eAA0B,CAAC;AACjC,QAAM,kBAAkB,oBAAI,IAAoB;AAChD,MAAI,eAAe;AAEnB,QAAM,SAA0B,CAAC;AACjC,MAAI,SAAmB,CAAC;AACxB,MAAI,aAAa;AACjB,MAAI,gBAAgC;AACpC,MAAI,kBAAiC;AAErC,QAAM,aAAa,MAAY;AAC7B,UAAM,UAAU,OAAO,KAAK,MAAM;AAClC,aAAS,CAAC;AACV,QAAI,QAAQ,KAAK,MAAM,GAAI;AAE3B,UAAM,QAAQ,OAAO;AACrB,UAAM,SAAS,GAAG,SAAS,WAAW,OAAO,KAAK,EAAE,SAAS,GAAG,GAAG,CAAC;AACpE,WAAO,KAAK;AAAA,MACV;AAAA,MACA;AAAA,MACA,eAAe,eAAe,OAAO;AAAA,MACrC;AAAA,MACA,aAAa,eAAe,QAAQ,CAAC;AAAA,MACrC,aAAa,YAAY,OAAO;AAAA,MAChC,UAAU,EAAE,GAAG,SAAS;AAAA,IAC1B,CAAC;AACD,UAAM,KAAK;AAAA,MACT,IAAI;AAAA,MACJ,OAAO,WAAW,OAAO;AAAA,MACzB,YAAY;AAAA,MACZ,gBAAgB,IAAI,UAAU;AAAA,MAC9B,MAAM;AAAA,IACR,CAAC;AACD,UAAM,KAAK;AAAA,MACT,QAAQ,eAAe,MAAM;AAAA,MAC7B,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,YAAY;AAAA,IACd,CAAC;AACD,QAAI,oBAAoB,MAAM;AAC5B,YAAM,KAAK,EAAE,QAAQ,iBAAiB,QAAQ,QAAQ,UAAU,WAAW,YAAY,YAAY,CAAC;AAAA,IACtG;AACA,sBAAkB;AAElB,eAAW,UAAU,mBAAmB,SAAS,SAAS,GAAG;AAC3D,UAAI,WAAW,UAAW;AAC1B,YAAM,KAAK,EAAE,QAAQ,QAAQ,QAAQ,UAAU,cAAc,YAAY,WAAW,CAAC;AAAA,IACvF;AAEA,QAAI,gBAAgB,GAAG;AACrB,YAAM,YAAY,gBAAgB;AAClC,YAAM,OAAO,QAAQ,SAAS,YAAY,QAAQ,MAAM,QAAQ,QAAQ,KAAK,QAAQ,SAAS,SAAS,IAAI,CAAC,IAAI;AAChH,UAAI,KAAK,KAAK,MAAM,IAAI;AACtB,eAAO,KAAK,IAAI;AAAA,MAGlB;AAAA,IACF;AAAA,EACF;AAEA,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,SAAS,WAAW;AAC5B,iBAAW;AACX,eAAS,CAAC;AACV,aAAO,aAAa,SAAS,KAAM,aAAa,aAAa,SAAS,CAAC,EAAc,SAAS,MAAM,OAAO;AACzG,qBAAa,IAAI;AAAA,MACnB;AACA,YAAM,SAAS,aAAa,aAAa,SAAS,CAAC;AACnD,YAAM,OAAO,CAAC,GAAI,QAAQ,QAAQ,CAAC,GAAI,MAAM,IAAI;AACjD,YAAM,SAAS,GAAG,SAAS,SAAS,KAAK,KAAK,GAAG,CAAC;AAClD,YAAM,OAAO,gBAAgB,IAAI,MAAM,KAAK;AAC5C,sBAAgB,IAAI,QAAQ,OAAO,CAAC;AACpC,YAAM,KAAK,SAAS,IAAI,SAAS,GAAG,MAAM,IAAI,OAAO,CAAC;AAEtD,YAAM,KAAK;AAAA,QACT;AAAA,QACA,OAAO,MAAM;AAAA,QACb,YAAY;AAAA,QACZ,gBAAgB,IAAI,MAAM,IAAI;AAAA,QAC9B,MAAM;AAAA,MACR,CAAC;AACD,YAAM,KAAK,EAAE,QAAQ,QAAQ,MAAM,YAAY,QAAQ,IAAI,UAAU,YAAY,YAAY,YAAY,CAAC;AAC1G,mBAAa,KAAK,EAAE,IAAI,OAAO,MAAM,OAAO,KAAK,CAAC;AAClD,sBAAgB;AAChB,sBAAgB,aAAa,aAAa,SAAS,CAAC;AACpD,mBAAa,MAAM,OAAO;AAC1B;AAAA,IACF;AAEA,UAAM,SACJ,eAAe,MAAM,IAAI,IAAI,YAAY,mBAAmB,MAAM,MAAM,SAAS,IAAI,CAAC,MAAM,IAAI;AAClG,eAAW,SAAS,QAAQ;AAC1B,YAAM,eAAe,eAAe,OAAO,KAAK,MAAM,CAAC;AACvD,UAAI,OAAO,SAAS,KAAK,eAAe,eAAe,KAAK,IAAI,cAAc;AAC5E,mBAAW;AAAA,MACb;AACA,UAAI,OAAO,WAAW,EAAG,cAAa,MAAM;AAC5C,aAAO,KAAK,KAAK;AAAA,IACnB;AAAA,EACF;AACA,aAAW;AAEX,MAAI,WAAW;AACf,MAAI,QAAQ,mBAAmB;AAC7B,UAAM,WAAW;AAAA,MACf,MAAM,QAAQ,kBAAkB,gBAAgB,WAAW,MAAM,IAAI;AAAA,MACrE,qBAAqB,SAAS;AAAA,IAChC;AACA,eAAW,SAAS,MAAM;AAC1B,UAAM,KAAK,GAAG,SAAS,KAAK;AAC5B,UAAM,KAAK,GAAG,SAAS,KAAK;AAAA,EAC9B;AAEA,QAAM,aAAa,mBAAmB,EAAE,OAAO,MAAM,GAAG,kBAAkB,SAAS,GAAG;AACtF,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,OAAO;AAAA,MACL,UAAU;AAAA,MACV;AAAA,MACA,YAAY,OAAO,OAAO,CAAC,KAAK,UAAU,MAAM,MAAM,eAAe,CAAC;AAAA,IACxE;AAAA,EACF;AACF;;;ACzWA,OAAO,WAAW;AAWlB,IAAM,qBAAqB;AAE3B,IAAM,0BAA0B;AAgChC,SAAS,uBAAuB,OAAoB;AAClD,QAAM,gBAAgB,oBAAI,IAA+C;AACzE,MAAI,eAAe;AACnB,QAAM,YAAY,CAAC,KAAK,UAAU;AAChC,UAAM,YAAY,MAAM;AACxB,QAAI,OAAO,cAAc,SAAU;AACnC,QAAI,YAAY,aAAc,gBAAe;AAC7C,QAAI,CAAC,cAAc,IAAI,SAAS,GAAG;AACjC,oBAAc,IAAI,WAAW;AAAA,QAC3B,OAAO,MAAM;AAAA,QACb,MAAM,MAAM;AAAA,MACd,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AAED,QAAM,aAAa,MAChB,YAAY,CAAC,KAAK,UAAU,MAAM,cAAc,MAAS,EACzD,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAEpC,aAAW,MAAM,YAAY;AAC3B,UAAM,QAAQ,oBAAI,IAAoB;AACtC,UAAM,gBAAgB,IAAI,CAAC,WAAW,UAAU;AAC9C,YAAM,YAAY,MAAM;AACxB,UAAI,OAAO,cAAc,SAAU,OAAM,IAAI,YAAY,MAAM,IAAI,SAAS,KAAK,KAAK,CAAC;AAAA,IACzF,CAAC;AAED,QAAI,UAAyB;AAC7B,QAAI,YAAY;AAChB,eAAW,CAAC,WAAW,KAAK,KAAK,OAAO;AACtC,UAAI,QAAQ,aAAc,UAAU,aAAa,YAAY,QAAQ,YAAY,SAAU;AACzF,kBAAU;AACV,oBAAY;AAAA,MACd;AAAA,IACF;AACA,QAAI,YAAY,MAAM;AACpB,gBAAU,EAAE;AACZ,oBAAc,IAAI,SAAS;AAAA,QACzB,OAAQ,MAAM,iBAAiB,IAAI,OAAO,KAA4B;AAAA,MACxE,CAAC;AAAA,IACH;AAEA,UAAM,OAAO,cAAc,IAAI,OAAO;AACtC,UAAM,oBAAoB,IAAI;AAAA,MAC5B,WAAW;AAAA,MACX,gBAAgB,MAAM,SAAS,aAAa,OAAO;AAAA,MACnD,GAAI,MAAM,SAAS,SAAY,EAAE,eAAe,KAAK,KAAK,IAAI,CAAC;AAAA,IACjE,CAAC;AAAA,EACH;AACF;AAaO,SAAS,kBACd,OACA,YACA,UAA+B,CAAC,GACzB;AACP,QAAM,YAAY,mBAAmB,YAAY,mBAAmB;AACpE,QAAM,SAAS,SAAS,IAAI,MAAM,EAAE,MAAM,YAAY,OAAO,MAAM,gBAAgB,KAAK,CAAC;AAEzF,QAAM,cAAc,IAAI,IAAI,UAAU,MAAM,IAAI,CAAC,SAAS,KAAK,UAAU,CAAC;AAC1E,QAAM,QAAQ,OAAO,YAAY,CAAC,KAAK,UAAU,YAAY,IAAI,MAAM,UAAoB,CAAC;AAC5F,aAAW,MAAM,MAAO,QAAO,SAAS,EAAE;AAI1C,QAAM,gBAAgB,IAAI,IAAI,UAAU,MAAM,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AACpE,QAAM,YAAY,UAAU,MAAM,OAAO,CAAC,SAAS,CAAC,OAAO,QAAQ,KAAK,EAAE,CAAC,EAAE;AAC7E,QAAM,kBAAkB,oBAAI,IAAY;AACxC,aAAW,QAAQ,UAAU,OAAO;AAClC,eAAW,YAAY,CAAC,KAAK,QAAQ,KAAK,MAAM,GAAG;AACjD,UAAI,CAAC,cAAc,IAAI,QAAQ,KAAK,CAAC,OAAO,QAAQ,QAAQ,EAAG,iBAAgB,IAAI,QAAQ;AAAA,IAC7F;AAAA,EACF;AAEA,uBAAqB,QAAQ,CAAC,SAAS,CAAC;AAKxC,QAAM,WAAW,OAAO;AAAA,IACtB,CAAC,IAAI,UAAU,MAAM,eAAe,eAAe,OAAO,OAAO,EAAE,MAAM;AAAA,EAC3E;AACA,aAAW,MAAM,SAAU,QAAO,SAAS,EAAE;AAE7C,QAAM,QAAQ,MAAM,SAAS,YAAY,gBAAgB,OAAO,SAAS;AACzE,QAAM,cAAc,OAAO,OAAO,aAAa,kBAAkB,CAAC,KAAK,KAAK;AAE5E,QAAM,OAAO,QAAQ,aAAa;AAClC,QAAM,QAAQ,QAAQ,kBAAkB;AACxC,QAAM,iBACJ,OAAO,QAAQ,KAAK,OAAO,SAAS,CAAC,KAAK,UAAU,MAAM,cAAc,MAAS,MAAM;AAEzF,MAAI,SAAS,QAAS,SAAS,WAAW,kBAAkB,aAAa,OAAO,QAAQ,QAAS;AAC/F,YAAQ,QAAQ,EAAE,WAAW,QAAQ,UAAU,CAAC;AAChD,WAAO,aAAa,oBAAoB,CAAC;AAAA,EAC3C,OAAO;AACL,QAAI,SAAS,OAAQ,wBAAuB,MAAM;AAClD,WAAO,aAAa,oBAAoB,UAAU;AAAA,EACpD;AACA,SAAO;AACT;;;AC3FA,IAAM,uBAAuB;AAC7B,IAAM,mBAAmB;AAgBzB,SAAS,aAAa,OAAc,MAA8B;AAChE,QAAM,QAAQ,oBAAI,IAAkB;AACpC,aAAW,OAAO,MAAM;AACtB,QAAI,KAAoB;AACxB,QAAI,IAAI,WAAW,UAAa,MAAM,QAAQ,IAAI,MAAM,GAAG;AACzD,WAAK,IAAI;AAAA,IACX,WAAW,IAAI,SAAS,QAAW;AACjC,WAAK,YAAY,OAAO,IAAI,IAAI,GAAG,MAAM;AAAA,IAC3C;AAEA,UAAM,MAAM,MAAM,IAAI,UAAU,IAAI,QAAQ;AAC5C,QAAI,QAAQ,GAAI;AAChB,UAAM,WAAW,MAAM,IAAI,GAAG;AAC9B,QAAI,UAAU;AACZ,UAAI,IAAI,UAAU,WAAc,SAAS,UAAU,UAAa,IAAI,QAAQ,SAAS,QAAQ;AAC3F,iBAAS,QAAQ,IAAI;AAAA,MACvB;AACA;AAAA,IACF;AACA,UAAM,IAAI,KAAK,EAAE,IAAI,KAAK,OAAO,IAAI,OAAO,SAAS,OAAO,MAAM,MAAM,IAAI,KAAK,CAAC;AAAA,EACpF;AACA,SAAO,CAAC,GAAG,MAAM,OAAO,CAAC;AAC3B;AAQO,SAAS,gBAAgB,OAAc,MAAsB,UAAyB,CAAC,GAAoB;AAChH,QAAM,cAAc,QAAQ,eAAe;AAC3C,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,YAAY,QAAQ,YAAY,IAAI,IAAY,QAAQ,SAAS,IAAI;AAE3E,QAAM,QAAQ,aAAa,OAAO,IAAI;AACtC,QAAM,aAAa,MAAM,OAAO,CAAC,MAAM,EAAE,OAAO;AAChD,QAAM,YAAY,IAAI,IAAI,WAAW,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;AAEhE,QAAM,OAAO,oBAAI,IAAoB;AACrC,QAAM,MAAM,oBAAI,IAA2B;AAC3C,QAAM,WAAqB,CAAC;AAC5B,MAAI,cAAc;AAClB,aAAW,QAAQ,YAAY;AAC7B,SAAK,IAAI,KAAK,IAAI,CAAC;AACnB,aAAS,KAAK,KAAK,EAAE;AACrB,mBAAe,cAAc,OAAO,KAAK,EAAE;AAAA,EAC7C;AAEA,MAAI,YAAY;AAChB,SAAO,SAAS,SAAS,GAAG;AAC1B,UAAM,UAAU,SAAS,MAAM;AAC/B,UAAM,cAAc,KAAK,IAAI,OAAO;AACpC,QAAI,eAAe,QAAS;AAI5B,UAAM,aAA+D,CAAC;AACtE,UAAM,YAAY,SAAS,CAAC,MAAM,OAAO,QAAQ,WAAW;AAC1D,YAAM,WAAW,OAAO,MAAM,QAAQ;AACtC,UAAI,aAAa,CAAC,UAAU,IAAI,QAAQ,EAAG;AAC3C,YAAM,WAAW,WAAW,UAAU,SAAS;AAC/C,iBAAW,KAAK;AAAA,QACd;AAAA,QACA,MAAM,EAAE,QAAQ,QAAQ,UAAU,YAAY,OAAO,MAAM,UAAU,EAAE;AAAA,MACzE,CAAC;AAAA,IACH,CAAC;AACD,eAAW,KAAK,CAAC,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC;AAE9D,eAAW,EAAE,UAAU,KAAK,KAAK,YAAY;AAC3C,UAAI,KAAK,IAAI,QAAQ,EAAG;AACxB,YAAM,OAAO,cAAc,OAAO,QAAQ;AAC1C,UAAI,cAAc,OAAO,aAAa;AACpC,oBAAY;AACZ;AAAA,MACF;AACA,qBAAe;AACf,WAAK,IAAI,UAAU,cAAc,CAAC;AAClC,UAAI,IAAI,UAAU,IAAI;AACtB,eAAS,KAAK,QAAQ;AAAA,IACxB;AAAA,EACF;AAEA,QAAM,SAAS,CAAC,OAAuB,MAAM,OAAO,EAAE;AACtD,QAAM,QAAwB,CAAC,GAAG,KAAK,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,QAAQ,MAAM;AACxE,UAAM,QAAQ,MAAM,kBAAkB,EAAE;AACxC,WAAO;AAAA,MACL;AAAA,MACA,OAAQ,MAAM,SAAgC;AAAA,MAC9C,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,OAAO,MAAM,IAAI,EAAE,IAAI,CAAC;AAAA,MAC/D,YAAa,MAAM,cAAqC;AAAA,MACxD,gBAAiB,MAAM,kBAAyC;AAAA,MAChE,MAAM;AAAA,MACN,GAAI,IAAI,IAAI,EAAE,IAAI,EAAE,KAAK,IAAI,IAAI,EAAE,EAAmB,IAAI,CAAC;AAAA,MAC3D,GAAI,aAAa,KAAK,UAAU,IAAI,EAAE,MAAM,SAAY,EAAE,WAAW,UAAU,IAAI,EAAE,EAAY,IAAI,CAAC;AAAA,IACxG;AAAA,EACF,CAAC;AAID,aAAW,QAAQ,OAAO;AACxB,QAAI,KAAK,QAAS;AAClB,UAAM,KAAK;AAAA,MACT,IAAI,KAAK;AAAA,MACT,OAAO,KAAK,QAAQ,KAAK;AAAA,MACzB,YAAY;AAAA,MACZ,gBAAgB;AAAA,MAChB,MAAM;AAAA,MACN,GAAI,KAAK,UAAU,SAAY,EAAE,WAAW,KAAK,MAAM,IAAI,CAAC;AAAA,IAC9D,CAAC;AAAA,EACH;AAEA,QAAM,KAAK,CAAC,GAAG,MAAM;AACnB,QAAI,EAAE,SAAS,EAAE,KAAM,QAAO,EAAE,OAAO,EAAE;AACzC,QAAI,EAAE,SAAS,GAAG;AAChB,cAAQ,EAAE,aAAa,cAAc,EAAE,aAAa,cAAc,EAAE,GAAG,cAAc,EAAE,EAAE;AAAA,IAC3F;AACA,UAAM,cAAc,MAAM,QAAQ,EAAE,EAAE,IAAI,OAAO,EAAE,EAAE,IAAI,MAAM,MAAM,QAAQ,EAAE,EAAE,IAAI,OAAO,EAAE,EAAE,IAAI;AACpG,WAAO,cAAc,EAAE,GAAG,cAAc,EAAE,EAAE;AAAA,EAC9C,CAAC;AAED,QAAM,WAAW,oBAAI,IAAI,CAAC,GAAG,KAAK,KAAK,CAAC,CAAC;AACzC,QAAM,QAAyB,CAAC;AAChC,QAAM,YAAY,CAAC,MAAM,OAAO,QAAQ,WAAW;AACjD,QAAI,CAAC,SAAS,IAAI,MAAM,KAAK,CAAC,SAAS,IAAI,MAAM,EAAG;AACpD,UAAM,WAAW,OAAO,MAAM,QAAQ;AACtC,QAAI,aAAa,CAAC,UAAU,IAAI,QAAQ,EAAG;AAC3C,UAAM,KAAK,EAAE,QAAQ,QAAQ,UAAU,YAAY,OAAO,MAAM,UAAU,EAAE,CAAC;AAAA,EAC/E,CAAC;AACD,QAAM;AAAA,IACJ,CAAC,GAAG,MAAM,EAAE,OAAO,cAAc,EAAE,MAAM,KAAK,EAAE,OAAO,cAAc,EAAE,MAAM,KAAK,EAAE,SAAS,cAAc,EAAE,QAAQ;AAAA,EACvH;AAEA,QAAM,cAAc,oBAAI,IAA8D;AACtF,aAAW,MAAM,UAAU;AACzB,UAAM,QAAQ,MAAM,kBAAkB,EAAE;AACxC,UAAM,YAAY,MAAM;AACxB,QAAI,cAAc,OAAW;AAC7B,UAAM,QAAQ,YAAY,IAAI,SAAS,KAAK;AAAA,MAC1C,IAAI;AAAA,MACJ,OAAQ,MAAM,kBAAyC,OAAO,SAAS;AAAA,MACvE,WAAW;AAAA,IACb;AACA,QAAK,KAAK,IAAI,EAAE,MAAiB,EAAG,OAAM,aAAa;AACvD,gBAAY,IAAI,WAAW,KAAK;AAAA,EAClC;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,aAAa,CAAC,GAAG,YAAY,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,KAAK,EAAE,EAAE;AAAA,IAC9F;AAAA,EACF;AACF;","names":[]}