@dreamtree-org/graphify 1.2.0 → 1.4.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.d.cts CHANGED
@@ -172,6 +172,30 @@ declare function renderReport(graph: Graph, analysis: Analysis, now?: Date): str
172
172
  */
173
173
  declare function exportGraph(graph: Graph, options: ExportOptions, report?: string): Promise<void>;
174
174
 
175
+ /**
176
+ * Graphology's node-link JSON — exactly the shape `graph.json` has always
177
+ * contained, derived from Graph#export() rather than imported from
178
+ * graphology-types so consumers of our .d.ts don't need that package.
179
+ */
180
+ type SerializedGraph = ReturnType<Graph['export']>;
181
+ /**
182
+ * Where a graph lives between runs. Graphify defines the interface and
183
+ * ships LocalFileGraphStore (the classic graphify-out/ behavior); callers
184
+ * that persist graphs elsewhere — a DB row, an object-store key, anything
185
+ * that can hold JSON — implement it over their own storage with
186
+ * serializeGraph()/deserializeGraph(). Graphify never instantiates a
187
+ * remote store itself (see docs/KB-PROVIDER.md).
188
+ *
189
+ * `ref` is an opaque caller-defined locator. For LocalFileGraphStore it is
190
+ * an outDir path; for a provider it is whatever routes to one graph, e.g.
191
+ * `${companyId}/${knowledgeBaseId}`.
192
+ */
193
+ interface GraphStore {
194
+ /** Resolves null when no graph exists yet at `ref` — not an error. */
195
+ load(ref: string): Promise<Graph | null>;
196
+ save(ref: string, graph: Graph): Promise<void>;
197
+ }
198
+
175
199
  interface PipelineOptions {
176
200
  /** Where to write graph.json/graph.html/GRAPH_REPORT.md. Defaults to `<root>/graphify-out`. */
177
201
  outDir?: string;
@@ -197,6 +221,13 @@ interface PipelineOptions {
197
221
  update?: boolean;
198
222
  /** Called with a short progress message after each stage (e.g. for CLI stderr output). */
199
223
  onProgress?: (message: string) => void;
224
+ /**
225
+ * Also save the built graph through a caller-supplied GraphStore (e.g. a
226
+ * DB-backed store — see docs/KB-PROVIDER.md), in addition to the regular
227
+ * graphify-out/ export. Keyed by `storeRef` (defaults to the outDir path).
228
+ */
229
+ store?: GraphStore;
230
+ storeRef?: string;
200
231
  }
201
232
  interface PipelineResult {
202
233
  manifest: FileManifest;
@@ -215,12 +246,35 @@ declare function runPipeline(root: string, options?: PipelineOptions): Promise<P
215
246
 
216
247
  /**
217
248
  * Load a previously-exported graph.json back into a graphology Graph.
218
- * Always goes through security.validateGraphPath() (path-traversal guard —
219
- * see SECURITY.md) so a caller-supplied `outDir`/CLI arg can never make
220
- * this read outside the graphify-out/ directory it resolves to.
249
+ * Thin wrapper over LocalFileGraphStore (which enforces the path-traversal
250
+ * guard — see SECURITY.md) that throws when no graph exists, because every
251
+ * CLI/MCP caller treats "no graph yet" as a hard error. Library callers who
252
+ * want the null-on-missing contract should use a GraphStore directly.
221
253
  */
222
254
  declare function loadGraph(outDir?: string): Promise<Graph>;
223
255
 
256
+ /**
257
+ * The reference GraphStore: one graph.json per directory, `ref` = the
258
+ * outDir path. Reproduces the classic graphify-out/ behavior byte-for-byte
259
+ * (same pretty-printed JSON exportGraph() writes) and routes every path
260
+ * through security.validateGraphPath(), so a caller-supplied ref — or a
261
+ * symlinked graph.json — can never read or write outside the directory it
262
+ * resolves to.
263
+ */
264
+ declare class LocalFileGraphStore implements GraphStore {
265
+ load(ref: string): Promise<Graph | null>;
266
+ save(ref: string, graph: Graph): Promise<void>;
267
+ }
268
+
269
+ /**
270
+ * The stable serialization contract for graphs at rest: graphology's
271
+ * node-link JSON. exportGraph() has always written exactly this shape to
272
+ * graph.json, so anything serialized here stays loadable by every existing
273
+ * consumer (and vice versa).
274
+ */
275
+ declare function serializeGraph(graph: Graph): SerializedGraph;
276
+ declare function deserializeGraph(data: SerializedGraph): Graph;
277
+
224
278
  interface NodeMatch {
225
279
  id: string;
226
280
  label: string;
@@ -304,6 +358,73 @@ interface ExplainResult {
304
358
  /** Plain-language-ready explanation of the node that best matches `query`. */
305
359
  declare function explainNode(graph: Graph, query: string): ExplainResult | null;
306
360
 
361
+ /**
362
+ * Graph-augmented retrieval (docs/KB-PROVIDER.md §4): the caller has
363
+ * already done similarity search (vector, keyword, hybrid — their
364
+ * pipeline, their embeddings); expandRetrieval() takes those hits and adds
365
+ * what similarity can't see — the nodes *structurally connected* to them.
366
+ * Pure and synchronous like every query function: Graph in, data out; no
367
+ * store access, no embedding calls.
368
+ */
369
+ /**
370
+ * One hit from the caller's own search. `nodeId` when the caller stored
371
+ * graph node ids alongside its records at ingest time; otherwise `text`,
372
+ * resolved against the graph lexically (same resolver queryGraph uses).
373
+ */
374
+ interface RetrievalHit {
375
+ nodeId?: string;
376
+ text?: string;
377
+ /** The caller's similarity score, echoed through to rank seeds. */
378
+ score?: number;
379
+ }
380
+ interface ExpandOptions {
381
+ /** Approximate cap, in tokens (~4 chars each), on the result. Default 2000. */
382
+ tokenBudget?: number;
383
+ maxHops?: number;
384
+ /** Restrict traversal (and reported edges) to these relations, e.g. ['contains', 'references']. */
385
+ relations?: Relation[];
386
+ }
387
+ interface ExpandedNode {
388
+ id: string;
389
+ label: string;
390
+ /** Node kind attr (chunk|section|entity|document|...) — absent on classic code nodes. */
391
+ kind?: string;
392
+ sourceFile: string;
393
+ sourceLocation: string;
394
+ /** 0 = was a seed (or an unresolvable hit echoed through). */
395
+ hops: number;
396
+ /** The edge that first led the traversal here (absent for seeds). */
397
+ via?: TraversalEdge;
398
+ /** The caller's similarity score — seeds only. */
399
+ seedScore?: number;
400
+ }
401
+ interface ExpandedContext {
402
+ /**
403
+ * Union of seed nodes and discovered neighbors, ranked: seeds first (by
404
+ * caller score desc), then by (hops asc, degree desc). Deduped across
405
+ * seeds. Hits that resolve to no graph node are echoed through with
406
+ * hops 0 and no expansion, so adopting this on a partially-ingested
407
+ * corpus never does worse than flat retrieval.
408
+ */
409
+ nodes: ExpandedNode[];
410
+ /** Every edge between included nodes — provenance for citations. */
411
+ edges: TraversalEdge[];
412
+ communities: Array<{
413
+ id: number;
414
+ label: string;
415
+ seedCount: number;
416
+ }>;
417
+ /** True when the token budget stopped the traversal before the frontier was exhausted. */
418
+ truncated: boolean;
419
+ }
420
+ /**
421
+ * Expand the caller's top-K hits through the graph: one joint,
422
+ * token-budgeted BFS seeded from every hit at once, so overlapping
423
+ * neighborhoods dedupe and the budget is shared across seeds. Deterministic
424
+ * (lexical neighbor order, stable ranking) like the rest of the query layer.
425
+ */
426
+ declare function expandRetrieval(graph: Graph, hits: RetrievalHit[], options?: ExpandOptions): ExpandedContext;
427
+
307
428
  interface AffectedNode {
308
429
  id: string;
309
430
  label: string;
@@ -651,4 +772,4 @@ declare class ExtractionValidationError extends Error {
651
772
  */
652
773
  declare function validateExtraction(value: unknown, context?: string): ExtractionResult;
653
774
 
654
- export { type AffectedNode, type Analysis, type ChangedSymbol, type ClusterAlgorithm, type ClusterOptions, type Confidence, type ContextOptions, type ContextPack, type ContextSnippet, type DependencyRule, type DsnQueryFn, EXTRACTOR_REGISTRY, type ExplainResult, type ExportOptions, ExtractionCache, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphDiff, type GraphEdge, type GraphNode, type ImpactOptions, type ImpactResult, type MergeEntry, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type ReflectOptions, type Relation, type ResolveStats, type ResultOutcome, type ResultType, type RewiredSymbol, type RulesConfig, type SavedResult, type TestSelection, type TraversalEdge, type Violation, type VisitedNode, affectedBy, analyze, buildContextPack, buildGraph, checkRules, cluster, collectFiles, diffGraphs, explainNode, exportGraph, extract, extractMysql, globToRegExp, graphForDirectory, isTestFile, loadGraph, loadResults, mergeGraphs, queryGraph, rankForTask, renderContextPack, renderLessons, renderReport, renderReview, resolveCrossFileReferences, resolveNode, reviewRevisions, runPipeline, saveResult, scoreNodes, shortestPath, testsForChangedFiles, testsForNode, validateExtraction, validateRules };
775
+ export { type AffectedNode, type Analysis, type ChangedSymbol, type ClusterAlgorithm, type ClusterOptions, type Confidence, type ContextOptions, type ContextPack, type ContextSnippet, type DependencyRule, type DsnQueryFn, EXTRACTOR_REGISTRY, type ExpandOptions, type ExpandedContext, type ExpandedNode, type ExplainResult, type ExportOptions, ExtractionCache, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphDiff, type GraphEdge, type GraphNode, type GraphStore, type ImpactOptions, type ImpactResult, LocalFileGraphStore, type MergeEntry, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type ReflectOptions, type Relation, type ResolveStats, type ResultOutcome, type ResultType, type RetrievalHit, type RewiredSymbol, type RulesConfig, type SavedResult, type SerializedGraph, type TestSelection, type TraversalEdge, type Violation, type VisitedNode, affectedBy, analyze, buildContextPack, buildGraph, checkRules, cluster, collectFiles, deserializeGraph, diffGraphs, expandRetrieval, explainNode, exportGraph, extract, extractMysql, globToRegExp, graphForDirectory, isTestFile, loadGraph, loadResults, mergeGraphs, queryGraph, rankForTask, renderContextPack, renderLessons, renderReport, renderReview, resolveCrossFileReferences, resolveNode, reviewRevisions, runPipeline, saveResult, scoreNodes, serializeGraph, shortestPath, testsForChangedFiles, testsForNode, validateExtraction, validateRules };
package/dist/index.d.ts CHANGED
@@ -172,6 +172,30 @@ declare function renderReport(graph: Graph, analysis: Analysis, now?: Date): str
172
172
  */
173
173
  declare function exportGraph(graph: Graph, options: ExportOptions, report?: string): Promise<void>;
174
174
 
175
+ /**
176
+ * Graphology's node-link JSON — exactly the shape `graph.json` has always
177
+ * contained, derived from Graph#export() rather than imported from
178
+ * graphology-types so consumers of our .d.ts don't need that package.
179
+ */
180
+ type SerializedGraph = ReturnType<Graph['export']>;
181
+ /**
182
+ * Where a graph lives between runs. Graphify defines the interface and
183
+ * ships LocalFileGraphStore (the classic graphify-out/ behavior); callers
184
+ * that persist graphs elsewhere — a DB row, an object-store key, anything
185
+ * that can hold JSON — implement it over their own storage with
186
+ * serializeGraph()/deserializeGraph(). Graphify never instantiates a
187
+ * remote store itself (see docs/KB-PROVIDER.md).
188
+ *
189
+ * `ref` is an opaque caller-defined locator. For LocalFileGraphStore it is
190
+ * an outDir path; for a provider it is whatever routes to one graph, e.g.
191
+ * `${companyId}/${knowledgeBaseId}`.
192
+ */
193
+ interface GraphStore {
194
+ /** Resolves null when no graph exists yet at `ref` — not an error. */
195
+ load(ref: string): Promise<Graph | null>;
196
+ save(ref: string, graph: Graph): Promise<void>;
197
+ }
198
+
175
199
  interface PipelineOptions {
176
200
  /** Where to write graph.json/graph.html/GRAPH_REPORT.md. Defaults to `<root>/graphify-out`. */
177
201
  outDir?: string;
@@ -197,6 +221,13 @@ interface PipelineOptions {
197
221
  update?: boolean;
198
222
  /** Called with a short progress message after each stage (e.g. for CLI stderr output). */
199
223
  onProgress?: (message: string) => void;
224
+ /**
225
+ * Also save the built graph through a caller-supplied GraphStore (e.g. a
226
+ * DB-backed store — see docs/KB-PROVIDER.md), in addition to the regular
227
+ * graphify-out/ export. Keyed by `storeRef` (defaults to the outDir path).
228
+ */
229
+ store?: GraphStore;
230
+ storeRef?: string;
200
231
  }
201
232
  interface PipelineResult {
202
233
  manifest: FileManifest;
@@ -215,12 +246,35 @@ declare function runPipeline(root: string, options?: PipelineOptions): Promise<P
215
246
 
216
247
  /**
217
248
  * Load a previously-exported graph.json back into a graphology Graph.
218
- * Always goes through security.validateGraphPath() (path-traversal guard —
219
- * see SECURITY.md) so a caller-supplied `outDir`/CLI arg can never make
220
- * this read outside the graphify-out/ directory it resolves to.
249
+ * Thin wrapper over LocalFileGraphStore (which enforces the path-traversal
250
+ * guard — see SECURITY.md) that throws when no graph exists, because every
251
+ * CLI/MCP caller treats "no graph yet" as a hard error. Library callers who
252
+ * want the null-on-missing contract should use a GraphStore directly.
221
253
  */
222
254
  declare function loadGraph(outDir?: string): Promise<Graph>;
223
255
 
256
+ /**
257
+ * The reference GraphStore: one graph.json per directory, `ref` = the
258
+ * outDir path. Reproduces the classic graphify-out/ behavior byte-for-byte
259
+ * (same pretty-printed JSON exportGraph() writes) and routes every path
260
+ * through security.validateGraphPath(), so a caller-supplied ref — or a
261
+ * symlinked graph.json — can never read or write outside the directory it
262
+ * resolves to.
263
+ */
264
+ declare class LocalFileGraphStore implements GraphStore {
265
+ load(ref: string): Promise<Graph | null>;
266
+ save(ref: string, graph: Graph): Promise<void>;
267
+ }
268
+
269
+ /**
270
+ * The stable serialization contract for graphs at rest: graphology's
271
+ * node-link JSON. exportGraph() has always written exactly this shape to
272
+ * graph.json, so anything serialized here stays loadable by every existing
273
+ * consumer (and vice versa).
274
+ */
275
+ declare function serializeGraph(graph: Graph): SerializedGraph;
276
+ declare function deserializeGraph(data: SerializedGraph): Graph;
277
+
224
278
  interface NodeMatch {
225
279
  id: string;
226
280
  label: string;
@@ -304,6 +358,73 @@ interface ExplainResult {
304
358
  /** Plain-language-ready explanation of the node that best matches `query`. */
305
359
  declare function explainNode(graph: Graph, query: string): ExplainResult | null;
306
360
 
361
+ /**
362
+ * Graph-augmented retrieval (docs/KB-PROVIDER.md §4): the caller has
363
+ * already done similarity search (vector, keyword, hybrid — their
364
+ * pipeline, their embeddings); expandRetrieval() takes those hits and adds
365
+ * what similarity can't see — the nodes *structurally connected* to them.
366
+ * Pure and synchronous like every query function: Graph in, data out; no
367
+ * store access, no embedding calls.
368
+ */
369
+ /**
370
+ * One hit from the caller's own search. `nodeId` when the caller stored
371
+ * graph node ids alongside its records at ingest time; otherwise `text`,
372
+ * resolved against the graph lexically (same resolver queryGraph uses).
373
+ */
374
+ interface RetrievalHit {
375
+ nodeId?: string;
376
+ text?: string;
377
+ /** The caller's similarity score, echoed through to rank seeds. */
378
+ score?: number;
379
+ }
380
+ interface ExpandOptions {
381
+ /** Approximate cap, in tokens (~4 chars each), on the result. Default 2000. */
382
+ tokenBudget?: number;
383
+ maxHops?: number;
384
+ /** Restrict traversal (and reported edges) to these relations, e.g. ['contains', 'references']. */
385
+ relations?: Relation[];
386
+ }
387
+ interface ExpandedNode {
388
+ id: string;
389
+ label: string;
390
+ /** Node kind attr (chunk|section|entity|document|...) — absent on classic code nodes. */
391
+ kind?: string;
392
+ sourceFile: string;
393
+ sourceLocation: string;
394
+ /** 0 = was a seed (or an unresolvable hit echoed through). */
395
+ hops: number;
396
+ /** The edge that first led the traversal here (absent for seeds). */
397
+ via?: TraversalEdge;
398
+ /** The caller's similarity score — seeds only. */
399
+ seedScore?: number;
400
+ }
401
+ interface ExpandedContext {
402
+ /**
403
+ * Union of seed nodes and discovered neighbors, ranked: seeds first (by
404
+ * caller score desc), then by (hops asc, degree desc). Deduped across
405
+ * seeds. Hits that resolve to no graph node are echoed through with
406
+ * hops 0 and no expansion, so adopting this on a partially-ingested
407
+ * corpus never does worse than flat retrieval.
408
+ */
409
+ nodes: ExpandedNode[];
410
+ /** Every edge between included nodes — provenance for citations. */
411
+ edges: TraversalEdge[];
412
+ communities: Array<{
413
+ id: number;
414
+ label: string;
415
+ seedCount: number;
416
+ }>;
417
+ /** True when the token budget stopped the traversal before the frontier was exhausted. */
418
+ truncated: boolean;
419
+ }
420
+ /**
421
+ * Expand the caller's top-K hits through the graph: one joint,
422
+ * token-budgeted BFS seeded from every hit at once, so overlapping
423
+ * neighborhoods dedupe and the budget is shared across seeds. Deterministic
424
+ * (lexical neighbor order, stable ranking) like the rest of the query layer.
425
+ */
426
+ declare function expandRetrieval(graph: Graph, hits: RetrievalHit[], options?: ExpandOptions): ExpandedContext;
427
+
307
428
  interface AffectedNode {
308
429
  id: string;
309
430
  label: string;
@@ -651,4 +772,4 @@ declare class ExtractionValidationError extends Error {
651
772
  */
652
773
  declare function validateExtraction(value: unknown, context?: string): ExtractionResult;
653
774
 
654
- export { type AffectedNode, type Analysis, type ChangedSymbol, type ClusterAlgorithm, type ClusterOptions, type Confidence, type ContextOptions, type ContextPack, type ContextSnippet, type DependencyRule, type DsnQueryFn, EXTRACTOR_REGISTRY, type ExplainResult, type ExportOptions, ExtractionCache, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphDiff, type GraphEdge, type GraphNode, type ImpactOptions, type ImpactResult, type MergeEntry, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type ReflectOptions, type Relation, type ResolveStats, type ResultOutcome, type ResultType, type RewiredSymbol, type RulesConfig, type SavedResult, type TestSelection, type TraversalEdge, type Violation, type VisitedNode, affectedBy, analyze, buildContextPack, buildGraph, checkRules, cluster, collectFiles, diffGraphs, explainNode, exportGraph, extract, extractMysql, globToRegExp, graphForDirectory, isTestFile, loadGraph, loadResults, mergeGraphs, queryGraph, rankForTask, renderContextPack, renderLessons, renderReport, renderReview, resolveCrossFileReferences, resolveNode, reviewRevisions, runPipeline, saveResult, scoreNodes, shortestPath, testsForChangedFiles, testsForNode, validateExtraction, validateRules };
775
+ export { type AffectedNode, type Analysis, type ChangedSymbol, type ClusterAlgorithm, type ClusterOptions, type Confidence, type ContextOptions, type ContextPack, type ContextSnippet, type DependencyRule, type DsnQueryFn, EXTRACTOR_REGISTRY, type ExpandOptions, type ExpandedContext, type ExpandedNode, type ExplainResult, type ExportOptions, ExtractionCache, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphDiff, type GraphEdge, type GraphNode, type GraphStore, type ImpactOptions, type ImpactResult, LocalFileGraphStore, type MergeEntry, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type ReflectOptions, type Relation, type ResolveStats, type ResultOutcome, type ResultType, type RetrievalHit, type RewiredSymbol, type RulesConfig, type SavedResult, type SerializedGraph, type TestSelection, type TraversalEdge, type Violation, type VisitedNode, affectedBy, analyze, buildContextPack, buildGraph, checkRules, cluster, collectFiles, deserializeGraph, diffGraphs, expandRetrieval, explainNode, exportGraph, extract, extractMysql, globToRegExp, graphForDirectory, isTestFile, loadGraph, loadResults, mergeGraphs, queryGraph, rankForTask, renderContextPack, renderLessons, renderReport, renderReview, resolveCrossFileReferences, resolveNode, reviewRevisions, runPipeline, saveResult, scoreNodes, serializeGraph, shortestPath, testsForChangedFiles, testsForNode, validateExtraction, validateRules };
package/dist/index.js CHANGED
@@ -24,31 +24,171 @@ import {
24
24
  saveResult,
25
25
  validateExtraction,
26
26
  validateRules
27
- } from "./chunk-NS5HB65S.js";
27
+ } from "./chunk-QEB7A5KB.js";
28
28
  import {
29
+ LocalFileGraphStore,
29
30
  affectedBy,
30
31
  buildContextPack,
32
+ deserializeGraph,
31
33
  explainNode,
32
34
  isTestFile,
33
35
  loadGraph,
36
+ nodeTokenCost,
34
37
  queryGraph,
35
38
  rankForTask,
36
39
  renderContextPack,
37
40
  resolveNode,
38
41
  scoreNodes,
42
+ serializeGraph,
39
43
  shortestPath,
40
44
  testsForChangedFiles,
41
45
  testsForNode
42
- } from "./chunk-ZK3TA6FW.js";
46
+ } from "./chunk-7LTO76UD.js";
43
47
  import {
44
48
  extractMysql
45
49
  } from "./chunk-ZPB37LLQ.js";
46
50
  import "./chunk-6JLEILYF.js";
51
+
52
+ // src/retrieval.ts
53
+ var DEFAULT_TOKEN_BUDGET = 2e3;
54
+ var DEFAULT_MAX_HOPS = 2;
55
+ function resolveSeeds(graph, hits) {
56
+ const seeds = /* @__PURE__ */ new Map();
57
+ for (const hit of hits) {
58
+ let id = null;
59
+ if (hit.nodeId !== void 0 && graph.hasNode(hit.nodeId)) {
60
+ id = hit.nodeId;
61
+ } else if (hit.text !== void 0) {
62
+ id = resolveNode(graph, hit.text)?.id ?? null;
63
+ }
64
+ const key = id ?? hit.nodeId ?? hit.text ?? "";
65
+ if (key === "") continue;
66
+ const existing = seeds.get(key);
67
+ if (existing) {
68
+ if (hit.score !== void 0 && (existing.score === void 0 || hit.score > existing.score)) {
69
+ existing.score = hit.score;
70
+ }
71
+ continue;
72
+ }
73
+ seeds.set(key, { id: key, score: hit.score, inGraph: id !== null, text: hit.text });
74
+ }
75
+ return [...seeds.values()];
76
+ }
77
+ function expandRetrieval(graph, hits, options = {}) {
78
+ const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
79
+ const maxHops = options.maxHops ?? DEFAULT_MAX_HOPS;
80
+ const relations = options.relations ? new Set(options.relations) : null;
81
+ const seeds = resolveSeeds(graph, hits);
82
+ const graphSeeds = seeds.filter((s) => s.inGraph);
83
+ const seedScore = new Map(graphSeeds.map((s) => [s.id, s.score]));
84
+ const hops = /* @__PURE__ */ new Map();
85
+ const via = /* @__PURE__ */ new Map();
86
+ const frontier = [];
87
+ let spentTokens = 0;
88
+ for (const seed of graphSeeds) {
89
+ hops.set(seed.id, 0);
90
+ frontier.push(seed.id);
91
+ spentTokens += nodeTokenCost(graph, seed.id);
92
+ }
93
+ let truncated = false;
94
+ while (frontier.length > 0) {
95
+ const current = frontier.shift();
96
+ const currentHops = hops.get(current);
97
+ if (currentHops >= maxHops) continue;
98
+ const candidates = [];
99
+ graph.forEachEdge(current, (_key, attrs, source, target) => {
100
+ const relation = String(attrs.relation);
101
+ if (relations && !relations.has(relation)) return;
102
+ const neighbor = source === current ? target : source;
103
+ candidates.push({
104
+ neighbor,
105
+ edge: { source, target, relation, confidence: String(attrs.confidence) }
106
+ });
107
+ });
108
+ candidates.sort((a, b) => a.neighbor.localeCompare(b.neighbor));
109
+ for (const { neighbor, edge } of candidates) {
110
+ if (hops.has(neighbor)) continue;
111
+ const cost = nodeTokenCost(graph, neighbor);
112
+ if (spentTokens + cost > tokenBudget) {
113
+ truncated = true;
114
+ continue;
115
+ }
116
+ spentTokens += cost;
117
+ hops.set(neighbor, currentHops + 1);
118
+ via.set(neighbor, edge);
119
+ frontier.push(neighbor);
120
+ }
121
+ }
122
+ const degree = (id) => graph.degree(id);
123
+ const nodes = [...hops.entries()].map(([id, hopCount]) => {
124
+ const attrs = graph.getNodeAttributes(id);
125
+ return {
126
+ id,
127
+ label: attrs.label ?? id,
128
+ ...attrs.kind !== void 0 ? { kind: String(attrs.kind) } : {},
129
+ sourceFile: attrs.sourceFile ?? "",
130
+ sourceLocation: attrs.sourceLocation ?? "",
131
+ hops: hopCount,
132
+ ...via.has(id) ? { via: via.get(id) } : {},
133
+ ...hopCount === 0 && seedScore.get(id) !== void 0 ? { seedScore: seedScore.get(id) } : {}
134
+ };
135
+ });
136
+ for (const seed of seeds) {
137
+ if (seed.inGraph) continue;
138
+ nodes.push({
139
+ id: seed.id,
140
+ label: seed.text ?? seed.id,
141
+ sourceFile: "",
142
+ sourceLocation: "",
143
+ hops: 0,
144
+ ...seed.score !== void 0 ? { seedScore: seed.score } : {}
145
+ });
146
+ }
147
+ nodes.sort((a, b) => {
148
+ if (a.hops !== b.hops) return a.hops - b.hops;
149
+ if (a.hops === 0) {
150
+ return (b.seedScore ?? -Infinity) - (a.seedScore ?? -Infinity) || a.id.localeCompare(b.id);
151
+ }
152
+ const degreeDiff = (graph.hasNode(b.id) ? degree(b.id) : 0) - (graph.hasNode(a.id) ? degree(a.id) : 0);
153
+ return degreeDiff || a.id.localeCompare(b.id);
154
+ });
155
+ const included = /* @__PURE__ */ new Set([...hops.keys()]);
156
+ const edges = [];
157
+ graph.forEachEdge((_key, attrs, source, target) => {
158
+ if (!included.has(source) || !included.has(target)) return;
159
+ const relation = String(attrs.relation);
160
+ if (relations && !relations.has(relation)) return;
161
+ edges.push({ source, target, relation, confidence: String(attrs.confidence) });
162
+ });
163
+ edges.sort(
164
+ (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation)
165
+ );
166
+ const communities = /* @__PURE__ */ new Map();
167
+ for (const id of included) {
168
+ const attrs = graph.getNodeAttributes(id);
169
+ const community = attrs.community;
170
+ if (community === void 0) continue;
171
+ const entry = communities.get(community) ?? {
172
+ id: community,
173
+ label: attrs.communityLabel ?? String(community),
174
+ seedCount: 0
175
+ };
176
+ if (hops.get(id) === 0) entry.seedCount += 1;
177
+ communities.set(community, entry);
178
+ }
179
+ return {
180
+ nodes,
181
+ edges,
182
+ communities: [...communities.values()].sort((a, b) => b.seedCount - a.seedCount || a.id - b.id),
183
+ truncated
184
+ };
185
+ }
47
186
  export {
48
187
  EXTRACTOR_REGISTRY,
49
188
  ExtractionCache,
50
189
  ExtractionResultSchema,
51
190
  ExtractionValidationError,
191
+ LocalFileGraphStore,
52
192
  affectedBy,
53
193
  analyze,
54
194
  buildContextPack,
@@ -56,7 +196,9 @@ export {
56
196
  checkRules,
57
197
  cluster,
58
198
  collectFiles,
199
+ deserializeGraph,
59
200
  diffGraphs,
201
+ expandRetrieval,
60
202
  explainNode,
61
203
  exportGraph,
62
204
  extract,
@@ -79,6 +221,7 @@ export {
79
221
  runPipeline,
80
222
  saveResult,
81
223
  scoreNodes,
224
+ serializeGraph,
82
225
  shortestPath,
83
226
  testsForChangedFiles,
84
227
  testsForNode,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/retrieval.ts"],"sourcesContent":["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":"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","names":[]}
@@ -442,9 +442,10 @@ function renderContextPack(pack) {
442
442
  }
443
443
 
444
444
  // src/graphStore.ts
445
- var fs2 = __toESM(require("fs/promises"), 1);
446
445
  var path = __toESM(require("path"), 1);
447
- var import_graphology = __toESM(require("graphology"), 1);
446
+
447
+ // src/store/localFile.ts
448
+ var fs2 = __toESM(require("fs/promises"), 1);
448
449
 
449
450
  // src/security.ts
450
451
  var import_node_crypto = require("crypto");
@@ -479,12 +480,48 @@ function validateGraphPath(path3, base) {
479
480
  return realCandidate;
480
481
  }
481
482
 
483
+ // src/store/serialize.ts
484
+ var import_graphology = __toESM(require("graphology"), 1);
485
+ function serializeGraph(graph) {
486
+ return graph.export();
487
+ }
488
+ function deserializeGraph(data) {
489
+ return import_graphology.default.from(data);
490
+ }
491
+
492
+ // src/store/localFile.ts
493
+ var LocalFileGraphStore = class {
494
+ async load(ref) {
495
+ let jsonPath;
496
+ try {
497
+ jsonPath = validateGraphPath("graph.json", ref);
498
+ } catch (error) {
499
+ if (error.message.startsWith("Base directory does not exist")) return null;
500
+ throw error;
501
+ }
502
+ let raw;
503
+ try {
504
+ raw = await fs2.readFile(jsonPath, "utf-8");
505
+ } catch (error) {
506
+ if (error.code === "ENOENT") return null;
507
+ throw error;
508
+ }
509
+ return deserializeGraph(JSON.parse(raw));
510
+ }
511
+ async save(ref, graph) {
512
+ await fs2.mkdir(ref, { recursive: true });
513
+ const jsonPath = validateGraphPath("graph.json", ref);
514
+ await fs2.writeFile(jsonPath, JSON.stringify(serializeGraph(graph), null, 2), "utf-8");
515
+ }
516
+ };
517
+
482
518
  // src/graphStore.ts
483
519
  async function loadGraph(outDir = path.join(process.cwd(), "graphify-out")) {
484
- const jsonPath = validateGraphPath("graph.json", outDir);
485
- const raw = await fs2.readFile(jsonPath, "utf-8");
486
- const data = JSON.parse(raw);
487
- return import_graphology.default.from(data);
520
+ const graph = await new LocalFileGraphStore().load(outDir);
521
+ if (graph === null) {
522
+ throw new Error(`No graph found in ${outDir} \u2014 run \`graphify <path>\` to build one first.`);
523
+ }
524
+ return graph;
488
525
  }
489
526
 
490
527
  // src/impact.ts