akm-cli 0.7.5 → 0.8.0-rc2
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/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +853 -479
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +285 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +8 -26
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +378 -1
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +194 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Search-time graph-boost integration for the `akm index` graph pass (#207).
|
|
3
3
|
*
|
|
4
4
|
* This module is the consumer half of the graph-extraction pass. It loads
|
|
5
|
-
* the persisted
|
|
5
|
+
* the persisted graph snapshot from SQLite and exposes a single helper,
|
|
6
6
|
* {@link computeGraphBoost}, that the existing FTS5+boosts loop in
|
|
7
7
|
* `src/indexer/db-search.ts` calls per-entry to obtain an additive boost
|
|
8
8
|
* value.
|
|
@@ -13,12 +13,26 @@
|
|
|
13
13
|
* - There is no second `SearchHit` scorer. `searchDatabase` continues to
|
|
14
14
|
* own ranking; this module just answers "what additive boost does the
|
|
15
15
|
* graph contribute for this (query, entry) pair?".
|
|
16
|
-
* - Missing
|
|
16
|
+
* - Missing graph rows → boost is `0`. The pipeline
|
|
17
17
|
* degrades gracefully to its non-graph behaviour, exactly as today.
|
|
18
18
|
*/
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
import { loadStoredGraphMeta, loadStoredGraphSnapshot } from "./graph-db";
|
|
20
|
+
function normalizeGraphName(value) {
|
|
21
|
+
return value.trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
let cachedParsedGraph;
|
|
24
|
+
function resolveGraphBoostWeights(config) {
|
|
25
|
+
const configured = config?.search?.graphBoost;
|
|
26
|
+
return {
|
|
27
|
+
directBoostPerEntity: configured?.directBoostPerEntity ?? GRAPH_DIRECT_BOOST_PER_ENTITY,
|
|
28
|
+
directBoostCap: configured?.directBoostCap ?? GRAPH_DIRECT_BOOST_CAP,
|
|
29
|
+
hopBoostPerEntity: configured?.hopBoostPerEntity ?? GRAPH_HOP_BOOST_PER_ENTITY,
|
|
30
|
+
hopBoostCap: configured?.hopBoostCap ?? GRAPH_HOP_BOOST_CAP,
|
|
31
|
+
maxHops: Math.min(Math.max(configured?.maxHops ?? GRAPH_MAX_HOPS, 1), GRAPH_MAX_HOPS_HARD_CAP),
|
|
32
|
+
confidenceMode: configured?.confidenceMode ?? GRAPH_CONFIDENCE_MODE,
|
|
33
|
+
confidenceWeight: configured?.confidenceWeight ?? GRAPH_CONFIDENCE_WEIGHT,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
22
36
|
/**
|
|
23
37
|
* Per-entry weights, exposed as constants so tests can read them and so the
|
|
24
38
|
* single-source-of-truth for "how much does the graph contribute" is here
|
|
@@ -29,20 +43,47 @@ export const GRAPH_DIRECT_BOOST_PER_ENTITY = 0.25;
|
|
|
29
43
|
export const GRAPH_DIRECT_BOOST_CAP = 0.75;
|
|
30
44
|
export const GRAPH_HOP_BOOST_PER_ENTITY = 0.1;
|
|
31
45
|
export const GRAPH_HOP_BOOST_CAP = 0.3;
|
|
46
|
+
export const GRAPH_MAX_HOPS = 1;
|
|
47
|
+
export const GRAPH_CONFIDENCE_MODE = "blend";
|
|
48
|
+
export const GRAPH_CONFIDENCE_WEIGHT = 0.2;
|
|
49
|
+
const GRAPH_MAX_HOPS_HARD_CAP = 3;
|
|
50
|
+
function normalizeConfidence(raw) {
|
|
51
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
52
|
+
return undefined;
|
|
53
|
+
return Math.max(0, Math.min(1, raw));
|
|
54
|
+
}
|
|
55
|
+
function combineConfidence(...parts) {
|
|
56
|
+
let out;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
const value = normalizeConfidence(part);
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
out = out === undefined ? value : out * value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function toConfidenceMultiplier(rawConfidence, weights) {
|
|
66
|
+
if (weights.confidenceMode === "off")
|
|
67
|
+
return 1;
|
|
68
|
+
const confidence = normalizeConfidence(rawConfidence) ?? 1;
|
|
69
|
+
if (weights.confidenceMode === "multiply")
|
|
70
|
+
return confidence;
|
|
71
|
+
const blendWeight = Math.max(0, Math.min(1, weights.confidenceWeight));
|
|
72
|
+
return 1 - blendWeight + blendWeight * confidence;
|
|
73
|
+
}
|
|
32
74
|
/**
|
|
33
75
|
* Load the graph file for a stash root and pre-compute everything that's
|
|
34
76
|
* shared across all entries scored for one query. Returns `null` when:
|
|
35
|
-
* -
|
|
36
|
-
* - The file fails to parse.
|
|
37
|
-
* - The schema version doesn't match (treated like "missing" so an old
|
|
38
|
-
* index keeps working until the next `akm index --full`).
|
|
77
|
+
* - No graph snapshot exists in SQLite.
|
|
39
78
|
* - The query produces no token-level entity matches (no boost is
|
|
40
79
|
* possible, so we skip the per-entry overhead entirely).
|
|
41
80
|
*/
|
|
42
|
-
export function loadGraphBoostContext(stashRoot, query) {
|
|
43
|
-
const
|
|
44
|
-
|
|
81
|
+
export function loadGraphBoostContext(stashRoot, query, config, db) {
|
|
82
|
+
const stashRoots = Array.isArray(stashRoot) ? stashRoot : [stashRoot];
|
|
83
|
+
const parsed = readParsedGraphContext(stashRoots, db);
|
|
84
|
+
if (!parsed)
|
|
45
85
|
return null;
|
|
86
|
+
const weights = resolveGraphBoostWeights(config);
|
|
46
87
|
const queryTokens = query
|
|
47
88
|
.toLowerCase()
|
|
48
89
|
.split(/[\s\-_/]+/)
|
|
@@ -53,20 +94,21 @@ export function loadGraphBoostContext(stashRoot, query) {
|
|
|
53
94
|
// is small (capped per-asset at extract time) and lets the per-entry
|
|
54
95
|
// path do a single set membership test.
|
|
55
96
|
const allEntities = new Set();
|
|
56
|
-
const
|
|
57
|
-
for (const node of graph.files) {
|
|
58
|
-
nodesByPath.set(node.path, node);
|
|
97
|
+
for (const node of parsed.graph.files) {
|
|
59
98
|
for (const entity of node.entities)
|
|
60
99
|
allEntities.add(entity);
|
|
61
100
|
}
|
|
62
101
|
// An entity matches the query when any of its sub-tokens equals or
|
|
63
|
-
// contains a query token.
|
|
64
|
-
//
|
|
102
|
+
// contains a query token. Matching is case-insensitive; the graph keeps
|
|
103
|
+
// canonical display strings and we normalize only for comparisons here.
|
|
65
104
|
const matchedEntities = new Set();
|
|
66
105
|
for (const entity of allEntities) {
|
|
67
|
-
const
|
|
106
|
+
const normalizedEntity = normalizeGraphName(entity);
|
|
107
|
+
const entityTokens = normalizedEntity.split(/[\s\-_/]+/).filter(Boolean);
|
|
68
108
|
for (const qt of queryTokens) {
|
|
69
|
-
if (
|
|
109
|
+
if (normalizedEntity === qt ||
|
|
110
|
+
normalizedEntity.includes(qt) ||
|
|
111
|
+
entityTokens.some((et) => et === qt || et.includes(qt))) {
|
|
70
112
|
matchedEntities.add(entity);
|
|
71
113
|
break;
|
|
72
114
|
}
|
|
@@ -74,20 +116,47 @@ export function loadGraphBoostContext(stashRoot, query) {
|
|
|
74
116
|
}
|
|
75
117
|
if (matchedEntities.size === 0)
|
|
76
118
|
return null;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
const connectedEntities = new Set();
|
|
120
|
+
const connectedConfidence = new Map();
|
|
121
|
+
const visited = new Set();
|
|
122
|
+
let frontier = new Map();
|
|
123
|
+
for (const entity of matchedEntities) {
|
|
124
|
+
const seed = parsed.entityConfidence.get(entity) ?? 1;
|
|
125
|
+
frontier.set(entity, seed);
|
|
126
|
+
visited.add(entity);
|
|
127
|
+
}
|
|
128
|
+
for (let hop = 1; hop <= weights.maxHops; hop += 1) {
|
|
129
|
+
const next = new Map();
|
|
130
|
+
for (const [entity, pathConfidence] of frontier.entries()) {
|
|
131
|
+
const neighbors = parsed.adjacency.get(entity);
|
|
132
|
+
if (!neighbors)
|
|
133
|
+
continue;
|
|
134
|
+
for (const [neighbor, edgeConfidence] of neighbors.entries()) {
|
|
135
|
+
const neighborPathConfidence = Math.max(0, Math.min(1, pathConfidence * edgeConfidence));
|
|
136
|
+
const currentBest = connectedConfidence.get(neighbor) ?? 0;
|
|
137
|
+
if (neighborPathConfidence > currentBest)
|
|
138
|
+
connectedConfidence.set(neighbor, neighborPathConfidence);
|
|
139
|
+
if (visited.has(neighbor))
|
|
140
|
+
continue;
|
|
141
|
+
visited.add(neighbor);
|
|
142
|
+
next.set(neighbor, Math.max(next.get(neighbor) ?? 0, neighborPathConfidence));
|
|
143
|
+
connectedEntities.add(neighbor);
|
|
87
144
|
}
|
|
88
145
|
}
|
|
146
|
+
if (next.size === 0)
|
|
147
|
+
break;
|
|
148
|
+
frontier = next;
|
|
89
149
|
}
|
|
90
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
graph: parsed.graph,
|
|
152
|
+
nodesByPath: parsed.nodesByPath,
|
|
153
|
+
matchedEntities,
|
|
154
|
+
connectedEntities,
|
|
155
|
+
connectedConfidence,
|
|
156
|
+
entityConfidence: parsed.entityConfidence,
|
|
157
|
+
adjacency: parsed.adjacency,
|
|
158
|
+
weights,
|
|
159
|
+
};
|
|
91
160
|
}
|
|
92
161
|
/**
|
|
93
162
|
* Compute the graph-boost contribution for a single scored entry.
|
|
@@ -100,80 +169,164 @@ export function computeGraphBoost(context, filePath) {
|
|
|
100
169
|
const node = context.nodesByPath.get(filePath);
|
|
101
170
|
if (!node)
|
|
102
171
|
return 0;
|
|
103
|
-
let
|
|
104
|
-
let
|
|
172
|
+
let directBoostRaw = 0;
|
|
173
|
+
let hopBoostRaw = 0;
|
|
105
174
|
for (const entity of node.entities) {
|
|
106
|
-
if (context.matchedEntities.has(entity))
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
175
|
+
if (context.matchedEntities.has(entity)) {
|
|
176
|
+
const directConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity));
|
|
177
|
+
directBoostRaw +=
|
|
178
|
+
context.weights.directBoostPerEntity * toConfidenceMultiplier(directConfidence, context.weights);
|
|
179
|
+
}
|
|
180
|
+
else if (context.connectedEntities.has(entity)) {
|
|
181
|
+
const hopConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity), context.connectedConfidence.get(entity));
|
|
182
|
+
hopBoostRaw += context.weights.hopBoostPerEntity * toConfidenceMultiplier(hopConfidence, context.weights);
|
|
183
|
+
}
|
|
110
184
|
}
|
|
111
|
-
const directBoost = Math.min(
|
|
112
|
-
const hopBoost = Math.min(
|
|
185
|
+
const directBoost = Math.min(context.weights.directBoostCap, directBoostRaw);
|
|
186
|
+
const hopBoost = Math.min(context.weights.hopBoostCap, hopBoostRaw);
|
|
113
187
|
return directBoost + hopBoost;
|
|
114
188
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
* when an existing file fails to parse so corruption is visible.
|
|
119
|
-
*/
|
|
120
|
-
function readGraphFile(stashRoot) {
|
|
121
|
-
const target = getGraphFilePath(stashRoot);
|
|
122
|
-
let raw;
|
|
123
|
-
try {
|
|
124
|
-
raw = fs.readFileSync(target, "utf8");
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
// Missing → no boost. Not an error: the user simply hasn't enabled
|
|
128
|
-
// graph extraction yet, or the pass hasn't run.
|
|
189
|
+
export function collectGraphRelatedHit(context, filePath) {
|
|
190
|
+
const node = context.nodesByPath.get(filePath);
|
|
191
|
+
if (!node)
|
|
129
192
|
return null;
|
|
193
|
+
const entities = [];
|
|
194
|
+
for (const entity of node.entities) {
|
|
195
|
+
if (context.matchedEntities.has(entity)) {
|
|
196
|
+
entities.push({
|
|
197
|
+
name: entity,
|
|
198
|
+
kind: "matched",
|
|
199
|
+
...(context.entityConfidence.get(entity) !== undefined
|
|
200
|
+
? { confidence: context.entityConfidence.get(entity) }
|
|
201
|
+
: {}),
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (context.connectedEntities.has(entity)) {
|
|
206
|
+
entities.push({
|
|
207
|
+
name: entity,
|
|
208
|
+
kind: "connected",
|
|
209
|
+
...(context.connectedConfidence.get(entity) !== undefined
|
|
210
|
+
? { confidence: context.connectedConfidence.get(entity) }
|
|
211
|
+
: {}),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
130
214
|
}
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
parsed = JSON.parse(raw);
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
warn(`graph boost: failed to parse ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
215
|
+
if (entities.length === 0)
|
|
137
216
|
return null;
|
|
217
|
+
const relatedNames = new Set(entities.map((entity) => entity.name));
|
|
218
|
+
const relations = node.relations
|
|
219
|
+
.filter((relation) => relatedNames.has(relation.from) || relatedNames.has(relation.to))
|
|
220
|
+
.map((relation) => ({
|
|
221
|
+
from: relation.from,
|
|
222
|
+
to: relation.to,
|
|
223
|
+
...(relation.type ? { type: relation.type } : {}),
|
|
224
|
+
...(normalizeConfidence(relation.confidence) !== undefined
|
|
225
|
+
? { confidence: normalizeConfidence(relation.confidence) }
|
|
226
|
+
: {}),
|
|
227
|
+
}));
|
|
228
|
+
return {
|
|
229
|
+
path: filePath,
|
|
230
|
+
type: node.type,
|
|
231
|
+
entities: entities.sort((a, b) => a.name.localeCompare(b.name)),
|
|
232
|
+
relations,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
|
|
236
|
+
const parsed = readParsedGraphContext([stashRoot], db);
|
|
237
|
+
if (!parsed)
|
|
238
|
+
return [];
|
|
239
|
+
const node = parsed.nodesByPath.get(filePath);
|
|
240
|
+
if (!node)
|
|
241
|
+
return [];
|
|
242
|
+
const entitySet = new Set(node.entities.map(normalizeGraphName));
|
|
243
|
+
const results = [];
|
|
244
|
+
for (const candidate of parsed.graph.files) {
|
|
245
|
+
if (candidate.path === filePath)
|
|
246
|
+
continue;
|
|
247
|
+
const sharedEntities = candidate.entities.filter((entity) => entitySet.has(normalizeGraphName(entity)));
|
|
248
|
+
if (sharedEntities.length === 0)
|
|
249
|
+
continue;
|
|
250
|
+
const relationCount = candidate.relations.filter((relation) => entitySet.has(normalizeGraphName(relation.from)) || entitySet.has(normalizeGraphName(relation.to))).length;
|
|
251
|
+
results.push({
|
|
252
|
+
path: candidate.path,
|
|
253
|
+
type: candidate.type,
|
|
254
|
+
sharedEntities: [...new Set(sharedEntities)].sort((a, b) => a.localeCompare(b)),
|
|
255
|
+
relationCount,
|
|
256
|
+
});
|
|
138
257
|
}
|
|
139
|
-
|
|
258
|
+
results.sort((a, b) => b.sharedEntities.length - a.sharedEntities.length ||
|
|
259
|
+
b.relationCount - a.relationCount ||
|
|
260
|
+
a.path.localeCompare(b.path));
|
|
261
|
+
return results.slice(0, Math.max(1, limit));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Load and normalize graph data from SQLite once, then reuse it across all
|
|
265
|
+
* per-entry boost lookups in the current query.
|
|
266
|
+
*/
|
|
267
|
+
function readParsedGraphContext(stashRoots, db) {
|
|
268
|
+
const sortedRoots = [...new Set(stashRoots)].sort((a, b) => a.localeCompare(b));
|
|
269
|
+
if (sortedRoots.length === 0)
|
|
140
270
|
return null;
|
|
271
|
+
const metas = sortedRoots
|
|
272
|
+
.map((stashRoot) => loadStoredGraphMeta(stashRoot, db))
|
|
273
|
+
.filter((meta) => meta !== null);
|
|
274
|
+
if (metas.length === 0)
|
|
275
|
+
return null;
|
|
276
|
+
const cacheKey = metas.map((meta) => `${meta.stashPath}\u0000${meta.generatedAt}`).join("\u0001");
|
|
277
|
+
if (cachedParsedGraph && cachedParsedGraph.cacheKey === cacheKey)
|
|
278
|
+
return cachedParsedGraph.context;
|
|
279
|
+
const snapshots = metas
|
|
280
|
+
.map((meta) => loadStoredGraphSnapshot(meta.stashPath, db))
|
|
281
|
+
.filter((snapshot) => snapshot !== null);
|
|
282
|
+
if (snapshots.length === 0)
|
|
283
|
+
return null;
|
|
284
|
+
const graph = {
|
|
285
|
+
schemaVersion: Math.max(...snapshots.map((snapshot) => snapshot.schemaVersion)),
|
|
286
|
+
generatedAt: snapshots
|
|
287
|
+
.map((snapshot) => snapshot.generatedAt)
|
|
288
|
+
.sort()
|
|
289
|
+
.at(-1) ?? new Date(0).toISOString(),
|
|
290
|
+
stashRoot: snapshots[0]?.stashPath ?? "",
|
|
291
|
+
files: snapshots.flatMap((snapshot) => snapshot.files),
|
|
292
|
+
entities: [...new Set(snapshots.flatMap((snapshot) => snapshot.entities))],
|
|
293
|
+
relations: snapshots.flatMap((snapshot) => snapshot.relations),
|
|
294
|
+
};
|
|
295
|
+
const nodesByPath = new Map();
|
|
296
|
+
const entityConfidence = new Map();
|
|
297
|
+
const adjacency = new Map();
|
|
298
|
+
function setBestEntityConfidence(entity, confidence) {
|
|
299
|
+
const normalized = normalizeConfidence(confidence);
|
|
300
|
+
if (normalized === undefined)
|
|
301
|
+
return;
|
|
302
|
+
const current = entityConfidence.get(entity);
|
|
303
|
+
if (current === undefined || normalized > current)
|
|
304
|
+
entityConfidence.set(entity, normalized);
|
|
141
305
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const node
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return false;
|
|
164
|
-
if (!Array.isArray(node.entities) || !node.entities.every((e) => typeof e === "string"))
|
|
165
|
-
return false;
|
|
166
|
-
if (!Array.isArray(node.relations))
|
|
167
|
-
return false;
|
|
168
|
-
for (const r of node.relations) {
|
|
169
|
-
if (typeof r !== "object" || r === null)
|
|
170
|
-
return false;
|
|
171
|
-
const rel = r;
|
|
172
|
-
if (typeof rel.from !== "string" || typeof rel.to !== "string")
|
|
173
|
-
return false;
|
|
174
|
-
if (rel.type !== undefined && typeof rel.type !== "string")
|
|
175
|
-
return false;
|
|
306
|
+
function setBestEdgeConfidence(from, to, confidence) {
|
|
307
|
+
const normalized = normalizeConfidence(confidence);
|
|
308
|
+
if (!adjacency.has(from))
|
|
309
|
+
adjacency.set(from, new Map());
|
|
310
|
+
const neighbors = adjacency.get(from);
|
|
311
|
+
if (!neighbors)
|
|
312
|
+
return;
|
|
313
|
+
const current = neighbors.get(to);
|
|
314
|
+
const next = normalized ?? 1;
|
|
315
|
+
if (current === undefined || next > current)
|
|
316
|
+
neighbors.set(to, next);
|
|
317
|
+
}
|
|
318
|
+
for (const node of graph.files) {
|
|
319
|
+
nodesByPath.set(node.path, node);
|
|
320
|
+
for (const entity of node.entities) {
|
|
321
|
+
setBestEntityConfidence(entity, node.confidence);
|
|
322
|
+
}
|
|
323
|
+
for (const rel of node.relations) {
|
|
324
|
+
const edgeConfidence = combineConfidence(node.confidence, rel.confidence);
|
|
325
|
+
setBestEdgeConfidence(rel.from, rel.to, edgeConfidence);
|
|
326
|
+
setBestEdgeConfidence(rel.to, rel.from, edgeConfidence);
|
|
176
327
|
}
|
|
177
328
|
}
|
|
178
|
-
|
|
329
|
+
const context = { graph, nodesByPath, entityConfidence, adjacency };
|
|
330
|
+
cachedParsedGraph = { cacheKey, context };
|
|
331
|
+
return context;
|
|
179
332
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { getDbPath } from "../core/paths";
|
|
3
|
+
import { closeDatabase, openExistingDatabase } from "./db";
|
|
4
|
+
function withReadableGraphDb(db, fn) {
|
|
5
|
+
if (db)
|
|
6
|
+
return fn(db);
|
|
7
|
+
const dbPath = getDbPath();
|
|
8
|
+
if (!fs.existsSync(dbPath))
|
|
9
|
+
throw new Error("GRAPH_DB_MISSING");
|
|
10
|
+
const opened = openExistingDatabase(dbPath);
|
|
11
|
+
try {
|
|
12
|
+
return fn(opened);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
closeDatabase(opened);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function uniqueSorted(values) {
|
|
19
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
20
|
+
}
|
|
21
|
+
export function replaceStoredGraph(db, graph) {
|
|
22
|
+
const upsertMeta = db.prepare(`INSERT INTO graph_meta (
|
|
23
|
+
stash_root,
|
|
24
|
+
schema_version,
|
|
25
|
+
generated_at,
|
|
26
|
+
considered_files,
|
|
27
|
+
extracted_files,
|
|
28
|
+
entity_count,
|
|
29
|
+
relation_count,
|
|
30
|
+
extraction_coverage,
|
|
31
|
+
density
|
|
32
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
33
|
+
ON CONFLICT(stash_root) DO UPDATE SET
|
|
34
|
+
schema_version = excluded.schema_version,
|
|
35
|
+
generated_at = excluded.generated_at,
|
|
36
|
+
considered_files = excluded.considered_files,
|
|
37
|
+
extracted_files = excluded.extracted_files,
|
|
38
|
+
entity_count = excluded.entity_count,
|
|
39
|
+
relation_count = excluded.relation_count,
|
|
40
|
+
extraction_coverage = excluded.extraction_coverage,
|
|
41
|
+
density = excluded.density`);
|
|
42
|
+
const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ?");
|
|
43
|
+
const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ?");
|
|
44
|
+
const deleteFiles = db.prepare("DELETE FROM graph_files WHERE stash_root = ?");
|
|
45
|
+
const insertFile = db.prepare(`INSERT INTO graph_files (stash_root, file_path, file_order, file_type, body_hash, confidence)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
47
|
+
const insertEntity = db.prepare(`INSERT INTO graph_file_entities (stash_root, file_path, entity_order, entity)
|
|
48
|
+
VALUES (?, ?, ?, ?)`);
|
|
49
|
+
const insertRelation = db.prepare(`INSERT INTO graph_file_relations (
|
|
50
|
+
stash_root,
|
|
51
|
+
file_path,
|
|
52
|
+
relation_order,
|
|
53
|
+
from_entity,
|
|
54
|
+
to_entity,
|
|
55
|
+
relation_type,
|
|
56
|
+
confidence
|
|
57
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
58
|
+
const quality = graph.quality;
|
|
59
|
+
db.transaction(() => {
|
|
60
|
+
upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality?.consideredFiles ?? graph.files.length, quality?.extractedFiles ?? graph.files.length, quality?.entityCount ?? graph.entities?.length ?? 0, quality?.relationCount ?? graph.relations?.length ?? 0, quality?.extractionCoverage ?? 0, quality?.density ?? 0);
|
|
61
|
+
deleteRelations.run(graph.stashRoot);
|
|
62
|
+
deleteEntities.run(graph.stashRoot);
|
|
63
|
+
deleteFiles.run(graph.stashRoot);
|
|
64
|
+
for (const [fileOrder, node] of graph.files.entries()) {
|
|
65
|
+
insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, node.bodyHash ?? null, node.confidence ?? null);
|
|
66
|
+
for (const [entityOrder, entity] of node.entities.entries()) {
|
|
67
|
+
insertEntity.run(graph.stashRoot, node.path, entityOrder, entity);
|
|
68
|
+
}
|
|
69
|
+
for (const [relationOrder, relation] of node.relations.entries()) {
|
|
70
|
+
insertRelation.run(graph.stashRoot, node.path, relationOrder, relation.from, relation.to, relation.type ?? null, relation.confidence ?? null);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
}
|
|
75
|
+
export function deleteStoredGraph(db, stashPath) {
|
|
76
|
+
db.transaction(() => {
|
|
77
|
+
db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ?").run(stashPath);
|
|
78
|
+
db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ?").run(stashPath);
|
|
79
|
+
db.prepare("DELETE FROM graph_files WHERE stash_root = ?").run(stashPath);
|
|
80
|
+
db.prepare("DELETE FROM graph_meta WHERE stash_root = ?").run(stashPath);
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
export function loadStoredGraphMeta(stashPath, db) {
|
|
84
|
+
try {
|
|
85
|
+
return withReadableGraphDb(db, (readDb) => {
|
|
86
|
+
try {
|
|
87
|
+
const row = readDb
|
|
88
|
+
.prepare(`SELECT
|
|
89
|
+
stash_root,
|
|
90
|
+
schema_version,
|
|
91
|
+
generated_at,
|
|
92
|
+
considered_files,
|
|
93
|
+
extracted_files,
|
|
94
|
+
entity_count,
|
|
95
|
+
relation_count,
|
|
96
|
+
extraction_coverage,
|
|
97
|
+
density
|
|
98
|
+
FROM graph_meta
|
|
99
|
+
WHERE stash_root = ?`)
|
|
100
|
+
.get(stashPath);
|
|
101
|
+
if (!row)
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
stashPath: row.stash_root,
|
|
105
|
+
graphPath: getDbPath(),
|
|
106
|
+
schemaVersion: row.schema_version,
|
|
107
|
+
generatedAt: row.generated_at,
|
|
108
|
+
quality: {
|
|
109
|
+
consideredFiles: row.considered_files,
|
|
110
|
+
extractedFiles: row.extracted_files,
|
|
111
|
+
entityCount: row.entity_count,
|
|
112
|
+
relationCount: row.relation_count,
|
|
113
|
+
extractionCoverage: row.extraction_coverage,
|
|
114
|
+
density: row.density,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function loadStoredGraphSnapshot(stashPath, db) {
|
|
128
|
+
try {
|
|
129
|
+
return withReadableGraphDb(db, (readDb) => {
|
|
130
|
+
const meta = loadStoredGraphMeta(stashPath, readDb);
|
|
131
|
+
if (!meta)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const fileRows = readDb
|
|
135
|
+
.prepare(`SELECT file_path, file_type, body_hash, confidence
|
|
136
|
+
FROM graph_files
|
|
137
|
+
WHERE stash_root = ?
|
|
138
|
+
ORDER BY file_order`)
|
|
139
|
+
.all(stashPath);
|
|
140
|
+
const entityRows = readDb
|
|
141
|
+
.prepare(`SELECT file_path, entity
|
|
142
|
+
FROM graph_file_entities
|
|
143
|
+
WHERE stash_root = ?
|
|
144
|
+
ORDER BY file_path, entity_order`)
|
|
145
|
+
.all(stashPath);
|
|
146
|
+
const relationRows = readDb
|
|
147
|
+
.prepare(`SELECT file_path, from_entity, to_entity, relation_type, confidence
|
|
148
|
+
FROM graph_file_relations
|
|
149
|
+
WHERE stash_root = ?
|
|
150
|
+
ORDER BY file_path, relation_order`)
|
|
151
|
+
.all(stashPath);
|
|
152
|
+
const entitiesByPath = new Map();
|
|
153
|
+
for (const row of entityRows) {
|
|
154
|
+
const bucket = entitiesByPath.get(row.file_path);
|
|
155
|
+
if (bucket)
|
|
156
|
+
bucket.push(row.entity);
|
|
157
|
+
else
|
|
158
|
+
entitiesByPath.set(row.file_path, [row.entity]);
|
|
159
|
+
}
|
|
160
|
+
const relationsByPath = new Map();
|
|
161
|
+
for (const row of relationRows) {
|
|
162
|
+
const relation = {
|
|
163
|
+
from: row.from_entity,
|
|
164
|
+
to: row.to_entity,
|
|
165
|
+
...(row.relation_type ? { type: row.relation_type } : {}),
|
|
166
|
+
...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
|
|
167
|
+
};
|
|
168
|
+
const bucket = relationsByPath.get(row.file_path);
|
|
169
|
+
if (bucket)
|
|
170
|
+
bucket.push(relation);
|
|
171
|
+
else
|
|
172
|
+
relationsByPath.set(row.file_path, [relation]);
|
|
173
|
+
}
|
|
174
|
+
const files = fileRows.map((row) => ({
|
|
175
|
+
path: row.file_path,
|
|
176
|
+
type: row.file_type,
|
|
177
|
+
...(row.body_hash ? { bodyHash: row.body_hash } : {}),
|
|
178
|
+
entities: entitiesByPath.get(row.file_path) ?? [],
|
|
179
|
+
relations: relationsByPath.get(row.file_path) ?? [],
|
|
180
|
+
...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
|
|
181
|
+
}));
|
|
182
|
+
return {
|
|
183
|
+
stashPath: meta.stashPath,
|
|
184
|
+
graphPath: meta.graphPath,
|
|
185
|
+
schemaVersion: meta.schemaVersion,
|
|
186
|
+
generatedAt: meta.generatedAt,
|
|
187
|
+
...(meta.quality ? { quality: meta.quality } : {}),
|
|
188
|
+
files,
|
|
189
|
+
entities: uniqueSorted(files.flatMap((file) => file.entities)),
|
|
190
|
+
relations: files.flatMap((file) => file.relations),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|