@gmickel/gno 1.4.2 → 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/assets/skill/SKILL.md +11 -1
- package/assets/skill/cli-reference.md +9 -1
- package/assets/skill/mcp-reference.md +16 -0
- package/package.json +1 -1
- package/src/cli/commands/graph.ts +237 -4
- package/src/cli/program.ts +31 -0
- package/src/core/graph-analysis.ts +201 -0
- package/src/mcp/tools/index.ts +58 -4
- package/src/mcp/tools/links.ts +304 -7
- package/src/mcp/tools/query.ts +2 -0
- package/src/pipeline/explain.ts +2 -0
- package/src/pipeline/fusion.ts +28 -0
- package/src/pipeline/graph-retrieval.ts +368 -0
- package/src/pipeline/hybrid.ts +45 -3
- package/src/pipeline/types.ts +18 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/GraphView.tsx +237 -16
- package/src/serve/public/pages/Search.tsx +1 -0
- package/src/serve/routes/api.ts +2 -0
- package/src/store/sqlite/adapter.ts +343 -16
- package/src/store/types.ts +120 -0
package/assets/skill/SKILL.md
CHANGED
|
@@ -133,13 +133,21 @@ gno search "error handling" --json | jq -r '.results[].uri' | xargs gno multi-ge
|
|
|
133
133
|
|
|
134
134
|
## MCP Retrieval Strategy
|
|
135
135
|
|
|
136
|
-
When using GNO through MCP, prefer
|
|
136
|
+
When using GNO through MCP, prefer this retrieval order:
|
|
137
|
+
|
|
138
|
+
1. Check `gno_status` first when freshness, missing vectors, or stale results are plausible.
|
|
139
|
+
2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, and often `line`; it also adds bounded one-hop graph neighbors from top seeds when graph data exists.
|
|
140
|
+
3. Use graph/link expansion for relationship context: `gno_graph_neighbors` for nearby documents, `gno_graph_path` for "how are X and Y connected?", `gno_links`/`gno_backlinks` for one-document link expansion, and `gno_similar` for semantic neighbors. Prefer `explicit` graph edges over `inferred`, `ambiguous`, or `similarity` edges when confidence matters.
|
|
141
|
+
4. Use `gno_get` with `fromLine`/`lineCount` for targeted reads, or `gno_multi_get` to batch top refs.
|
|
137
142
|
|
|
138
143
|
Use narrower tools when the request tells you to:
|
|
139
144
|
|
|
140
145
|
- `gno_search`: exact phrase, filename, identifier, stack trace, error text
|
|
141
146
|
- `gno_vsearch`: conceptual similarity when exact wording differs
|
|
142
147
|
- `gno_status`: stale results, missing embeddings, vector unavailable
|
|
148
|
+
- `gno_graph`: graph report/stats, hubs, isolates, unresolved links, edge confidence/audit, communities, unfamiliar corpus overview
|
|
149
|
+
- `gno_graph_neighbors`: relationship/corpus-navigation questions around a known document
|
|
150
|
+
- `gno_graph_path`: "how are X and Y connected?" questions
|
|
143
151
|
|
|
144
152
|
For ambiguous terms, pass `intent` instead of bloating the query text. For typed retrieval, use `queryModes`: `term` for lexical anchors, `intent` for disambiguation, one `hyde` for a hypothetical answer/document.
|
|
145
153
|
|
|
@@ -164,6 +172,8 @@ gno similar gno://notes/auth.md --threshold 0.85
|
|
|
164
172
|
# Knowledge graph
|
|
165
173
|
gno graph --json
|
|
166
174
|
gno graph -c notes --similar # Include similarity edges
|
|
175
|
+
gno graph --neighbors gno://notes/auth.md
|
|
176
|
+
gno graph --from gno://notes/a.md --to gno://notes/b.md
|
|
167
177
|
```
|
|
168
178
|
|
|
169
179
|
## Global Flags
|
|
@@ -154,7 +154,7 @@ gno query <query> [options]
|
|
|
154
154
|
|
|
155
155
|
| Flag | Time | Description |
|
|
156
156
|
| ------------ | ----- | ------------------------------ |
|
|
157
|
-
| `--fast` | ~0.7s | Skip expansion
|
|
157
|
+
| `--fast` | ~0.7s | Skip expansion, graph, rerank |
|
|
158
158
|
| (default) | ~2-3s | Skip expansion, with reranking |
|
|
159
159
|
| `--thorough` | ~5-8s | Full pipeline with expansion |
|
|
160
160
|
|
|
@@ -164,6 +164,7 @@ Additional options:
|
|
|
164
164
|
| ------------- | --------------------------------- |
|
|
165
165
|
| `--no-expand` | Disable query expansion |
|
|
166
166
|
| `--no-rerank` | Disable reranking |
|
|
167
|
+
| `--no-graph` | Disable graph-neighbor candidates |
|
|
167
168
|
| `--explain` | Print retrieval details to stderr |
|
|
168
169
|
|
|
169
170
|
### gno ask
|
|
@@ -304,6 +305,8 @@ Generate knowledge graph of document connections.
|
|
|
304
305
|
|
|
305
306
|
```bash
|
|
306
307
|
gno graph [options]
|
|
308
|
+
gno graph --neighbors <ref> [--direction both|out|in]
|
|
309
|
+
gno graph --from <ref> --to <ref> [--max-depth 6]
|
|
307
310
|
```
|
|
308
311
|
|
|
309
312
|
| Option | Default | Description |
|
|
@@ -315,9 +318,14 @@ gno graph [options]
|
|
|
315
318
|
| `--threshold` | 0.7 | Similarity threshold (0-1) |
|
|
316
319
|
| `--linked-only` | true | Exclude isolated nodes |
|
|
317
320
|
| `--similar-top-k` | 5 | Similar docs per node (max 20) |
|
|
321
|
+
| `--neighbors` | - | Show graph neighbors for ref |
|
|
322
|
+
| `--direction` | both | Neighbor direction |
|
|
323
|
+
| `--from`, `--to` | - | Find shortest graph path |
|
|
324
|
+
| `--max-depth` | 6 | Max path hops |
|
|
318
325
|
| `--json` | - | JSON output |
|
|
319
326
|
|
|
320
327
|
**Edge types**: `wiki` (wiki links), `markdown` (md links), `similar` (vector similarity).
|
|
328
|
+
JSON edges include `confidence` (`explicit`, `inferred`, `ambiguous`, `similarity`) and `audit` metadata so agents can prefer exact links over fallback or collision-prone matches.
|
|
321
329
|
|
|
322
330
|
**Web UI**: Access interactive graph at `http://localhost:3000/graph` via `gno serve`.
|
|
323
331
|
|
|
@@ -53,6 +53,22 @@ gno mcp install -t claude-code -s project # Project scope
|
|
|
53
53
|
gno mcp status
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
## Retrieval Order
|
|
57
|
+
|
|
58
|
+
For normal questions, start with `gno_query`, then read targeted snippets with
|
|
59
|
+
`gno_get` or batch refs with `gno_multi_get`. `gno_query` already does bounded
|
|
60
|
+
one-hop graph expansion from top seeds; pass `noGraph: true` only when comparing
|
|
61
|
+
pure BM25/vector retrieval. Check `gno_status` first when freshness or
|
|
62
|
+
embeddings may be stale.
|
|
63
|
+
|
|
64
|
+
Use graph tools for relationship context: `gno_graph` for corpus report/stats,
|
|
65
|
+
community summaries,
|
|
66
|
+
`gno_graph_neighbors` for nearby incoming/outgoing graph context, and
|
|
67
|
+
`gno_graph_path` for "how are X and Y connected?" questions. Use
|
|
68
|
+
`gno_links`, `gno_backlinks`, and `gno_similar` for one-document expansion.
|
|
69
|
+
Graph edges include confidence/audit metadata; prefer `explicit` edges when
|
|
70
|
+
answers depend on link certainty.
|
|
71
|
+
|
|
56
72
|
## Uninstall
|
|
57
73
|
|
|
58
74
|
```bash
|
package/package.json
CHANGED
|
@@ -32,12 +32,55 @@ export interface GraphOptions {
|
|
|
32
32
|
similarTopK?: number;
|
|
33
33
|
/** Output format: json (default), dot, mermaid */
|
|
34
34
|
format?: "json" | "dot" | "mermaid";
|
|
35
|
+
/** Show neighbors for a graph node/ref */
|
|
36
|
+
neighbors?: string;
|
|
37
|
+
/** Neighbor direction */
|
|
38
|
+
direction?: "both" | "out" | "in";
|
|
39
|
+
/** Path start graph node/ref */
|
|
40
|
+
from?: string;
|
|
41
|
+
/** Path target graph node/ref */
|
|
42
|
+
to?: string;
|
|
43
|
+
/** Max path hops */
|
|
44
|
+
maxDepth?: number;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export type GraphCommandResult =
|
|
38
48
|
| { success: true; data: GraphResult }
|
|
49
|
+
| { success: true; data: GraphNeighborsCliResult }
|
|
50
|
+
| { success: true; data: GraphPathCliResult }
|
|
39
51
|
| { success: false; error: string; isValidation?: boolean };
|
|
40
52
|
|
|
53
|
+
type GraphNode = GraphResult["nodes"][number];
|
|
54
|
+
type GraphLink = GraphResult["links"][number];
|
|
55
|
+
|
|
56
|
+
interface GraphNeighborCliItem {
|
|
57
|
+
node: GraphNode;
|
|
58
|
+
direction: "out" | "in";
|
|
59
|
+
edge: GraphLink;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface GraphNeighborsCliResult {
|
|
63
|
+
source: GraphNode;
|
|
64
|
+
neighbors: GraphNeighborCliItem[];
|
|
65
|
+
meta: GraphResult["meta"] & {
|
|
66
|
+
mode: "neighbors";
|
|
67
|
+
direction: "both" | "out" | "in";
|
|
68
|
+
totalNeighbors: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface GraphPathCliResult {
|
|
73
|
+
from: GraphNode;
|
|
74
|
+
to: GraphNode;
|
|
75
|
+
path: { nodes: GraphNode[]; edges: GraphLink[] } | null;
|
|
76
|
+
meta: GraphResult["meta"] & {
|
|
77
|
+
mode: "path";
|
|
78
|
+
maxDepth: number;
|
|
79
|
+
found: boolean;
|
|
80
|
+
hops: number;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
85
|
// Command: graph
|
|
43
86
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -74,12 +117,162 @@ export async function graph(
|
|
|
74
117
|
return { success: false, error: result.error.message };
|
|
75
118
|
}
|
|
76
119
|
|
|
120
|
+
if (options.neighbors) {
|
|
121
|
+
const source = resolveGraphNode(result.value, options.neighbors);
|
|
122
|
+
if (!source) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
error: `Graph node not found: ${options.neighbors}`,
|
|
126
|
+
isValidation: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const direction = options.direction ?? "both";
|
|
130
|
+
const neighbors = getGraphNeighbors(result.value, source, direction);
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
data: {
|
|
134
|
+
source,
|
|
135
|
+
neighbors,
|
|
136
|
+
meta: {
|
|
137
|
+
...result.value.meta,
|
|
138
|
+
mode: "neighbors",
|
|
139
|
+
direction,
|
|
140
|
+
totalNeighbors: neighbors.length,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.from || options.to) {
|
|
147
|
+
if (!(options.from && options.to)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: "--from and --to must be used together",
|
|
151
|
+
isValidation: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const from = resolveGraphNode(result.value, options.from);
|
|
155
|
+
const to = resolveGraphNode(result.value, options.to);
|
|
156
|
+
if (!from || !to) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: `Graph node not found: ${from ? options.to : options.from}`,
|
|
160
|
+
isValidation: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const maxDepth = options.maxDepth ?? 6;
|
|
164
|
+
const path = findShortestPath(result.value, from, to, maxDepth);
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
data: {
|
|
168
|
+
from,
|
|
169
|
+
to,
|
|
170
|
+
path,
|
|
171
|
+
meta: {
|
|
172
|
+
...result.value.meta,
|
|
173
|
+
mode: "path",
|
|
174
|
+
maxDepth,
|
|
175
|
+
found: path !== null,
|
|
176
|
+
hops: path ? path.edges.length : 0,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
77
182
|
return { success: true, data: result.value };
|
|
78
183
|
} finally {
|
|
79
184
|
await store.close();
|
|
80
185
|
}
|
|
81
186
|
}
|
|
82
187
|
|
|
188
|
+
function resolveGraphNode(
|
|
189
|
+
graphResult: GraphResult,
|
|
190
|
+
ref: string
|
|
191
|
+
): GraphNode | null {
|
|
192
|
+
const normalized = ref.trim().toLowerCase();
|
|
193
|
+
return (
|
|
194
|
+
graphResult.nodes.find((node) => {
|
|
195
|
+
const title = node.title?.toLowerCase();
|
|
196
|
+
return (
|
|
197
|
+
node.id.toLowerCase() === normalized ||
|
|
198
|
+
node.uri.toLowerCase() === normalized ||
|
|
199
|
+
node.relPath.toLowerCase() === normalized ||
|
|
200
|
+
`${node.collection}/${node.relPath}`.toLowerCase() === normalized ||
|
|
201
|
+
title === normalized
|
|
202
|
+
);
|
|
203
|
+
}) ?? null
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getGraphNeighbors(
|
|
208
|
+
graphResult: GraphResult,
|
|
209
|
+
source: GraphNode,
|
|
210
|
+
direction: "both" | "out" | "in"
|
|
211
|
+
): GraphNeighborCliItem[] {
|
|
212
|
+
const nodesById = new Map(graphResult.nodes.map((node) => [node.id, node]));
|
|
213
|
+
const neighbors: GraphNeighborCliItem[] = [];
|
|
214
|
+
for (const edge of graphResult.links) {
|
|
215
|
+
if (direction !== "in" && edge.source === source.id) {
|
|
216
|
+
const node = nodesById.get(edge.target);
|
|
217
|
+
if (node) neighbors.push({ node, direction: "out", edge });
|
|
218
|
+
}
|
|
219
|
+
if (direction !== "out" && edge.target === source.id) {
|
|
220
|
+
const node = nodesById.get(edge.source);
|
|
221
|
+
if (node) neighbors.push({ node, direction: "in", edge });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return neighbors.sort((a, b) => a.node.uri.localeCompare(b.node.uri));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function findShortestPath(
|
|
228
|
+
graphResult: GraphResult,
|
|
229
|
+
from: GraphNode,
|
|
230
|
+
to: GraphNode,
|
|
231
|
+
maxDepth: number
|
|
232
|
+
): { nodes: GraphNode[]; edges: GraphLink[] } | null {
|
|
233
|
+
const nodesById = new Map(graphResult.nodes.map((node) => [node.id, node]));
|
|
234
|
+
const adjacency = new Map<string, Array<{ next: string; edge: GraphLink }>>();
|
|
235
|
+
for (const edge of graphResult.links) {
|
|
236
|
+
const sourceEdges = adjacency.get(edge.source) ?? [];
|
|
237
|
+
sourceEdges.push({ next: edge.target, edge });
|
|
238
|
+
adjacency.set(edge.source, sourceEdges);
|
|
239
|
+
|
|
240
|
+
const targetEdges = adjacency.get(edge.target) ?? [];
|
|
241
|
+
targetEdges.push({ next: edge.source, edge });
|
|
242
|
+
adjacency.set(edge.target, targetEdges);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const queue: Array<{ id: string; edges: GraphLink[] }> = [
|
|
246
|
+
{ id: from.id, edges: [] },
|
|
247
|
+
];
|
|
248
|
+
const visited = new Set([from.id]);
|
|
249
|
+
while (queue.length > 0) {
|
|
250
|
+
const current = queue.shift();
|
|
251
|
+
if (!current) break;
|
|
252
|
+
if (current.id === to.id) {
|
|
253
|
+
const nodeIds = [from.id];
|
|
254
|
+
let cursor = from.id;
|
|
255
|
+
for (const edge of current.edges) {
|
|
256
|
+
cursor = edge.source === cursor ? edge.target : edge.source;
|
|
257
|
+
nodeIds.push(cursor);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
nodes: nodeIds
|
|
261
|
+
.map((id) => nodesById.get(id))
|
|
262
|
+
.filter((node): node is GraphNode => node !== undefined),
|
|
263
|
+
edges: current.edges,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (current.edges.length >= maxDepth) continue;
|
|
267
|
+
for (const { next, edge } of adjacency.get(current.id) ?? []) {
|
|
268
|
+
if (visited.has(next)) continue;
|
|
269
|
+
visited.add(next);
|
|
270
|
+
queue.push({ id: next, edges: [...current.edges, edge] });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
83
276
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
277
|
// Formatters
|
|
85
278
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -119,10 +312,18 @@ export function formatDot(result: GraphResult): string {
|
|
|
119
312
|
for (const link of result.links) {
|
|
120
313
|
const style =
|
|
121
314
|
link.type === "similar"
|
|
122
|
-
? '
|
|
123
|
-
: ""
|
|
315
|
+
? 'style=dashed, color="#888888", dir=none'
|
|
316
|
+
: link.confidence === "ambiguous"
|
|
317
|
+
? 'style=dotted, color="#d97706"'
|
|
318
|
+
: link.confidence === "inferred"
|
|
319
|
+
? 'style=dashed, color="#64748b"'
|
|
320
|
+
: "";
|
|
321
|
+
const attrs = [
|
|
322
|
+
style,
|
|
323
|
+
`label="${escapeDot(`${link.type}/${link.confidence}`)}"`,
|
|
324
|
+
].filter(Boolean);
|
|
124
325
|
lines.push(
|
|
125
|
-
` "${escapeDot(link.source)}" -> "${escapeDot(link.target)}"${
|
|
326
|
+
` "${escapeDot(link.source)}" -> "${escapeDot(link.target)}" [${attrs.join(", ")}];`
|
|
126
327
|
);
|
|
127
328
|
}
|
|
128
329
|
|
|
@@ -154,7 +355,9 @@ export function formatMermaid(result: GraphResult): string {
|
|
|
154
355
|
const sourceId = nodeIds.get(link.source) ?? link.source;
|
|
155
356
|
const targetId = nodeIds.get(link.target) ?? link.target;
|
|
156
357
|
const arrow = link.type === "similar" ? "---" : "-->";
|
|
157
|
-
lines.push(
|
|
358
|
+
lines.push(
|
|
359
|
+
` ${sourceId} ${arrow}|${escapeMermaid(`${link.type}/${link.confidence}`)}| ${targetId}`
|
|
360
|
+
);
|
|
158
361
|
}
|
|
159
362
|
|
|
160
363
|
return lines.join("\n");
|
|
@@ -178,6 +381,36 @@ export function formatGraph(
|
|
|
178
381
|
|
|
179
382
|
const { data } = result;
|
|
180
383
|
|
|
384
|
+
if ("neighbors" in data) {
|
|
385
|
+
if (options.format === "json" || !options.format) {
|
|
386
|
+
return JSON.stringify(data, null, 2);
|
|
387
|
+
}
|
|
388
|
+
if (data.neighbors.length === 0) {
|
|
389
|
+
return `No graph neighbors found for ${data.source.uri} (direction=${data.meta.direction})`;
|
|
390
|
+
}
|
|
391
|
+
const lines = [
|
|
392
|
+
`Found ${data.neighbors.length} graph neighbors for ${data.source.uri}:`,
|
|
393
|
+
"",
|
|
394
|
+
];
|
|
395
|
+
for (const item of data.neighbors) {
|
|
396
|
+
const title = item.node.title ? ` "${item.node.title}"` : "";
|
|
397
|
+
lines.push(
|
|
398
|
+
` [${item.direction}] ${item.node.uri}${title} (${item.edge.type}, ${item.edge.confidence}, weight: ${item.edge.weight})`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if ("path" in data) {
|
|
405
|
+
if (options.format === "json" || !options.format) {
|
|
406
|
+
return JSON.stringify(data, null, 2);
|
|
407
|
+
}
|
|
408
|
+
if (!data.path) {
|
|
409
|
+
return `No graph path found from ${data.from.uri} to ${data.to.uri} within ${data.meta.maxDepth} hops`;
|
|
410
|
+
}
|
|
411
|
+
return `Graph path (${data.meta.hops} hops):\n${data.path.nodes.map((node) => node.uri).join(" -> ")}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
181
414
|
switch (options.format) {
|
|
182
415
|
case "dot":
|
|
183
416
|
return formatDot(data);
|
package/src/cli/program.ts
CHANGED
|
@@ -539,6 +539,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
539
539
|
)
|
|
540
540
|
.option("--no-expand", "disable query expansion")
|
|
541
541
|
.option("--no-rerank", "disable reranking")
|
|
542
|
+
.option("--no-graph", "disable graph neighbor expansion")
|
|
542
543
|
.option(
|
|
543
544
|
"--query-mode <mode:text>",
|
|
544
545
|
"structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
|
|
@@ -655,6 +656,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
655
656
|
lineNumbers: Boolean(cmdOpts.lineNumbers),
|
|
656
657
|
noExpand: depthPolicy.noExpand,
|
|
657
658
|
noRerank: depthPolicy.noRerank,
|
|
659
|
+
noGraph: Boolean(cmdOpts.fast) || cmdOpts.graph === false,
|
|
658
660
|
candidateLimit: depthPolicy.candidateLimit,
|
|
659
661
|
queryModes,
|
|
660
662
|
explain: Boolean(cmdOpts.explain),
|
|
@@ -2223,6 +2225,11 @@ function wireGraphCommand(program: Command): void {
|
|
|
2223
2225
|
.option("--threshold <n>", "similarity threshold (default 0.7)")
|
|
2224
2226
|
.option("--include-isolated", "include nodes with no links")
|
|
2225
2227
|
.option("--similar-top-k <n>", "similar docs per node (default 5)")
|
|
2228
|
+
.option("--neighbors <ref>", "show graph neighbors for document/node ref")
|
|
2229
|
+
.option("--direction <dir>", "neighbor direction: both, out, in", "both")
|
|
2230
|
+
.option("--from <ref>", "path start document/node ref")
|
|
2231
|
+
.option("--to <ref>", "path target document/node ref")
|
|
2232
|
+
.option("--max-depth <n>", "max path hops (default 6)")
|
|
2226
2233
|
.option("--json", "JSON output (default)")
|
|
2227
2234
|
.option("--dot", "Graphviz DOT output")
|
|
2228
2235
|
.option("--mermaid", "Mermaid diagram output")
|
|
@@ -2259,6 +2266,25 @@ function wireGraphCommand(program: Command): void {
|
|
|
2259
2266
|
const similarTopK = cmdOpts.similarTopK
|
|
2260
2267
|
? parsePositiveInt("similar-top-k", cmdOpts.similarTopK)
|
|
2261
2268
|
: undefined;
|
|
2269
|
+
const maxDepth = cmdOpts.maxDepth
|
|
2270
|
+
? parsePositiveInt("max-depth", cmdOpts.maxDepth)
|
|
2271
|
+
: undefined;
|
|
2272
|
+
if (
|
|
2273
|
+
cmdOpts.direction !== "both" &&
|
|
2274
|
+
cmdOpts.direction !== "out" &&
|
|
2275
|
+
cmdOpts.direction !== "in"
|
|
2276
|
+
) {
|
|
2277
|
+
throw new CliError(
|
|
2278
|
+
"VALIDATION",
|
|
2279
|
+
"--direction must be one of: both, out, in"
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
if (cmdOpts.neighbors && (cmdOpts.from || cmdOpts.to)) {
|
|
2283
|
+
throw new CliError(
|
|
2284
|
+
"VALIDATION",
|
|
2285
|
+
"--neighbors cannot be combined with --from/--to"
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2262
2288
|
|
|
2263
2289
|
const { graph, formatGraph } = await import("./commands/graph.js");
|
|
2264
2290
|
const result = await graph({
|
|
@@ -2271,6 +2297,11 @@ function wireGraphCommand(program: Command): void {
|
|
|
2271
2297
|
includeIsolated: Boolean(cmdOpts.includeIsolated),
|
|
2272
2298
|
similarTopK,
|
|
2273
2299
|
format,
|
|
2300
|
+
neighbors: cmdOpts.neighbors as string | undefined,
|
|
2301
|
+
direction: cmdOpts.direction as "both" | "out" | "in",
|
|
2302
|
+
from: cmdOpts.from as string | undefined,
|
|
2303
|
+
to: cmdOpts.to as string | undefined,
|
|
2304
|
+
maxDepth,
|
|
2274
2305
|
});
|
|
2275
2306
|
|
|
2276
2307
|
if (!result.success) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { GraphLink, GraphNode } from "../store/types";
|
|
2
|
+
|
|
3
|
+
export interface GraphCommunity {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
size: number;
|
|
7
|
+
edgeCount: number;
|
|
8
|
+
density: number;
|
|
9
|
+
topNodes: Array<
|
|
10
|
+
Pick<
|
|
11
|
+
GraphNode,
|
|
12
|
+
"id" | "uri" | "title" | "collection" | "relPath" | "degree"
|
|
13
|
+
>
|
|
14
|
+
>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GraphCommunityAnalysis {
|
|
18
|
+
total: number;
|
|
19
|
+
algorithm: "deterministic-label-propagation";
|
|
20
|
+
skipped: boolean;
|
|
21
|
+
assignments: Record<string, string>;
|
|
22
|
+
communities: GraphCommunity[];
|
|
23
|
+
warnings: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_COMMUNITY_NODE_CAP = 2000;
|
|
27
|
+
const MAX_ITERATIONS = 8;
|
|
28
|
+
|
|
29
|
+
const confidenceFactor = (confidence: GraphLink["confidence"]): number => {
|
|
30
|
+
switch (confidence) {
|
|
31
|
+
case "explicit":
|
|
32
|
+
return 1;
|
|
33
|
+
case "inferred":
|
|
34
|
+
return 0.75;
|
|
35
|
+
case "ambiguous":
|
|
36
|
+
return 0.5;
|
|
37
|
+
case "similarity":
|
|
38
|
+
return 0.35;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const edgeStrength = (edge: GraphLink): number =>
|
|
43
|
+
Math.max(0.01, edge.weight) * confidenceFactor(edge.confidence);
|
|
44
|
+
|
|
45
|
+
export function analyzeGraphCommunities(
|
|
46
|
+
nodes: GraphNode[],
|
|
47
|
+
links: GraphLink[],
|
|
48
|
+
options: { nodeCap?: number } = {}
|
|
49
|
+
): GraphCommunityAnalysis {
|
|
50
|
+
const nodeCap = options.nodeCap ?? DEFAULT_COMMUNITY_NODE_CAP;
|
|
51
|
+
if (nodes.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
total: 0,
|
|
54
|
+
algorithm: "deterministic-label-propagation",
|
|
55
|
+
skipped: false,
|
|
56
|
+
assignments: {},
|
|
57
|
+
communities: [],
|
|
58
|
+
warnings: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (nodes.length > nodeCap) {
|
|
63
|
+
return {
|
|
64
|
+
total: 0,
|
|
65
|
+
algorithm: "deterministic-label-propagation",
|
|
66
|
+
skipped: true,
|
|
67
|
+
assignments: {},
|
|
68
|
+
communities: [],
|
|
69
|
+
warnings: [
|
|
70
|
+
`Community detection skipped: graph has ${nodes.length} nodes (cap ${nodeCap})`,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sortedNodes = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
76
|
+
const nodeIds = new Set(sortedNodes.map((node) => node.id));
|
|
77
|
+
const adjacency = new Map<string, Map<string, number>>();
|
|
78
|
+
for (const node of sortedNodes) {
|
|
79
|
+
adjacency.set(node.id, new Map());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const edge of links) {
|
|
83
|
+
if (!(nodeIds.has(edge.source) && nodeIds.has(edge.target))) continue;
|
|
84
|
+
if (edge.source === edge.target) continue;
|
|
85
|
+
const strength = edgeStrength(edge);
|
|
86
|
+
const sourceNeighbors = adjacency.get(edge.source);
|
|
87
|
+
const targetNeighbors = adjacency.get(edge.target);
|
|
88
|
+
if (!(sourceNeighbors && targetNeighbors)) continue;
|
|
89
|
+
sourceNeighbors.set(
|
|
90
|
+
edge.target,
|
|
91
|
+
(sourceNeighbors.get(edge.target) ?? 0) + strength
|
|
92
|
+
);
|
|
93
|
+
targetNeighbors.set(
|
|
94
|
+
edge.source,
|
|
95
|
+
(targetNeighbors.get(edge.source) ?? 0) + strength
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const labels = new Map(sortedNodes.map((node) => [node.id, node.id]));
|
|
100
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
101
|
+
let changed = false;
|
|
102
|
+
for (const node of sortedNodes) {
|
|
103
|
+
const neighbors = adjacency.get(node.id);
|
|
104
|
+
if (!neighbors || neighbors.size === 0) continue;
|
|
105
|
+
|
|
106
|
+
const scores = new Map<string, number>();
|
|
107
|
+
for (const [neighborId, strength] of neighbors) {
|
|
108
|
+
const label = labels.get(neighborId) ?? neighborId;
|
|
109
|
+
scores.set(label, (scores.get(label) ?? 0) + strength);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentLabel = labels.get(node.id) ?? node.id;
|
|
113
|
+
let bestLabel = currentLabel;
|
|
114
|
+
let bestScore = scores.get(currentLabel) ?? 0;
|
|
115
|
+
for (const [candidate, score] of [...scores.entries()].sort((a, b) =>
|
|
116
|
+
a[0].localeCompare(b[0])
|
|
117
|
+
)) {
|
|
118
|
+
if (
|
|
119
|
+
score > bestScore ||
|
|
120
|
+
(score === bestScore && candidate < bestLabel)
|
|
121
|
+
) {
|
|
122
|
+
bestLabel = candidate;
|
|
123
|
+
bestScore = score;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (bestLabel !== currentLabel) {
|
|
127
|
+
labels.set(node.id, bestLabel);
|
|
128
|
+
changed = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!changed) break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const groups = new Map<string, GraphNode[]>();
|
|
135
|
+
for (const node of sortedNodes) {
|
|
136
|
+
const label = labels.get(node.id) ?? node.id;
|
|
137
|
+
const group = groups.get(label) ?? [];
|
|
138
|
+
group.push(node);
|
|
139
|
+
groups.set(label, group);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const edgeCountByLabel = new Map<string, number>();
|
|
143
|
+
for (const edge of links) {
|
|
144
|
+
const sourceLabel = labels.get(edge.source);
|
|
145
|
+
const targetLabel = labels.get(edge.target);
|
|
146
|
+
if (!sourceLabel || sourceLabel !== targetLabel) continue;
|
|
147
|
+
edgeCountByLabel.set(
|
|
148
|
+
sourceLabel,
|
|
149
|
+
(edgeCountByLabel.get(sourceLabel) ?? 0) + 1
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const orderedGroups = [...groups.entries()].sort((a, b) => {
|
|
154
|
+
const sizeDelta = b[1].length - a[1].length;
|
|
155
|
+
if (sizeDelta !== 0) return sizeDelta;
|
|
156
|
+
const aUri = a[1][0]?.uri ?? a[0];
|
|
157
|
+
const bUri = b[1][0]?.uri ?? b[0];
|
|
158
|
+
return aUri.localeCompare(bUri);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const labelToCommunityId = new Map<string, string>();
|
|
162
|
+
const communities = orderedGroups.map(([label, group], index) => {
|
|
163
|
+
const communityId = `c${index + 1}`;
|
|
164
|
+
labelToCommunityId.set(label, communityId);
|
|
165
|
+
const sortedGroup = [...group].sort(
|
|
166
|
+
(a, b) => b.degree - a.degree || a.uri.localeCompare(b.uri)
|
|
167
|
+
);
|
|
168
|
+
const edgeCount = edgeCountByLabel.get(label) ?? 0;
|
|
169
|
+
const maxUndirectedEdges = (group.length * (group.length - 1)) / 2;
|
|
170
|
+
return {
|
|
171
|
+
id: communityId,
|
|
172
|
+
label: sortedGroup[0]?.title ?? sortedGroup[0]?.relPath ?? communityId,
|
|
173
|
+
size: group.length,
|
|
174
|
+
edgeCount,
|
|
175
|
+
density: maxUndirectedEdges > 0 ? edgeCount / maxUndirectedEdges : 0,
|
|
176
|
+
topNodes: sortedGroup.slice(0, 5).map((node) => ({
|
|
177
|
+
id: node.id,
|
|
178
|
+
uri: node.uri,
|
|
179
|
+
title: node.title,
|
|
180
|
+
collection: node.collection,
|
|
181
|
+
relPath: node.relPath,
|
|
182
|
+
degree: node.degree,
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const assignments: Record<string, string> = {};
|
|
188
|
+
for (const node of sortedNodes) {
|
|
189
|
+
const label = labels.get(node.id) ?? node.id;
|
|
190
|
+
assignments[node.id] = labelToCommunityId.get(label) ?? "c0";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
total: communities.length,
|
|
195
|
+
algorithm: "deterministic-label-propagation",
|
|
196
|
+
skipped: false,
|
|
197
|
+
assignments,
|
|
198
|
+
communities: communities.slice(0, 10),
|
|
199
|
+
warnings: [],
|
|
200
|
+
};
|
|
201
|
+
}
|