akm-cli 0.7.4 → 0.8.0-rc.10

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.
Files changed (300) hide show
  1. package/CHANGELOG.md +224 -1
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2631 -1440
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  16. package/dist/commands/curate.js +45 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +660 -0
  19. package/dist/commands/distill.js +1081 -73
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +15 -24
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +3 -0
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +120 -45
  62. package/dist/commands/reflect.js +1104 -60
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +70 -7
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +158 -60
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -968
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +105 -135
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +198 -489
  113. package/dist/indexer/db.js +990 -108
  114. package/dist/indexer/ensure-index.js +136 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -114
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +547 -309
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +250 -36
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +183 -35
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +79 -88
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -72
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +80 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -311
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +306 -258
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -511
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1093
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +179 -20
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +141 -2
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +91 -89
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +79 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.4.md +1 -1
  294. package/docs/migration/release-notes/0.7.5.md +20 -0
  295. package/docs/migration/release-notes/0.8.0.md +48 -0
  296. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  297. package/package.json +29 -11
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -333
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * LLM helper for the `akm index` graph-extraction pass (#207).
3
6
  *
@@ -5,8 +8,8 @@
5
8
  * asks the configured LLM to surface the entities mentioned in it and the
6
9
  * relations between them. The pass itself
7
10
  * (`src/indexer/graph-extraction.ts`) is responsible for deciding which
8
- * files to extract, persisting the resulting nodes/edges to a stash-local
9
- * `graph.json` artifact, and feeding the artifact into the FTS5+boosts
11
+ * files to extract, persisting the resulting nodes/edges to the index DB,
12
+ * and feeding the graph data into the FTS5+boosts
10
13
  * search pipeline as a single boost component.
11
14
  *
12
15
  * This module is intentionally tiny and stateless so tests can stub it via
@@ -18,90 +21,691 @@
18
21
  * straight through.
19
22
  */
20
23
  import { toErrorMessage } from "../core/common";
21
- import { warn } from "../core/warn";
24
+ import { warn, warnVerbose } from "../core/warn";
22
25
  import { chatCompletion, parseEmbeddedJsonResponse } from "./client";
23
- /** Hard cap on body chars sent to the model. */
24
- const MAX_BODY_CHARS = 4000;
26
+ import { tryLlmFeature } from "./feature-gate";
27
+ import userPromptTemplate from "./prompts/graph-extract-user-prompt.md" with { type: "text" };
28
+ /**
29
+ * Separator token used between assets in a batch prompt.
30
+ * Chosen to be visually clear and unlikely to appear verbatim in asset bodies.
31
+ */
32
+ const BATCH_ASSET_SEPARATOR = "=== ASSET";
33
+ export const GRAPH_EXTRACT_PROMPT_VERSION = "v2";
34
+ /** Asset bodies longer than this are chunked instead of truncated. */
35
+ const MAX_CHUNK_BODY_CHARS = 1600;
36
+ /** Bodies longer than this are excluded from multi-asset batch prompts. */
37
+ const MAX_BATCH_BODY_CHARS = 1600;
38
+ const MIN_RELATION_CONFIDENCE = 0.5;
39
+ const NON_ARRAY_BATCH_DISABLE_THRESHOLD = 2;
25
40
  /** Hard cap on entities returned per asset — guards against runaway LLM output. */
26
41
  const MAX_ENTITIES_PER_ASSET = 32;
27
42
  /** Hard cap on relations returned per asset. */
28
43
  const MAX_RELATIONS_PER_ASSET = 32;
29
- /** Hard timeout for the LLM call; an `akm index` run must not hang on a misbehaving endpoint. */
30
- const LLM_TIMEOUT_MS = 30_000;
31
- const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return only valid JSON. " + "No prose, no markdown fences.";
32
- const USER_PROMPT_PREFIX = `Extract entities and relations from the asset body below.
33
-
34
- Rules:
35
- - Output ONLY a JSON object: {"entities": ["Entity One", ...], "relations": [{"from": "A", "to": "B", "type": "uses"}, ...]}.
36
- - Entities are short, canonical noun phrases (project names, services, tools, people, file/dir names, technical concepts).
37
- - Relations connect two entities that both appear in the entities array.
38
- - "type" is a short verb phrase (e.g. "uses", "depends on", "owns", "documents"). Optional; omit when unsure.
39
- - Drop pleasantries, meta-commentary, and timestamps.
40
- - Limit to at most ${MAX_ENTITIES_PER_ASSET} entities and ${MAX_RELATIONS_PER_ASSET} relations per asset.
41
- - Return {"entities": [], "relations": []} if the body has no extractable graph content.
42
-
43
- Asset body:
44
- `;
44
+ const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return ONLY valid JSON no prose, no markdown fences, no preamble.";
45
+ const USER_PROMPT_PREFIX = userPromptTemplate
46
+ .replace("{{MAX_ENTITIES}}", String(MAX_ENTITIES_PER_ASSET))
47
+ .replace("{{MAX_RELATIONS}}", String(MAX_RELATIONS_PER_ASSET));
48
+ /**
49
+ * Detect whether an error message indicates a context size exceeded condition.
50
+ * Covers common patterns from OpenAI-compatible APIs (LM Studio, Ollama, etc).
51
+ */
52
+ function isContextSizeError(message) {
53
+ const lower = message.toLowerCase();
54
+ return (lower.includes("context size") ||
55
+ lower.includes("context length") ||
56
+ lower.includes("context_window") ||
57
+ lower.includes("prompt too long") ||
58
+ (lower.includes("exceeds") && lower.includes("context")));
59
+ }
60
+ const GENERIC_ENTITIES = new Set([
61
+ "agent",
62
+ "application",
63
+ "assistant",
64
+ "code",
65
+ "content",
66
+ "data",
67
+ "developer",
68
+ "document",
69
+ "file",
70
+ "knowledge",
71
+ "memory",
72
+ "note",
73
+ "notes",
74
+ "project",
75
+ "service",
76
+ "system",
77
+ "task",
78
+ "team",
79
+ "text",
80
+ "thing",
81
+ "user",
82
+ ]);
83
+ const GENERIC_RELATION_TYPES = new Set(["has", "is", "mentions", "references", "related to"]);
84
+ function parseConfidence(raw) {
85
+ if (typeof raw !== "number" || !Number.isFinite(raw))
86
+ return undefined;
87
+ return Math.max(0, Math.min(1, raw));
88
+ }
89
+ function normalizeEntityName(raw) {
90
+ return raw
91
+ .trim()
92
+ .replace(/^[`"']+|[`"']+$/g, "")
93
+ .replace(/\s+/g, " ")
94
+ .replace(/[;,!?]+$/g, "")
95
+ .trim();
96
+ }
97
+ function normalizeRelationType(raw) {
98
+ const normalized = raw
99
+ .trim()
100
+ .toLowerCase()
101
+ .replace(/^[`"']+|[`"']+$/g, "")
102
+ .replace(/\s+/g, " ")
103
+ .replace(/[.;,!?]+$/g, "")
104
+ .trim();
105
+ if (!normalized)
106
+ return undefined;
107
+ if (normalized === "use" || normalized === "utilizes")
108
+ return "uses";
109
+ if (normalized === "depend on" || normalized === "depends")
110
+ return "depends on";
111
+ if (normalized === "integrates" || normalized === "integration with")
112
+ return "integrates with";
113
+ return normalized;
114
+ }
115
+ function normalizeEntityKey(raw) {
116
+ return normalizeEntityName(raw).toLowerCase();
117
+ }
118
+ function bumpTelemetry(telemetry, key, amount = 1) {
119
+ if (!telemetry)
120
+ return;
121
+ telemetry[key] = (telemetry[key] ?? 0) + amount;
122
+ }
123
+ function normalizeBatchState(state) {
124
+ if (!state)
125
+ return undefined;
126
+ state.batchingDisabled = state.batchingDisabled === true;
127
+ state.nonArrayBatchFailures = Math.max(0, state.nonArrayBatchFailures ?? 0);
128
+ return state;
129
+ }
130
+ function splitParagraph(text, maxChars) {
131
+ if (text.length <= maxChars)
132
+ return { chunks: [text], truncationCount: 0 };
133
+ const chunks = [];
134
+ let truncationCount = 0;
135
+ let remaining = text;
136
+ while (remaining.length > maxChars) {
137
+ let splitAt = remaining.lastIndexOf(" ", maxChars);
138
+ if (splitAt < Math.floor(maxChars * 0.6))
139
+ splitAt = maxChars;
140
+ const piece = remaining.slice(0, splitAt).trim();
141
+ if (piece)
142
+ chunks.push(piece);
143
+ remaining = remaining.slice(splitAt).trim();
144
+ truncationCount += 1;
145
+ }
146
+ if (remaining)
147
+ chunks.push(remaining);
148
+ return { chunks, truncationCount };
149
+ }
150
+ function splitBodyIntoChunks(body, maxChars = MAX_CHUNK_BODY_CHARS) {
151
+ const sections = body
152
+ .split(/\n(?=#{1,6}\s)/)
153
+ .map((section) => section.trim())
154
+ .filter(Boolean);
155
+ if (sections.length === 0)
156
+ return { chunks: [body.trim()].filter(Boolean), truncationCount: 0 };
157
+ const chunks = [];
158
+ let current = "";
159
+ let truncationCount = 0;
160
+ const flush = () => {
161
+ const trimmed = current.trim();
162
+ if (trimmed)
163
+ chunks.push(trimmed);
164
+ current = "";
165
+ };
166
+ for (const section of sections) {
167
+ if (section.length <= maxChars) {
168
+ const candidate = current ? `${current}\n\n${section}` : section;
169
+ if (candidate.length <= maxChars)
170
+ current = candidate;
171
+ else {
172
+ flush();
173
+ current = section;
174
+ }
175
+ continue;
176
+ }
177
+ const paragraphs = section
178
+ .split(/\n\s*\n/)
179
+ .map((part) => part.trim())
180
+ .filter(Boolean);
181
+ for (const paragraph of paragraphs) {
182
+ if (paragraph.length <= maxChars) {
183
+ const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
184
+ if (candidate.length <= maxChars)
185
+ current = candidate;
186
+ else {
187
+ flush();
188
+ current = paragraph;
189
+ }
190
+ continue;
191
+ }
192
+ flush();
193
+ const split = splitParagraph(paragraph, maxChars);
194
+ truncationCount += split.truncationCount;
195
+ for (const piece of split.chunks) {
196
+ if (piece.length <= maxChars)
197
+ chunks.push(piece);
198
+ }
199
+ }
200
+ }
201
+ flush();
202
+ return { chunks, truncationCount };
203
+ }
204
+ /** Consistency weight for blending chunk-agreement with LLM confidence. */
205
+ const CONSISTENCY_WEIGHT = 0.4;
206
+ function mergeGraphExtractions(extractions) {
207
+ const totalChunks = extractions.length;
208
+ const entityCanonical = new Map();
209
+ const entityChunkCounts = new Map();
210
+ const relationByKey = new Map();
211
+ const relationChunkCounts = new Map();
212
+ let confidence;
213
+ let truncationCount = 0;
214
+ let filteredGenericEntities = 0;
215
+ let filteredInvalidRelations = 0;
216
+ let filteredLowConfidenceRelations = 0;
217
+ let firstFailureReason;
218
+ for (const extraction of extractions) {
219
+ truncationCount += extraction.truncationCount ?? 0;
220
+ filteredGenericEntities += extraction.filteredGenericEntities ?? 0;
221
+ filteredInvalidRelations += extraction.filteredInvalidRelations ?? 0;
222
+ filteredLowConfidenceRelations += extraction.filteredLowConfidenceRelations ?? 0;
223
+ if (extraction.status === "failed" && !firstFailureReason)
224
+ firstFailureReason = extraction.reason;
225
+ const nextConfidence = parseConfidence(extraction.confidence);
226
+ if (nextConfidence !== undefined)
227
+ confidence = confidence === undefined ? nextConfidence : Math.max(confidence, nextConfidence);
228
+ for (const entity of extraction.entities) {
229
+ const key = normalizeEntityKey(entity);
230
+ if (!key)
231
+ continue;
232
+ if (!entityCanonical.has(key))
233
+ entityCanonical.set(key, entity);
234
+ entityChunkCounts.set(key, (entityChunkCounts.get(key) ?? 0) + 1);
235
+ }
236
+ }
237
+ for (const extraction of extractions) {
238
+ for (const relation of extraction.relations) {
239
+ const fromKey = normalizeEntityKey(relation.from);
240
+ const toKey = normalizeEntityKey(relation.to);
241
+ const type = normalizeRelationType(relation.type ?? "");
242
+ if (!fromKey || !toKey || !type)
243
+ continue;
244
+ const from = entityCanonical.get(fromKey);
245
+ const to = entityCanonical.get(toKey);
246
+ if (!from || !to)
247
+ continue;
248
+ const key = `${fromKey}\u0000${toKey}\u0000${type}`;
249
+ if (!relationByKey.has(key)) {
250
+ relationByKey.set(key, {
251
+ from,
252
+ to,
253
+ type,
254
+ });
255
+ relationChunkCounts.set(key, 0);
256
+ }
257
+ relationChunkCounts.set(key, (relationChunkCounts.get(key) ?? 0) + 1);
258
+ const nextConfidence = parseConfidence(relation.confidence);
259
+ const existing = relationByKey.get(key);
260
+ if (existing && nextConfidence !== undefined) {
261
+ const current = parseConfidence(existing.confidence) ?? 0;
262
+ if (nextConfidence > current)
263
+ existing.confidence = nextConfidence;
264
+ }
265
+ }
266
+ }
267
+ function blendConsistency(llmConfidence, chunkCount) {
268
+ const consistency = totalChunks > 1 ? chunkCount / totalChunks : 1;
269
+ if (llmConfidence === undefined)
270
+ return consistency;
271
+ return (1 - CONSISTENCY_WEIGHT) * llmConfidence + CONSISTENCY_WEIGHT * consistency;
272
+ }
273
+ const entities = [...entityCanonical.values()].slice(0, MAX_ENTITIES_PER_ASSET);
274
+ const relations = [...relationByKey.values()].slice(0, MAX_RELATIONS_PER_ASSET);
275
+ for (const relation of relations) {
276
+ const fromKey = normalizeEntityKey(relation.from);
277
+ const toKey = normalizeEntityKey(relation.to);
278
+ const type = normalizeRelationType(relation.type ?? "");
279
+ if (!fromKey || !toKey || !type)
280
+ continue;
281
+ const key = `${fromKey}\u0000${toKey}\u0000${type}`;
282
+ const chunkCount = relationChunkCounts.get(key) ?? 1;
283
+ relation.confidence = blendConsistency(relation.confidence, chunkCount);
284
+ }
285
+ const status = entities.length > 0 ? "extracted" : firstFailureReason ? "failed" : "empty";
286
+ const reason = status === "extracted" ? "none" : (firstFailureReason ?? "no_graph_content");
287
+ const mergedConfidence = confidence !== undefined ? blendConsistency(confidence, totalChunks) : totalChunks > 1 ? 1 : undefined;
288
+ return {
289
+ entities,
290
+ relations,
291
+ ...(mergedConfidence !== undefined ? { confidence: mergedConfidence } : {}),
292
+ status,
293
+ reason,
294
+ chunkCount: extractions.length,
295
+ truncationCount,
296
+ filteredGenericEntities,
297
+ filteredInvalidRelations,
298
+ filteredLowConfidenceRelations,
299
+ };
300
+ }
301
+ function parseGraphExtraction(raw) {
302
+ const empty = (reason = "no_graph_content") => ({
303
+ entities: [],
304
+ relations: [],
305
+ status: reason === "llm_error" || reason === "invalid_json" || reason === "context_limit" ? "failed" : "empty",
306
+ reason,
307
+ });
308
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
309
+ return empty();
310
+ const item = raw;
311
+ const extractionConfidence = parseConfidence(item.confidence);
312
+ const entityCanonical = new Map();
313
+ let filteredGenericEntities = 0;
314
+ if (Array.isArray(item.entities)) {
315
+ for (const value of item.entities) {
316
+ if (typeof value !== "string")
317
+ continue;
318
+ const normalized = normalizeEntityName(value);
319
+ if (!normalized)
320
+ continue;
321
+ const normalizedKey = normalized.toLowerCase();
322
+ if (!/[a-z0-9]/i.test(normalized) || GENERIC_ENTITIES.has(normalizedKey)) {
323
+ filteredGenericEntities += 1;
324
+ continue;
325
+ }
326
+ const key = normalized.toLowerCase();
327
+ if (!entityCanonical.has(key))
328
+ entityCanonical.set(key, normalized);
329
+ if (entityCanonical.size >= MAX_ENTITIES_PER_ASSET)
330
+ break;
331
+ }
332
+ }
333
+ const entities = Array.from(entityCanonical.values());
334
+ const relations = [];
335
+ let filteredInvalidRelations = 0;
336
+ let filteredLowConfidenceRelations = 0;
337
+ if (Array.isArray(item.relations)) {
338
+ for (const relation of item.relations) {
339
+ if (typeof relation !== "object" || relation === null || Array.isArray(relation)) {
340
+ filteredInvalidRelations += 1;
341
+ continue;
342
+ }
343
+ const rel = relation;
344
+ const fromRaw = typeof rel.from === "string" ? normalizeEntityName(rel.from) : "";
345
+ const toRaw = typeof rel.to === "string" ? normalizeEntityName(rel.to) : "";
346
+ if (!fromRaw || !toRaw) {
347
+ filteredInvalidRelations += 1;
348
+ continue;
349
+ }
350
+ const from = entityCanonical.get(fromRaw.toLowerCase());
351
+ const to = entityCanonical.get(toRaw.toLowerCase());
352
+ if (!from || !to || from.toLowerCase() === to.toLowerCase()) {
353
+ filteredInvalidRelations += 1;
354
+ continue;
355
+ }
356
+ const type = typeof rel.type === "string" ? normalizeRelationType(rel.type) : undefined;
357
+ if (type !== undefined && GENERIC_RELATION_TYPES.has(type)) {
358
+ filteredInvalidRelations += 1;
359
+ continue;
360
+ }
361
+ const confidence = parseConfidence(rel.confidence);
362
+ if (confidence !== undefined && confidence < MIN_RELATION_CONFIDENCE) {
363
+ filteredLowConfidenceRelations += 1;
364
+ continue;
365
+ }
366
+ relations.push({
367
+ from,
368
+ to,
369
+ ...(type ? { type } : {}),
370
+ ...(confidence !== undefined ? { confidence } : {}),
371
+ });
372
+ if (relations.length >= MAX_RELATIONS_PER_ASSET)
373
+ break;
374
+ }
375
+ }
376
+ const confidence = extractionConfidence;
377
+ const status = entities.length > 0 ? "extracted" : "empty";
378
+ const reason = entities.length > 0 ? "none" : filteredGenericEntities > 0 ? "generic_entities_only" : "no_graph_content";
379
+ return {
380
+ entities,
381
+ relations,
382
+ status,
383
+ reason,
384
+ filteredGenericEntities,
385
+ filteredInvalidRelations,
386
+ filteredLowConfidenceRelations,
387
+ ...(confidence !== undefined ? { confidence } : {}),
388
+ };
389
+ }
390
+ /**
391
+ * Build the system prompt for a batched graph-extraction call.
392
+ *
393
+ * The prompt instructs the model to return a JSON array of exactly `count`
394
+ * objects, one per asset, in input order. Index alignment is the critical
395
+ * invariant — if the model drops an asset it still must emit an empty
396
+ * placeholder `{"entities":[],"relations":[]}` at that position.
397
+ *
398
+ * Worked example (3 assets, abbreviated):
399
+ *
400
+ * Input user message:
401
+ * Extract entities and relations from the N=3 assets below.
402
+ * ...rules...
403
+ * === ASSET 1 ===
404
+ * ServiceA integrates with ServiceB.
405
+ * === ASSET 2 ===
406
+ * Terraform provisions the Prod cluster.
407
+ * === ASSET 3 ===
408
+ * No extractable graph content here.
409
+ *
410
+ * Expected model output (valid JSON array, no prose):
411
+ * [
412
+ * {"entities":["ServiceA","ServiceB"],"relations":[{"from":"ServiceA","to":"ServiceB","type":"integrates with"}]},
413
+ * {"entities":["Terraform","Prod cluster"],"relations":[{"from":"Terraform","to":"Prod cluster","type":"provisions"}]},
414
+ * {"entities":[],"relations":[]}
415
+ * ]
416
+ *
417
+ * If the model returns fewer than 3 items (partial failure), the caller
418
+ * (`extractGraphFromBodies`) falls back to individual calls for missing indices.
419
+ */
420
+ function buildBatchSystemPrompt() {
421
+ return ("You extract knowledge graphs from developer notes. " +
422
+ "Return ONLY a valid JSON array — no prose, no markdown fences, no preamble. " +
423
+ "Each element of the array corresponds to one input asset, in order. " +
424
+ "The array length MUST equal the number of assets provided. " +
425
+ 'Use {"entities":[],"relations":[]} for assets with no extractable graph content.');
426
+ }
427
+ function buildBatchUserPrompt(bodies) {
428
+ const count = bodies.length;
429
+ const assetBlocks = bodies.map((body, i) => `${BATCH_ASSET_SEPARATOR} ${i + 1} ===\n${body.trim()}`).join("\n\n");
430
+ return (`Extract entities and relations from the N=${count} assets below.\n\n` +
431
+ `Rules:\n` +
432
+ `- Output ONLY a JSON array of exactly ${count} objects, one per asset, preserving input order.\n` +
433
+ `- Each object: {"entities": ["Entity One", ...], "relations": [{"from": "A", "to": "B", "type": "uses"}, ...]}\n` +
434
+ `- Entities are short, canonical noun phrases (project names, services, tools, people, file/dir names, technical concepts).\n` +
435
+ `- Relations connect two entities that both appear in that asset's entities array.\n` +
436
+ `- "type" is a short verb phrase (e.g. "uses", "depends on", "owns"). Optional; omit when unsure.\n` +
437
+ `- Drop pleasantries, meta-commentary, and timestamps.\n` +
438
+ `- Limit to at most ${MAX_ENTITIES_PER_ASSET} entities and ${MAX_RELATIONS_PER_ASSET} relations per asset.\n` +
439
+ `- Use {"entities":[],"relations":[]} for assets with no extractable graph content.\n` +
440
+ `- The array MUST have exactly ${count} elements — one placeholder per asset even if empty.\n\n` +
441
+ assetBlocks);
442
+ }
443
+ function formatContextHint(llmConfig) {
444
+ return llmConfig.contextLength ? `, configured contextLength=${llmConfig.contextLength}` : "";
445
+ }
446
+ /**
447
+ * Parse and validate a single item from the batch response array.
448
+ * Mirrors the validation logic in `extractGraphFromBody`.
449
+ */
450
+ function parseBatchItem(raw) {
451
+ return parseGraphExtraction(raw);
452
+ }
453
+ /**
454
+ * Extract entities and relations from multiple asset bodies in a single LLM
455
+ * call (batched graph extraction).
456
+ *
457
+ * Sends all `bodies` as a single prompt with `=== ASSET N ===` separators
458
+ * and expects a JSON array where element `i` corresponds to `bodies[i]`.
459
+ *
460
+ * **Partial-failure handling**: if the model returns fewer elements than
461
+ * `bodies.length`, missing indices are filled by falling back to individual
462
+ * `extractGraphFromBody` calls — ensuring every input always has a result.
463
+ *
464
+ * Returns an array of the same length as `bodies` (never shorter).
465
+ * Individual elements default to `{entities:[], relations:[]}` on failure.
466
+ *
467
+ * Routes through `tryLlmFeature("graph_extraction", ...)` so the feature gate
468
+ * and onFallback hook are honoured uniformly.
469
+ *
470
+ * @param llmConfig - LLM connection configuration.
471
+ * @param bodies - Asset body strings to process in one batch.
472
+ * @param signal - Optional AbortSignal for cancellation.
473
+ * @param akmConfig - Full AKM config (for feature-gate checks).
474
+ * @param onFallback - Optional fallback event sink.
475
+ */
476
+ export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfig, onFallback, options = {}) {
477
+ const empty = () => ({ entities: [], relations: [] });
478
+ const batchState = normalizeBatchState(options.batchState);
479
+ // Degenerate case: no bodies → empty array (not an error).
480
+ if (bodies.length === 0)
481
+ return [];
482
+ // Single body: delegate to the single-asset path for identical behaviour.
483
+ if (bodies.length === 1) {
484
+ const result = await extractGraphFromBody(llmConfig, bodies[0] ?? "", signal, akmConfig, onFallback, options);
485
+ return [result];
486
+ }
487
+ // Filter out bodies that are empty so we don't waste tokens, but keep
488
+ // index correspondence by tracking which indices were non-empty.
489
+ const results = bodies.map(empty);
490
+ const nonEmptyIndices = [];
491
+ const nonEmptyBodies = [];
492
+ const oversizedIndices = [];
493
+ for (let i = 0; i < bodies.length; i++) {
494
+ const trimmed = (bodies[i] ?? "").trim();
495
+ if (trimmed) {
496
+ if (trimmed.length > MAX_BATCH_BODY_CHARS) {
497
+ oversizedIndices.push(i);
498
+ }
499
+ else {
500
+ nonEmptyIndices.push(i);
501
+ nonEmptyBodies.push(trimmed);
502
+ }
503
+ }
504
+ }
505
+ if (oversizedIndices.length > 0) {
506
+ await Promise.all(oversizedIndices.map(async (index) => {
507
+ results[index] = await extractGraphFromBody(llmConfig, bodies[index] ?? "", signal, akmConfig, onFallback, options);
508
+ }));
509
+ }
510
+ if (nonEmptyBodies.length === 0)
511
+ return results;
512
+ if (batchState?.batchingDisabled) {
513
+ return Promise.all(bodies.map((body) => extractGraphFromBody(llmConfig, body, signal, akmConfig, onFallback, options)));
514
+ }
515
+ const systemPrompt = buildBatchSystemPrompt();
516
+ const userPrompt = buildBatchUserPrompt(nonEmptyBodies);
517
+ const truncatedBodies = nonEmptyBodies.filter((body) => body.length > MAX_BATCH_BODY_CHARS).length;
518
+ if (truncatedBodies > 0) {
519
+ warnVerbose(`graph extraction (batch): ${truncatedBodies}/${nonEmptyBodies.length} asset body/bodies exceed the batch body threshold of ${MAX_BATCH_BODY_CHARS} chars.`);
520
+ }
521
+ let batchContextError = false;
522
+ let nonArrayResponse = false;
523
+ const batchResult = await tryLlmFeature("graph_extraction", akmConfig, async () => {
524
+ try {
525
+ const raw = await chatCompletion(llmConfig, [
526
+ { role: "system", content: systemPrompt },
527
+ { role: "user", content: userPrompt },
528
+ ], {
529
+ temperature: 0.1,
530
+ timeoutMs: llmConfig.timeoutMs,
531
+ signal,
532
+ });
533
+ if (!raw)
534
+ return null;
535
+ const parsed = parseEmbeddedJsonResponse(raw);
536
+ if (!Array.isArray(parsed)) {
537
+ nonArrayResponse = true;
538
+ bumpTelemetry(options.telemetry, "nonArrayBatchFailures");
539
+ if (batchState) {
540
+ batchState.nonArrayBatchFailures += 1;
541
+ if (batchState.nonArrayBatchFailures >= NON_ARRAY_BATCH_DISABLE_THRESHOLD) {
542
+ batchState.batchingDisabled = true;
543
+ }
544
+ }
545
+ warn(`graph extraction (batch): LLM response was not a JSON array for ${nonEmptyBodies.length} asset(s); ` +
546
+ `will fall back per-asset. promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
547
+ return null;
548
+ }
549
+ return parsed;
550
+ }
551
+ catch (err) {
552
+ const errMsg = toErrorMessage(err);
553
+ if (isContextSizeError(errMsg)) {
554
+ batchContextError = true;
555
+ bumpTelemetry(options.telemetry, "contextBatchRetries");
556
+ warn(`graph extraction (batch): context size exceeded for ${nonEmptyBodies.length} asset(s); ` +
557
+ `skipping batch. promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
558
+ }
559
+ else {
560
+ warn(`graph extraction (batch) failed for ${nonEmptyBodies.length} asset(s); ` +
561
+ `promptChars=${userPrompt.length}${formatContextHint(llmConfig)}: ${errMsg}`);
562
+ }
563
+ return null;
564
+ }
565
+ }, null, {
566
+ timeoutMs: llmConfig.timeoutMs,
567
+ onFallback,
568
+ });
569
+ // Map successful batch results back to their original indices.
570
+ if (batchResult !== null) {
571
+ if (batchState)
572
+ batchState.nonArrayBatchFailures = 0;
573
+ if (batchResult.length > nonEmptyBodies.length) {
574
+ warn(`graph extraction (batch): response had ${batchResult.length} items for ${nonEmptyBodies.length} assets; ` +
575
+ `ignoring ${batchResult.length - nonEmptyBodies.length} extra item(s).`);
576
+ }
577
+ for (let j = 0; j < nonEmptyBodies.length; j++) {
578
+ const originalIndex = nonEmptyIndices[j];
579
+ if (originalIndex === undefined)
580
+ continue;
581
+ if (j < batchResult.length) {
582
+ results[originalIndex] = parseBatchItem(batchResult[j]);
583
+ }
584
+ // j >= batchResult.length → partial failure; handled below.
585
+ }
586
+ }
587
+ if (batchContextError && nonEmptyBodies.length > 1) {
588
+ const splitAt = Math.ceil(nonEmptyBodies.length / 2);
589
+ const left = await extractGraphFromBodies(llmConfig, nonEmptyBodies.slice(0, splitAt), signal, akmConfig, onFallback, options);
590
+ const right = await extractGraphFromBodies(llmConfig, nonEmptyBodies.slice(splitAt), signal, akmConfig, onFallback, options);
591
+ const combined = [...left, ...right];
592
+ for (let j = 0; j < nonEmptyIndices.length; j++) {
593
+ const origIdx = nonEmptyIndices[j];
594
+ if (origIdx === undefined)
595
+ continue;
596
+ results[origIdx] = combined[j] ?? empty();
597
+ }
598
+ return results;
599
+ }
600
+ // Partial-failure fallback: any non-empty body whose result is still the
601
+ // empty placeholder (either because batchResult was null or the array was
602
+ // shorter than expected) gets an individual retry — unless the batch failed
603
+ // due to context size, in which case individual calls would also fail.
604
+ const fallbackIndices = nonEmptyIndices.filter((_origIdx, j) => {
605
+ if (batchContextError)
606
+ return false; // skip individual retries on context error
607
+ // Result is still empty → needs a fallback call.
608
+ if (batchResult === null)
609
+ return true;
610
+ // batchResult was shorter than the number of non-empty bodies.
611
+ return j >= batchResult.length;
612
+ });
613
+ if (fallbackIndices.length > 0) {
614
+ if (batchResult !== null) {
615
+ // Only warn on partial failure (not when the whole batch failed, which
616
+ // already emitted a warn above).
617
+ warn(`graph extraction (batch): response had ${batchResult.length} items for ${nonEmptyBodies.length} assets; ` +
618
+ `falling back to individual calls for ${fallbackIndices.length} missing asset(s).`);
619
+ }
620
+ await Promise.all(fallbackIndices.map(async (origIdx) => {
621
+ const body = bodies[origIdx] ?? "";
622
+ results[origIdx] = await extractGraphFromBody(llmConfig, body, signal, akmConfig, onFallback, options);
623
+ }));
624
+ }
625
+ else if (batchContextError) {
626
+ warn(`graph extraction (batch): skipped ${nonEmptyBodies.length} asset(s) due to context size error; ` +
627
+ `consider increasing llm.contextLength or reducing index.graph.graphExtractionBatchSize to 1.`);
628
+ }
629
+ else if (nonArrayResponse && batchState?.batchingDisabled) {
630
+ warn("graph extraction (batch): disabling batching for the rest of this run after repeated non-array responses.");
631
+ }
632
+ return results;
633
+ }
45
634
  /**
46
635
  * Extract entities and relations from a single asset body via the configured LLM.
47
636
  *
48
637
  * Returns `{entities: [], relations: []}` on any failure (timeout, invalid
49
638
  * JSON, empty response). Errors are logged via `warn()` but never thrown — a
50
639
  * failed extraction for one asset must not abort the rest of the index pass.
640
+ *
641
+ * Routes through `tryLlmFeature("graph_extraction", ...)` so the feature gate
642
+ * and onFallback hook are honoured uniformly (Fix C5).
51
643
  */
52
- export async function extractGraphFromBody(llmConfig, body, signal) {
53
- const empty = { entities: [], relations: [] };
644
+ export async function extractGraphFromBody(llmConfig, body, signal, akmConfig, onFallback, options = {}) {
645
+ const empty = (reason, status) => ({
646
+ entities: [],
647
+ relations: [],
648
+ ...(status ? { status } : {}),
649
+ ...(reason ? { reason } : {}),
650
+ });
54
651
  const trimmedBody = body.trim();
55
652
  if (!trimmedBody)
56
- return empty;
57
- const userPrompt = `${USER_PROMPT_PREFIX}${trimmedBody.slice(0, MAX_BODY_CHARS)}`;
58
- let timeoutHandle;
59
- try {
60
- const raw = await Promise.race([
61
- chatCompletion(llmConfig, [
653
+ return empty();
654
+ const chunked = splitBodyIntoChunks(trimmedBody, MAX_CHUNK_BODY_CHARS);
655
+ if (chunked.truncationCount > 0) {
656
+ bumpTelemetry(options.telemetry, "truncationCount", chunked.truncationCount);
657
+ warnVerbose(`graph extraction: split a long asset into ${chunked.chunks.length} chunk(s) with ${chunked.truncationCount} hard split(s).`);
658
+ }
659
+ if (chunked.chunks.length > 1) {
660
+ const chunkResults = [];
661
+ for (const chunk of chunked.chunks) {
662
+ chunkResults.push(await extractGraphFromBody(llmConfig, chunk, signal, akmConfig, onFallback, options));
663
+ }
664
+ const merged = mergeGraphExtractions(chunkResults);
665
+ merged.truncationCount = (merged.truncationCount ?? 0) + chunked.truncationCount;
666
+ return merged;
667
+ }
668
+ const userPrompt = `${USER_PROMPT_PREFIX}${trimmedBody}`;
669
+ return tryLlmFeature("graph_extraction", akmConfig, async () => {
670
+ try {
671
+ const raw = await chatCompletion(llmConfig, [
62
672
  { role: "system", content: SYSTEM_PROMPT },
63
673
  { role: "user", content: userPrompt },
64
- ], { maxTokens: 1024, temperature: 0.1, signal }),
65
- new Promise((_, reject) => {
66
- timeoutHandle = setTimeout(() => reject(new Error("graph extraction timed out")), LLM_TIMEOUT_MS);
67
- }),
68
- ]);
69
- if (!raw)
70
- return empty;
71
- const parsed = parseEmbeddedJsonResponse(raw);
72
- if (!parsed) {
73
- warn("graph extraction: invalid JSON response from LLM; skipping asset.");
74
- return empty;
75
- }
76
- const entities = Array.isArray(parsed.entities)
77
- ? parsed.entities
78
- .filter((e) => typeof e === "string")
79
- .map((e) => e.trim())
80
- .filter((e) => e.length > 0)
81
- .slice(0, MAX_ENTITIES_PER_ASSET)
82
- : [];
83
- const entitySet = new Set(entities);
84
- const relations = Array.isArray(parsed.relations)
85
- ? parsed.relations
86
- .filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
87
- .map((r) => ({
88
- from: typeof r.from === "string" ? r.from.trim() : "",
89
- to: typeof r.to === "string" ? r.to.trim() : "",
90
- type: typeof r.type === "string" && r.type.trim() ? r.type.trim() : undefined,
91
- }))
92
- // Both endpoints must be non-empty AND mentioned in entities[];
93
- // dangling relations are noise and inflate the boost component.
94
- .filter((r) => r.from && r.to && entitySet.has(r.from) && entitySet.has(r.to))
95
- .slice(0, MAX_RELATIONS_PER_ASSET)
96
- : [];
97
- return { entities, relations };
98
- }
99
- catch (err) {
100
- warn(`graph extraction failed: ${toErrorMessage(err)}`);
101
- return empty;
102
- }
103
- finally {
104
- if (timeoutHandle !== undefined)
105
- clearTimeout(timeoutHandle);
106
- }
674
+ ], { temperature: 0.1, timeoutMs: llmConfig.timeoutMs, signal });
675
+ if (!raw)
676
+ return empty();
677
+ const parsed = parseEmbeddedJsonResponse(raw);
678
+ if (!parsed) {
679
+ warn("graph extraction: invalid JSON response from LLM; skipping asset.");
680
+ bumpTelemetry(options.telemetry, "failureCount");
681
+ return empty("invalid_json", "failed");
682
+ }
683
+ const extraction = parseGraphExtraction(parsed);
684
+ bumpTelemetry(options.telemetry, "filteredGenericEntities", extraction.filteredGenericEntities ?? 0);
685
+ bumpTelemetry(options.telemetry, "filteredInvalidRelations", extraction.filteredInvalidRelations ?? 0);
686
+ bumpTelemetry(options.telemetry, "filteredLowConfidenceRelations", extraction.filteredLowConfidenceRelations ?? 0);
687
+ if (extraction.status === "failed")
688
+ bumpTelemetry(options.telemetry, "failureCount");
689
+ return extraction;
690
+ }
691
+ catch (err) {
692
+ const errMsg = toErrorMessage(err);
693
+ if (isContextSizeError(errMsg)) {
694
+ bumpTelemetry(options.telemetry, "failureCount");
695
+ warn(`graph extraction: context size exceeded for asset; promptChars=${userPrompt.length}${formatContextHint(llmConfig)}. ` +
696
+ `Consider increasing llm.contextLength in config.json.`);
697
+ return empty("context_limit", "failed");
698
+ }
699
+ else {
700
+ bumpTelemetry(options.telemetry, "failureCount");
701
+ warn(`graph extraction failed for asset; promptChars=${userPrompt.length}${formatContextHint(llmConfig)}: ${errMsg}`);
702
+ return empty("llm_error", "failed");
703
+ }
704
+ }
705
+ }, empty(), {
706
+ timeoutMs: llmConfig.timeoutMs,
707
+ onFallback,
708
+ });
107
709
  }
710
+ // deduplicateGraph moved to src/indexer/graph-dedup.ts (pure utility, no LLM calls).
711
+ export { deduplicateGraph } from "../indexer/graph-dedup";