akm-cli 0.7.5 → 0.8.0-rc1
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 +804 -461
- 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 +251 -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 +2 -23
- package/dist/core/action-contributors.js +25 -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 +377 -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 +188 -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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* asks the configured LLM to surface the entities mentioned in it and the
|
|
6
6
|
* relations between them. The pass itself
|
|
7
7
|
* (`src/indexer/graph-extraction.ts`) is responsible for deciding which
|
|
8
|
-
* files to extract, persisting the resulting nodes/edges to
|
|
9
|
-
*
|
|
8
|
+
* files to extract, persisting the resulting nodes/edges to the index DB,
|
|
9
|
+
* and feeding the graph data into the FTS5+boosts
|
|
10
10
|
* search pipeline as a single boost component.
|
|
11
11
|
*
|
|
12
12
|
* This module is intentionally tiny and stateless so tests can stub it via
|
|
@@ -20,86 +20,321 @@
|
|
|
20
20
|
import { toErrorMessage } from "../core/common";
|
|
21
21
|
import { warn } from "../core/warn";
|
|
22
22
|
import { chatCompletion, parseEmbeddedJsonResponse } from "./client";
|
|
23
|
+
import { tryLlmFeature } from "./feature-gate";
|
|
24
|
+
import userPromptTemplate from "./prompts/graph-extract-user-prompt.md" with { type: "text" };
|
|
25
|
+
/**
|
|
26
|
+
* Separator token used between assets in a batch prompt.
|
|
27
|
+
* Chosen to be visually clear and unlikely to appear verbatim in asset bodies.
|
|
28
|
+
*/
|
|
29
|
+
const BATCH_ASSET_SEPARATOR = "=== ASSET";
|
|
23
30
|
/** Hard cap on body chars sent to the model. */
|
|
24
31
|
const MAX_BODY_CHARS = 4000;
|
|
25
32
|
/** Hard cap on entities returned per asset — guards against runaway LLM output. */
|
|
26
33
|
const MAX_ENTITIES_PER_ASSET = 32;
|
|
27
34
|
/** Hard cap on relations returned per asset. */
|
|
28
35
|
const MAX_RELATIONS_PER_ASSET = 32;
|
|
29
|
-
const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return
|
|
30
|
-
const USER_PROMPT_PREFIX =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return ONLY valid JSON — no prose, no markdown fences, no preamble.";
|
|
37
|
+
const USER_PROMPT_PREFIX = userPromptTemplate
|
|
38
|
+
.replace("{{MAX_ENTITIES}}", String(MAX_ENTITIES_PER_ASSET))
|
|
39
|
+
.replace("{{MAX_RELATIONS}}", String(MAX_RELATIONS_PER_ASSET));
|
|
40
|
+
function parseConfidence(raw) {
|
|
41
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
42
|
+
return undefined;
|
|
43
|
+
return Math.max(0, Math.min(1, raw));
|
|
44
|
+
}
|
|
45
|
+
function normalizeEntityName(raw) {
|
|
46
|
+
return raw
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^[`"']+|[`"']+$/g, "")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.replace(/[;,!?]+$/g, "")
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
function normalizeRelationType(raw) {
|
|
54
|
+
const normalized = raw
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/^[`"']+|[`"']+$/g, "")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.replace(/[.;,!?]+$/g, "")
|
|
60
|
+
.trim();
|
|
61
|
+
if (!normalized)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (normalized === "use" || normalized === "utilizes")
|
|
64
|
+
return "uses";
|
|
65
|
+
if (normalized === "depend on" || normalized === "depends")
|
|
66
|
+
return "depends on";
|
|
67
|
+
if (normalized === "integrates" || normalized === "integration with")
|
|
68
|
+
return "integrates with";
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
function parseGraphExtraction(raw) {
|
|
72
|
+
const empty = { entities: [], relations: [] };
|
|
73
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
74
|
+
return empty;
|
|
75
|
+
const item = raw;
|
|
76
|
+
const entityCanonical = new Map();
|
|
77
|
+
if (Array.isArray(item.entities)) {
|
|
78
|
+
for (const value of item.entities) {
|
|
79
|
+
if (typeof value !== "string")
|
|
80
|
+
continue;
|
|
81
|
+
const normalized = normalizeEntityName(value);
|
|
82
|
+
if (!normalized)
|
|
83
|
+
continue;
|
|
84
|
+
const key = normalized.toLowerCase();
|
|
85
|
+
if (!entityCanonical.has(key))
|
|
86
|
+
entityCanonical.set(key, normalized);
|
|
87
|
+
if (entityCanonical.size >= MAX_ENTITIES_PER_ASSET)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const entities = Array.from(entityCanonical.values());
|
|
92
|
+
const relations = [];
|
|
93
|
+
if (Array.isArray(item.relations)) {
|
|
94
|
+
for (const relation of item.relations) {
|
|
95
|
+
if (typeof relation !== "object" || relation === null || Array.isArray(relation))
|
|
96
|
+
continue;
|
|
97
|
+
const rel = relation;
|
|
98
|
+
const fromRaw = typeof rel.from === "string" ? normalizeEntityName(rel.from) : "";
|
|
99
|
+
const toRaw = typeof rel.to === "string" ? normalizeEntityName(rel.to) : "";
|
|
100
|
+
if (!fromRaw || !toRaw)
|
|
101
|
+
continue;
|
|
102
|
+
const from = entityCanonical.get(fromRaw.toLowerCase());
|
|
103
|
+
const to = entityCanonical.get(toRaw.toLowerCase());
|
|
104
|
+
if (!from || !to)
|
|
105
|
+
continue;
|
|
106
|
+
const type = typeof rel.type === "string" ? normalizeRelationType(rel.type) : undefined;
|
|
107
|
+
const confidence = parseConfidence(rel.confidence);
|
|
108
|
+
relations.push({
|
|
109
|
+
from,
|
|
110
|
+
to,
|
|
111
|
+
...(type ? { type } : {}),
|
|
112
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
113
|
+
});
|
|
114
|
+
if (relations.length >= MAX_RELATIONS_PER_ASSET)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const confidence = parseConfidence(item.confidence);
|
|
119
|
+
return {
|
|
120
|
+
entities,
|
|
121
|
+
relations,
|
|
122
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the system prompt for a batched graph-extraction call.
|
|
127
|
+
*
|
|
128
|
+
* The prompt instructs the model to return a JSON array of exactly `count`
|
|
129
|
+
* objects, one per asset, in input order. Index alignment is the critical
|
|
130
|
+
* invariant — if the model drops an asset it still must emit an empty
|
|
131
|
+
* placeholder `{"entities":[],"relations":[]}` at that position.
|
|
132
|
+
*
|
|
133
|
+
* Worked example (3 assets, abbreviated):
|
|
134
|
+
*
|
|
135
|
+
* Input user message:
|
|
136
|
+
* Extract entities and relations from the N=3 assets below.
|
|
137
|
+
* ...rules...
|
|
138
|
+
* === ASSET 1 ===
|
|
139
|
+
* ServiceA integrates with ServiceB.
|
|
140
|
+
* === ASSET 2 ===
|
|
141
|
+
* Terraform provisions the Prod cluster.
|
|
142
|
+
* === ASSET 3 ===
|
|
143
|
+
* No extractable graph content here.
|
|
144
|
+
*
|
|
145
|
+
* Expected model output (valid JSON array, no prose):
|
|
146
|
+
* [
|
|
147
|
+
* {"entities":["ServiceA","ServiceB"],"relations":[{"from":"ServiceA","to":"ServiceB","type":"integrates with"}]},
|
|
148
|
+
* {"entities":["Terraform","Prod cluster"],"relations":[{"from":"Terraform","to":"Prod cluster","type":"provisions"}]},
|
|
149
|
+
* {"entities":[],"relations":[]}
|
|
150
|
+
* ]
|
|
151
|
+
*
|
|
152
|
+
* If the model returns fewer than 3 items (partial failure), the caller
|
|
153
|
+
* (`extractGraphFromBodies`) falls back to individual calls for missing indices.
|
|
154
|
+
*/
|
|
155
|
+
function buildBatchSystemPrompt() {
|
|
156
|
+
return ("You extract knowledge graphs from developer notes. " +
|
|
157
|
+
"Return ONLY a valid JSON array — no prose, no markdown fences, no preamble. " +
|
|
158
|
+
"Each element of the array corresponds to one input asset, in order. " +
|
|
159
|
+
"The array length MUST equal the number of assets provided. " +
|
|
160
|
+
'Use {"entities":[],"relations":[]} for assets with no extractable graph content.');
|
|
161
|
+
}
|
|
162
|
+
function buildBatchUserPrompt(bodies) {
|
|
163
|
+
const count = bodies.length;
|
|
164
|
+
const assetBlocks = bodies
|
|
165
|
+
.map((body, i) => `${BATCH_ASSET_SEPARATOR} ${i + 1} ===\n${body.trim().slice(0, MAX_BODY_CHARS)}`)
|
|
166
|
+
.join("\n\n");
|
|
167
|
+
return (`Extract entities and relations from the N=${count} assets below.\n\n` +
|
|
168
|
+
`Rules:\n` +
|
|
169
|
+
`- Output ONLY a JSON array of exactly ${count} objects, one per asset, preserving input order.\n` +
|
|
170
|
+
`- Each object: {"entities": ["Entity One", ...], "relations": [{"from": "A", "to": "B", "type": "uses"}, ...]}\n` +
|
|
171
|
+
`- Entities are short, canonical noun phrases (project names, services, tools, people, file/dir names, technical concepts).\n` +
|
|
172
|
+
`- Relations connect two entities that both appear in that asset's entities array.\n` +
|
|
173
|
+
`- "type" is a short verb phrase (e.g. "uses", "depends on", "owns"). Optional; omit when unsure.\n` +
|
|
174
|
+
`- Drop pleasantries, meta-commentary, and timestamps.\n` +
|
|
175
|
+
`- Limit to at most ${MAX_ENTITIES_PER_ASSET} entities and ${MAX_RELATIONS_PER_ASSET} relations per asset.\n` +
|
|
176
|
+
`- Use {"entities":[],"relations":[]} for assets with no extractable graph content.\n` +
|
|
177
|
+
`- The array MUST have exactly ${count} elements — one placeholder per asset even if empty.\n\n` +
|
|
178
|
+
assetBlocks);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Parse and validate a single item from the batch response array.
|
|
182
|
+
* Mirrors the validation logic in `extractGraphFromBody`.
|
|
183
|
+
*/
|
|
184
|
+
function parseBatchItem(raw) {
|
|
185
|
+
return parseGraphExtraction(raw);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Extract entities and relations from multiple asset bodies in a single LLM
|
|
189
|
+
* call (batched graph extraction).
|
|
190
|
+
*
|
|
191
|
+
* Sends all `bodies` as a single prompt with `=== ASSET N ===` separators
|
|
192
|
+
* and expects a JSON array where element `i` corresponds to `bodies[i]`.
|
|
193
|
+
*
|
|
194
|
+
* **Partial-failure handling**: if the model returns fewer elements than
|
|
195
|
+
* `bodies.length`, missing indices are filled by falling back to individual
|
|
196
|
+
* `extractGraphFromBody` calls — ensuring every input always has a result.
|
|
197
|
+
*
|
|
198
|
+
* Returns an array of the same length as `bodies` (never shorter).
|
|
199
|
+
* Individual elements default to `{entities:[], relations:[]}` on failure.
|
|
200
|
+
*
|
|
201
|
+
* Routes through `tryLlmFeature("graph_extraction", ...)` so the feature gate
|
|
202
|
+
* and onFallback hook are honoured uniformly.
|
|
203
|
+
*
|
|
204
|
+
* @param llmConfig - LLM connection configuration.
|
|
205
|
+
* @param bodies - Asset body strings to process in one batch.
|
|
206
|
+
* @param signal - Optional AbortSignal for cancellation.
|
|
207
|
+
* @param akmConfig - Full AKM config (for feature-gate checks).
|
|
208
|
+
* @param onFallback - Optional fallback event sink.
|
|
209
|
+
*/
|
|
210
|
+
export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfig, onFallback) {
|
|
211
|
+
const empty = () => ({ entities: [], relations: [] });
|
|
212
|
+
// Degenerate case: no bodies → empty array (not an error).
|
|
213
|
+
if (bodies.length === 0)
|
|
214
|
+
return [];
|
|
215
|
+
// Single body: delegate to the single-asset path for identical behaviour.
|
|
216
|
+
if (bodies.length === 1) {
|
|
217
|
+
const result = await extractGraphFromBody(llmConfig, bodies[0] ?? "", signal, akmConfig, onFallback);
|
|
218
|
+
return [result];
|
|
219
|
+
}
|
|
220
|
+
// Filter out bodies that are empty so we don't waste tokens, but keep
|
|
221
|
+
// index correspondence by tracking which indices were non-empty.
|
|
222
|
+
const results = bodies.map(empty);
|
|
223
|
+
const nonEmptyIndices = [];
|
|
224
|
+
const nonEmptyBodies = [];
|
|
225
|
+
for (let i = 0; i < bodies.length; i++) {
|
|
226
|
+
const trimmed = (bodies[i] ?? "").trim();
|
|
227
|
+
if (trimmed) {
|
|
228
|
+
nonEmptyIndices.push(i);
|
|
229
|
+
nonEmptyBodies.push(trimmed);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (nonEmptyBodies.length === 0)
|
|
233
|
+
return results;
|
|
234
|
+
const systemPrompt = buildBatchSystemPrompt();
|
|
235
|
+
const userPrompt = buildBatchUserPrompt(nonEmptyBodies);
|
|
236
|
+
const batchResult = await tryLlmFeature("graph_extraction", akmConfig, async () => {
|
|
237
|
+
try {
|
|
238
|
+
const raw = await chatCompletion(llmConfig, [
|
|
239
|
+
{ role: "system", content: systemPrompt },
|
|
240
|
+
{ role: "user", content: userPrompt },
|
|
241
|
+
], {
|
|
242
|
+
temperature: 0.1,
|
|
243
|
+
timeoutMs: llmConfig.timeoutMs,
|
|
244
|
+
signal,
|
|
245
|
+
});
|
|
246
|
+
if (!raw)
|
|
247
|
+
return null;
|
|
248
|
+
const parsed = parseEmbeddedJsonResponse(raw);
|
|
249
|
+
if (!Array.isArray(parsed)) {
|
|
250
|
+
warn("graph extraction (batch): LLM response was not a JSON array; will fall back per-asset.");
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return parsed;
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
warn(`graph extraction (batch) failed: ${toErrorMessage(err)}`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}, null, {
|
|
260
|
+
timeoutMs: llmConfig.timeoutMs,
|
|
261
|
+
onFallback,
|
|
262
|
+
});
|
|
263
|
+
// Map successful batch results back to their original indices.
|
|
264
|
+
if (batchResult !== null) {
|
|
265
|
+
for (let j = 0; j < nonEmptyBodies.length; j++) {
|
|
266
|
+
const originalIndex = nonEmptyIndices[j];
|
|
267
|
+
if (originalIndex === undefined)
|
|
268
|
+
continue;
|
|
269
|
+
if (j < batchResult.length) {
|
|
270
|
+
results[originalIndex] = parseBatchItem(batchResult[j]);
|
|
271
|
+
}
|
|
272
|
+
// j >= batchResult.length → partial failure; handled below.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Partial-failure fallback: any non-empty body whose result is still the
|
|
276
|
+
// empty placeholder (either because batchResult was null or the array was
|
|
277
|
+
// shorter than expected) gets an individual retry.
|
|
278
|
+
const fallbackIndices = nonEmptyIndices.filter((_origIdx, j) => {
|
|
279
|
+
// Result is still empty → needs a fallback call.
|
|
280
|
+
if (batchResult === null)
|
|
281
|
+
return true;
|
|
282
|
+
// batchResult was shorter than the number of non-empty bodies.
|
|
283
|
+
return j >= batchResult.length;
|
|
284
|
+
});
|
|
285
|
+
if (fallbackIndices.length > 0) {
|
|
286
|
+
if (batchResult !== null) {
|
|
287
|
+
// Only warn on partial failure (not when the whole batch failed, which
|
|
288
|
+
// already emitted a warn above).
|
|
289
|
+
warn(`graph extraction (batch): response had ${batchResult.length} items for ${nonEmptyBodies.length} assets; ` +
|
|
290
|
+
`falling back to individual calls for ${fallbackIndices.length} missing asset(s).`);
|
|
291
|
+
}
|
|
292
|
+
await Promise.all(fallbackIndices.map(async (origIdx) => {
|
|
293
|
+
const body = bodies[origIdx] ?? "";
|
|
294
|
+
results[origIdx] = await extractGraphFromBody(llmConfig, body, signal, akmConfig, onFallback);
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
return results;
|
|
298
|
+
}
|
|
43
299
|
/**
|
|
44
300
|
* Extract entities and relations from a single asset body via the configured LLM.
|
|
45
301
|
*
|
|
46
302
|
* Returns `{entities: [], relations: []}` on any failure (timeout, invalid
|
|
47
303
|
* JSON, empty response). Errors are logged via `warn()` but never thrown — a
|
|
48
304
|
* failed extraction for one asset must not abort the rest of the index pass.
|
|
305
|
+
*
|
|
306
|
+
* Routes through `tryLlmFeature("graph_extraction", ...)` so the feature gate
|
|
307
|
+
* and onFallback hook are honoured uniformly (Fix C5).
|
|
49
308
|
*/
|
|
50
|
-
export async function extractGraphFromBody(llmConfig, body, signal) {
|
|
309
|
+
export async function extractGraphFromBody(llmConfig, body, signal, akmConfig, onFallback) {
|
|
51
310
|
const empty = { entities: [], relations: [] };
|
|
52
311
|
const trimmedBody = body.trim();
|
|
53
312
|
if (!trimmedBody)
|
|
54
313
|
return empty;
|
|
55
314
|
const userPrompt = `${USER_PROMPT_PREFIX}${trimmedBody.slice(0, MAX_BODY_CHARS)}`;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
chatCompletion(llmConfig, [
|
|
315
|
+
return tryLlmFeature("graph_extraction", akmConfig, async () => {
|
|
316
|
+
try {
|
|
317
|
+
const raw = await chatCompletion(llmConfig, [
|
|
60
318
|
{ role: "system", content: SYSTEM_PROMPT },
|
|
61
319
|
{ role: "user", content: userPrompt },
|
|
62
|
-
], {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
320
|
+
], { temperature: 0.1, timeoutMs: llmConfig.timeoutMs, signal });
|
|
321
|
+
if (!raw)
|
|
322
|
+
return empty;
|
|
323
|
+
const parsed = parseEmbeddedJsonResponse(raw);
|
|
324
|
+
if (!parsed) {
|
|
325
|
+
warn("graph extraction: invalid JSON response from LLM; skipping asset.");
|
|
326
|
+
return empty;
|
|
327
|
+
}
|
|
328
|
+
return parseGraphExtraction(parsed);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
warn(`graph extraction failed: ${toErrorMessage(err)}`);
|
|
72
332
|
return empty;
|
|
73
333
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.filter((e) => e.length > 0)
|
|
79
|
-
.slice(0, MAX_ENTITIES_PER_ASSET)
|
|
80
|
-
: [];
|
|
81
|
-
const entitySet = new Set(entities);
|
|
82
|
-
const relations = Array.isArray(parsed.relations)
|
|
83
|
-
? parsed.relations
|
|
84
|
-
.filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
|
|
85
|
-
.map((r) => ({
|
|
86
|
-
from: typeof r.from === "string" ? r.from.trim() : "",
|
|
87
|
-
to: typeof r.to === "string" ? r.to.trim() : "",
|
|
88
|
-
type: typeof r.type === "string" && r.type.trim() ? r.type.trim() : undefined,
|
|
89
|
-
}))
|
|
90
|
-
// Both endpoints must be non-empty AND mentioned in entities[];
|
|
91
|
-
// dangling relations are noise and inflate the boost component.
|
|
92
|
-
.filter((r) => r.from && r.to && entitySet.has(r.from) && entitySet.has(r.to))
|
|
93
|
-
.slice(0, MAX_RELATIONS_PER_ASSET)
|
|
94
|
-
: [];
|
|
95
|
-
return { entities, relations };
|
|
96
|
-
}
|
|
97
|
-
catch (err) {
|
|
98
|
-
warn(`graph extraction failed: ${toErrorMessage(err)}`);
|
|
99
|
-
return empty;
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
if (timeoutHandle !== undefined)
|
|
103
|
-
clearTimeout(timeoutHandle);
|
|
104
|
-
}
|
|
334
|
+
}, empty, {
|
|
335
|
+
timeoutMs: llmConfig.timeoutMs,
|
|
336
|
+
onFallback,
|
|
337
|
+
});
|
|
105
338
|
}
|
|
339
|
+
// deduplicateGraph moved to src/indexer/graph-dedup.ts (pure utility, no LLM calls).
|
|
340
|
+
export { deduplicateGraph } from "../indexer/graph-dedup";
|
package/dist/llm/memory-infer.js
CHANGED
|
@@ -18,32 +18,14 @@
|
|
|
18
18
|
import { toErrorMessage } from "../core/common";
|
|
19
19
|
import { warn } from "../core/warn";
|
|
20
20
|
import { chatCompletion, parseEmbeddedJsonResponse } from "./client";
|
|
21
|
+
import { tryLlmFeature } from "./feature-gate";
|
|
21
22
|
/** Hard cap on body chars sent to the model — pragmatic and matches `runLlmEnrich`. */
|
|
22
23
|
const MAX_BODY_CHARS = 4000;
|
|
23
24
|
const SYSTEM_PROMPT = "You compress a developer memory into one high-signal derived memory for later retrieval. " +
|
|
24
25
|
"Return only valid JSON. No prose outside the JSON object. No markdown fences.";
|
|
25
|
-
const USER_PROMPT_PREFIX = `Compress the memory below into one
|
|
26
|
-
|
|
27
|
-
Rules:
|
|
28
|
-
- Output ONLY a JSON object with exactly these keys: {"title": string, "description": string, "tags": string[], "searchHints": string[], "content": string}.
|
|
29
|
-
- ` +
|
|
30
|
-
'"title"' +
|
|
31
|
-
` is a short, descriptive title for the derived memory.
|
|
32
|
-
- ` +
|
|
33
|
-
'"description"' +
|
|
34
|
-
` is one sentence explaining why this derived memory matters.
|
|
35
|
-
- ` +
|
|
36
|
-
'"tags"' +
|
|
37
|
-
` contains 3-8 specific keywords.
|
|
38
|
-
- ` +
|
|
39
|
-
'"searchHints"' +
|
|
40
|
-
` contains 3-6 natural-language retrieval phrases.
|
|
41
|
-
- ` +
|
|
42
|
-
'"content"' +
|
|
43
|
-
` must be compact markdown that preserves the reusable insight, root cause, fix, constraints, and applicability conditions when present.
|
|
44
|
-
- Prefer 2-4 short sections with informative headings over long prose.
|
|
45
|
-
- Omit timestamps, verification-only metrics, pleasantries, and session-specific chatter unless they are essential to applying the insight later.
|
|
46
|
-
- Preserve technical specifics (names, versions, identifiers, selectors, file paths, config keys) verbatim.
|
|
26
|
+
const USER_PROMPT_PREFIX = `Compress the memory below into one derived memory. Output ONLY JSON:
|
|
27
|
+
{"title":"string","description":"string","tags":["string"],"searchHints":["string"],"content":"string"}
|
|
28
|
+
Rules: be specific, no vague generalizations, preserve key facts (names/versions/paths/config keys verbatim), merge related points, max 3 sentences body, 3-8 tags, 3-6 searchHints.
|
|
47
29
|
|
|
48
30
|
Memory:
|
|
49
31
|
`;
|
|
@@ -51,67 +33,63 @@ Memory:
|
|
|
51
33
|
* Compress a single memory body into one derived memory via the configured LLM.
|
|
52
34
|
*
|
|
53
35
|
* Returns `undefined` on any failure (timeout, invalid JSON, empty response).
|
|
54
|
-
* Errors
|
|
55
|
-
* are logged via `warn()` but never thrown — a failed split for one memory
|
|
36
|
+
* Errors are logged via `warn()` but never thrown — a failed split for one memory
|
|
56
37
|
* must not abort the rest of the index pass.
|
|
38
|
+
*
|
|
39
|
+
* Routes through `tryLlmFeature("memory_inference", ...)` so the feature gate
|
|
40
|
+
* and onFallback hook are honoured uniformly (Fix C5).
|
|
57
41
|
*/
|
|
58
|
-
export async function compressMemoryToDerivedMemory(llmConfig, body, signal) {
|
|
42
|
+
export async function compressMemoryToDerivedMemory(llmConfig, body, signal, akmConfig, onFallback) {
|
|
59
43
|
const trimmedBody = body.trim();
|
|
60
44
|
if (!trimmedBody)
|
|
61
45
|
return undefined;
|
|
62
46
|
const userPrompt = `${USER_PROMPT_PREFIX}${trimmedBody.slice(0, MAX_BODY_CHARS)}`;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
chatCompletion(llmConfig, [
|
|
47
|
+
return tryLlmFeature("memory_inference", akmConfig, async () => {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await chatCompletion(llmConfig, [
|
|
67
50
|
{ role: "system", content: SYSTEM_PROMPT },
|
|
68
51
|
{ role: "user", content: userPrompt },
|
|
69
52
|
], {
|
|
70
|
-
maxTokens: llmConfig.maxTokens ?? 4096,
|
|
71
53
|
temperature: 0.1,
|
|
72
|
-
timeoutMs: llmConfig.timeoutMs
|
|
54
|
+
timeoutMs: llmConfig.timeoutMs,
|
|
73
55
|
signal,
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
56
|
+
});
|
|
57
|
+
if (!raw)
|
|
58
|
+
return undefined;
|
|
59
|
+
const parsed = parseEmbeddedJsonResponse(raw);
|
|
60
|
+
if (!parsed) {
|
|
61
|
+
warn("memory inference: invalid JSON response from LLM; skipping memory.");
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
|
|
65
|
+
const description = typeof parsed.description === "string" ? parsed.description.trim() : "";
|
|
66
|
+
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
67
|
+
const tags = Array.isArray(parsed.tags)
|
|
68
|
+
? parsed.tags
|
|
69
|
+
.filter((t) => typeof t === "string")
|
|
70
|
+
.map((t) => t.trim())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.slice(0, 8)
|
|
73
|
+
: [];
|
|
74
|
+
const searchHints = Array.isArray(parsed.searchHints)
|
|
75
|
+
? parsed.searchHints
|
|
76
|
+
.filter((h) => typeof h === "string")
|
|
77
|
+
.map((h) => h.trim())
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.slice(0, 6)
|
|
80
|
+
: [];
|
|
81
|
+
if (!title || !description || !content || tags.length === 0 || searchHints.length === 0) {
|
|
82
|
+
warn("memory inference: incomplete derived memory payload from LLM; skipping memory.");
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return { title, description, tags, searchHints, content };
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
89
|
-
const tags = Array.isArray(parsed.tags)
|
|
90
|
-
? parsed.tags
|
|
91
|
-
.filter((t) => typeof t === "string")
|
|
92
|
-
.map((t) => t.trim())
|
|
93
|
-
.filter(Boolean)
|
|
94
|
-
.slice(0, 8)
|
|
95
|
-
: [];
|
|
96
|
-
const searchHints = Array.isArray(parsed.searchHints)
|
|
97
|
-
? parsed.searchHints
|
|
98
|
-
.filter((h) => typeof h === "string")
|
|
99
|
-
.map((h) => h.trim())
|
|
100
|
-
.filter(Boolean)
|
|
101
|
-
.slice(0, 6)
|
|
102
|
-
: [];
|
|
103
|
-
if (!title || !description || !content || tags.length === 0 || searchHints.length === 0) {
|
|
104
|
-
warn("memory inference: incomplete derived memory payload from LLM; skipping memory.");
|
|
87
|
+
catch (err) {
|
|
88
|
+
warn(`memory inference failed: ${toErrorMessage(err)}`);
|
|
105
89
|
return undefined;
|
|
106
90
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return undefined;
|
|
112
|
-
}
|
|
113
|
-
finally {
|
|
114
|
-
if (timeoutHandle !== undefined)
|
|
115
|
-
clearTimeout(timeoutHandle);
|
|
116
|
-
}
|
|
91
|
+
}, undefined, {
|
|
92
|
+
timeoutMs: llmConfig.timeoutMs,
|
|
93
|
+
onFallback,
|
|
94
|
+
});
|
|
117
95
|
}
|
|
@@ -6,20 +6,27 @@
|
|
|
6
6
|
* transport client in `client.ts`.
|
|
7
7
|
*/
|
|
8
8
|
import { chatCompletion, parseJsonResponse } from "./client";
|
|
9
|
+
import { tryLlmFeature } from "./feature-gate";
|
|
9
10
|
const SYSTEM_PROMPT = `You are a metadata generator for a developer asset registry. Given a script/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`;
|
|
10
11
|
/**
|
|
11
12
|
* Use an LLM to enhance a stash entry's metadata: improve description,
|
|
12
13
|
* generate searchHints, and suggest tags.
|
|
14
|
+
*
|
|
15
|
+
* When `akmConfig` is provided, routes through
|
|
16
|
+
* `tryLlmFeature("metadata_enhance", ...)` so the feature gate is honoured and
|
|
17
|
+
* errors are swallowed to `{}`. When `akmConfig` is `undefined` the gate is
|
|
18
|
+
* bypassed entirely — the LLM call runs unconditionally and errors propagate to
|
|
19
|
+
* the caller (pre-gate behaviour, used by direct callers such as tests).
|
|
13
20
|
*/
|
|
14
|
-
export async function enhanceMetadata(config, entry, fileContent, signal) {
|
|
21
|
+
export async function enhanceMetadata(config, entry, fileContent, signal, akmConfig) {
|
|
15
22
|
const contextParts = [`Name: ${entry.name}`, `Type: ${entry.type}`];
|
|
16
23
|
if (entry.description)
|
|
17
24
|
contextParts.push(`Current description: ${entry.description}`);
|
|
18
25
|
if (entry.tags?.length)
|
|
19
26
|
contextParts.push(`Current tags: ${entry.tags.join(", ")}`);
|
|
20
27
|
if (fileContent) {
|
|
21
|
-
// Limit content to first
|
|
22
|
-
const truncated = fileContent.length >
|
|
28
|
+
// Limit content to first 4000 chars to stay within token limits (matches other modules)
|
|
29
|
+
const truncated = fileContent.length > 4000 ? `${fileContent.slice(0, 4000)}\n... (truncated)` : fileContent;
|
|
23
30
|
contextParts.push(`File content:\n${truncated}`);
|
|
24
31
|
}
|
|
25
32
|
const userPrompt = `${contextParts.join("\n")}
|
|
@@ -30,24 +37,34 @@ Generate improved metadata for this ${entry.type}. Return JSON with these fields
|
|
|
30
37
|
- "tags": an array of 3-8 relevant keyword tags
|
|
31
38
|
|
|
32
39
|
Return ONLY the JSON object, no explanation.`;
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
const runLlm = async () => {
|
|
41
|
+
const raw = await chatCompletion(config, [
|
|
42
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
43
|
+
{ role: "user", content: userPrompt },
|
|
44
|
+
], { signal });
|
|
45
|
+
const parsed = parseJsonResponse(raw);
|
|
46
|
+
if (!parsed)
|
|
47
|
+
return {};
|
|
48
|
+
const result = {};
|
|
49
|
+
if (typeof parsed.description === "string" && parsed.description) {
|
|
50
|
+
result.description = parsed.description;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(parsed.searchHints)) {
|
|
53
|
+
result.searchHints = parsed.searchHints
|
|
54
|
+
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
55
|
+
.slice(0, 8);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(parsed.tags)) {
|
|
58
|
+
result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
// When no akmConfig is provided, bypass the feature gate entirely: run the
|
|
63
|
+
// LLM call directly and let errors propagate to the caller (pre-gate
|
|
64
|
+
// behaviour). When akmConfig is present, honour the feature flag and swallow
|
|
65
|
+
// errors to {} via tryLlmFeature.
|
|
66
|
+
if (akmConfig === undefined) {
|
|
67
|
+
return runLlm();
|
|
51
68
|
}
|
|
52
|
-
return
|
|
69
|
+
return tryLlmFeature("metadata_enhance", akmConfig, runLlm, {}, { timeoutMs: config.timeoutMs });
|
|
53
70
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Extract entities and relations from the asset body below.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Output ONLY a JSON object: {"entities": ["Entity One", ...], "relations": [{"from": "A", "to": "B", "type": "uses"}, ...]}.
|
|
5
|
+
- Entities are short, canonical noun phrases (project names, services, tools, people, file/dir names, technical concepts).
|
|
6
|
+
- Relations connect two entities that both appear in the entities array.
|
|
7
|
+
- "type" is a short verb phrase (e.g. "uses", "depends on", "owns", "documents"). Optional; omit when unsure.
|
|
8
|
+
- Drop pleasantries, meta-commentary, and timestamps.
|
|
9
|
+
- Limit to at most {{MAX_ENTITIES}} entities and {{MAX_RELATIONS}} relations per asset.
|
|
10
|
+
- Return {"entities": [], "relations": []} if the body has no extractable graph content.
|
|
11
|
+
|
|
12
|
+
Asset body:
|