akm-cli 0.9.0-beta.5 → 0.9.0-beta.50

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 (207) hide show
  1. package/CHANGELOG.md +709 -0
  2. package/dist/assets/profiles/default.json +9 -4
  3. package/dist/assets/profiles/frequent.json +1 -1
  4. package/dist/assets/profiles/memory-focus.json +1 -1
  5. package/dist/assets/profiles/quick.json +1 -1
  6. package/dist/assets/profiles/synthesize.json +15 -0
  7. package/dist/assets/profiles/thorough.json +1 -1
  8. package/dist/assets/prompts/consolidate-system.md +23 -0
  9. package/dist/assets/prompts/contradiction-judge.md +33 -0
  10. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  11. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  12. package/dist/assets/prompts/extract-session.md +6 -2
  13. package/dist/assets/prompts/graph-extract-system.md +1 -0
  14. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  15. package/dist/assets/prompts/memory-infer-system.md +1 -0
  16. package/dist/assets/prompts/memory-infer-user.md +5 -0
  17. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  18. package/dist/assets/prompts/procedural-system.md +44 -0
  19. package/dist/assets/prompts/recombine-system.md +40 -0
  20. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  21. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  24. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  31. package/dist/assets/templates/html/health.html +281 -111
  32. package/dist/assets/wiki/ingest-workflow-template.md +17 -10
  33. package/dist/cli/shared.js +28 -0
  34. package/dist/cli.js +15 -5
  35. package/dist/commands/agent/agent-dispatch.js +2 -2
  36. package/dist/commands/agent/agent-support.js +0 -7
  37. package/dist/commands/agent/contribute-cli.js +17 -4
  38. package/dist/commands/env/env-cli.js +16 -24
  39. package/dist/commands/env/secret-cli.js +12 -20
  40. package/dist/commands/feedback-cli.js +15 -6
  41. package/dist/commands/graph/graph-cli.js +5 -13
  42. package/dist/commands/graph/graph.js +76 -72
  43. package/dist/commands/health/checks.js +48 -0
  44. package/dist/commands/health/html-report.js +422 -80
  45. package/dist/commands/health.js +386 -9
  46. package/dist/commands/improve/calibration.js +161 -0
  47. package/dist/commands/improve/consolidate/chunking.js +141 -0
  48. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  49. package/dist/commands/improve/consolidate/merge.js +145 -0
  50. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  51. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  52. package/dist/commands/improve/consolidate.js +635 -660
  53. package/dist/commands/improve/dedup.js +482 -0
  54. package/dist/commands/improve/distill.js +159 -69
  55. package/dist/commands/improve/eligibility.js +434 -0
  56. package/dist/commands/improve/encoding-salience.js +205 -0
  57. package/dist/commands/improve/extract-cli.js +124 -2
  58. package/dist/commands/improve/extract-prompt.js +39 -2
  59. package/dist/commands/improve/extract-watch.js +140 -0
  60. package/dist/commands/improve/extract.js +389 -40
  61. package/dist/commands/improve/feedback-valence.js +54 -0
  62. package/dist/commands/improve/homeostatic.js +467 -0
  63. package/dist/commands/improve/improve-auto-accept.js +109 -6
  64. package/dist/commands/improve/improve-cli.js +35 -60
  65. package/dist/commands/improve/improve-profiles.js +14 -0
  66. package/dist/commands/improve/improve-result-file.js +5 -23
  67. package/dist/commands/improve/improve-session.js +58 -0
  68. package/dist/commands/improve/improve.js +485 -2498
  69. package/dist/commands/improve/locks.js +154 -0
  70. package/dist/commands/improve/loop-stages.js +1083 -0
  71. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  72. package/dist/commands/improve/outcome-loop.js +256 -0
  73. package/dist/commands/improve/preparation.js +1966 -0
  74. package/dist/commands/improve/proactive-maintenance.js +115 -0
  75. package/dist/commands/improve/procedural.js +418 -0
  76. package/dist/commands/improve/recombine.js +813 -0
  77. package/dist/commands/improve/reflect-noise.js +0 -0
  78. package/dist/commands/improve/reflect.js +183 -40
  79. package/dist/commands/improve/salience.js +438 -0
  80. package/dist/commands/improve/triage.js +93 -0
  81. package/dist/commands/lint/agent-linter.js +19 -24
  82. package/dist/commands/lint/base-linter.js +173 -60
  83. package/dist/commands/lint/command-linter.js +19 -24
  84. package/dist/commands/lint/env-key-rules.js +34 -1
  85. package/dist/commands/lint/fact-linter.js +39 -0
  86. package/dist/commands/lint/index.js +31 -13
  87. package/dist/commands/lint/memory-linter.js +1 -1
  88. package/dist/commands/lint/registry.js +7 -2
  89. package/dist/commands/lint/task-linter.js +3 -3
  90. package/dist/commands/lint/workflow-linter.js +26 -1
  91. package/dist/commands/proposal/drain-policies.js +5 -0
  92. package/dist/commands/proposal/drain.js +43 -50
  93. package/dist/commands/proposal/proposal-cli.js +21 -31
  94. package/dist/commands/proposal/proposal.js +5 -0
  95. package/dist/commands/proposal/propose.js +7 -2
  96. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  97. package/dist/commands/proposal/validators/proposals.js +189 -63
  98. package/dist/commands/read/curate.js +414 -94
  99. package/dist/commands/read/knowledge.js +2 -2
  100. package/dist/commands/read/search-cli.js +7 -0
  101. package/dist/commands/read/search.js +1 -0
  102. package/dist/commands/read/show.js +67 -2
  103. package/dist/commands/sources/init.js +36 -9
  104. package/dist/commands/sources/installed-stashes.js +5 -1
  105. package/dist/commands/sources/schema-repair.js +13 -1
  106. package/dist/commands/sources/self-update.js +2 -2
  107. package/dist/commands/sources/stash-cli.js +28 -40
  108. package/dist/commands/sources/stash-skeleton.js +23 -8
  109. package/dist/commands/tasks/tasks-cli.js +19 -27
  110. package/dist/commands/tasks/tasks.js +1 -1
  111. package/dist/commands/wiki-cli.js +21 -35
  112. package/dist/core/asset/asset-registry.js +2 -0
  113. package/dist/core/asset/asset-spec.js +14 -0
  114. package/dist/core/asset/frontmatter.js +166 -167
  115. package/dist/core/asset/markdown.js +8 -0
  116. package/dist/core/authoring-rules.js +92 -0
  117. package/dist/core/common.js +0 -5
  118. package/dist/core/config/config-schema.js +340 -56
  119. package/dist/core/config/config-types.js +3 -3
  120. package/dist/core/config/config.js +28 -7
  121. package/dist/core/events.js +3 -7
  122. package/dist/core/improve-types.js +11 -8
  123. package/dist/core/logs-db.js +10 -66
  124. package/dist/core/parse.js +36 -16
  125. package/dist/core/paths.js +3 -0
  126. package/dist/core/standards/resolve-standards-context.js +87 -0
  127. package/dist/core/standards/resolve-stash-standards.js +99 -0
  128. package/dist/core/standards/resolve-type-conventions.js +66 -0
  129. package/dist/core/state/migrations.js +714 -0
  130. package/dist/core/state-db.js +525 -474
  131. package/dist/indexer/db/db.js +439 -247
  132. package/dist/indexer/db/graph-db.js +129 -86
  133. package/dist/indexer/ensure-index.js +152 -17
  134. package/dist/indexer/graph/graph-boost.js +51 -41
  135. package/dist/indexer/graph/graph-extraction.js +218 -4
  136. package/dist/indexer/index-writer-lock.js +99 -0
  137. package/dist/indexer/indexer.js +123 -221
  138. package/dist/indexer/passes/dir-staleness.js +114 -0
  139. package/dist/indexer/passes/memory-inference.js +10 -3
  140. package/dist/indexer/passes/staleness-detect.js +2 -5
  141. package/dist/indexer/search/db-search.js +15 -4
  142. package/dist/indexer/search/ranking-contributors.js +22 -0
  143. package/dist/indexer/search/ranking.js +4 -0
  144. package/dist/indexer/search/search-source.js +10 -24
  145. package/dist/indexer/search/semantic-status.js +4 -0
  146. package/dist/indexer/walk/matchers.js +9 -0
  147. package/dist/integrations/agent/config.js +6 -53
  148. package/dist/integrations/agent/index.js +2 -18
  149. package/dist/integrations/agent/prompts.js +74 -8
  150. package/dist/integrations/agent/runner-dispatch.js +59 -0
  151. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  152. package/dist/integrations/harnesses/index.js +2 -3
  153. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  154. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  155. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  156. package/dist/integrations/session-logs/index.js +16 -0
  157. package/dist/llm/client.js +45 -15
  158. package/dist/llm/embedder.js +42 -3
  159. package/dist/llm/embedders/deterministic.js +66 -0
  160. package/dist/llm/embedders/local.js +66 -2
  161. package/dist/llm/feature-gate.js +8 -4
  162. package/dist/llm/graph-extract.js +67 -44
  163. package/dist/llm/memory-infer.js +38 -30
  164. package/dist/llm/metadata-enhance.js +44 -31
  165. package/dist/llm/structured-call.js +49 -0
  166. package/dist/output/context.js +5 -5
  167. package/dist/output/renderers.js +73 -1
  168. package/dist/output/shapes/curate.js +14 -2
  169. package/dist/output/shapes/passthrough.js +0 -1
  170. package/dist/output/text/helpers.js +16 -1
  171. package/dist/registry/providers/skills-sh.js +21 -147
  172. package/dist/registry/providers/static-index.js +15 -157
  173. package/dist/registry/resolve.js +22 -9
  174. package/dist/runtime.js +25 -1
  175. package/dist/scripts/migrate-storage.js +2136 -1596
  176. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +682 -433
  177. package/dist/setup/setup.js +29 -8
  178. package/dist/sources/providers/filesystem.js +0 -1
  179. package/dist/sources/providers/git-install.js +206 -0
  180. package/dist/sources/providers/git-provider.js +234 -0
  181. package/dist/sources/providers/git-stash.js +248 -0
  182. package/dist/sources/providers/git.js +10 -661
  183. package/dist/sources/providers/npm.js +2 -6
  184. package/dist/sources/providers/sync-from-ref.js +9 -1
  185. package/dist/sources/providers/tar-utils.js +16 -8
  186. package/dist/sources/providers/website.js +2 -3
  187. package/dist/sources/website-ingest.js +51 -9
  188. package/dist/sources/wiki-fetchers/registry.js +53 -0
  189. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  190. package/dist/storage/database.js +45 -10
  191. package/dist/storage/managed-db.js +82 -0
  192. package/dist/storage/repositories/registry-cache.js +92 -0
  193. package/dist/storage/sqlite-pragmas.js +146 -0
  194. package/dist/tasks/backends/cron.js +1 -1
  195. package/dist/tasks/backends/launchd.js +1 -1
  196. package/dist/tasks/backends/schtasks.js +1 -1
  197. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  198. package/dist/tasks/runner.js +5 -13
  199. package/dist/wiki/wiki.js +37 -0
  200. package/dist/workflows/db.js +3 -4
  201. package/dist/workflows/runtime/runs.js +1 -117
  202. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  203. package/dist/workflows/validate-summary.js +2 -7
  204. package/docs/data-and-telemetry.md +1 -0
  205. package/package.json +9 -7
  206. package/dist/commands/db-cli.js +0 -23
  207. package/dist/indexer/db/db-backup.js +0 -376
@@ -5,7 +5,7 @@
5
5
  * OpenCode SDK harness (#564).
6
6
  *
7
7
  * Per-harness barrel for the SDK-mode dispatch path:
8
- * - agent runner → ./sdk-runner.ts (runOpencodeSdk / runAgentSdk)
8
+ * - agent runner → ./sdk-runner.ts (runOpencodeSdk)
9
9
  *
10
10
  * It also defines {@link OpencodeSdkHarness}, the {@link AkmHarness} descriptor
11
11
  * that `HARNESS_REGISTRY` registers.
@@ -17,7 +17,7 @@
17
17
  * names. Canonical id is `'opencode-sdk'` with no alias.
18
18
  */
19
19
  import { BaseHarness } from "../types.js";
20
- export { closeServer, runAgentSdk, runOpencodeSdk } from "./sdk-runner.js";
20
+ export { closeServer, runOpencodeSdk } from "./sdk-runner.js";
21
21
  function caps(c) {
22
22
  return {
23
23
  sessionLogs: false,
@@ -230,5 +230,3 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
230
230
  await client.session.delete({ path: { id: sessionId } }).catch(() => { });
231
231
  }
232
232
  }
233
- /** @deprecated Use {@link runOpencodeSdk} instead. */
234
- export const runAgentSdk = runOpencodeSdk;
@@ -28,6 +28,22 @@ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENO
28
28
  export function getAvailableHarnesses() {
29
29
  return HARNESSES.filter((harness) => harness.isAvailable());
30
30
  }
31
+ /**
32
+ * Map each available harness to its `{ harnessName, roots }` watch target,
33
+ * skipping harnesses that expose no roots (absent `watchRoots()` or an empty
34
+ * result). This is the one stable entry point the watcher uses so it never
35
+ * reaches into providers directly.
36
+ */
37
+ export function getWatchTargets() {
38
+ const targets = [];
39
+ for (const harness of getAvailableHarnesses()) {
40
+ const roots = harness.watchRoots?.() ?? [];
41
+ if (roots.length === 0)
42
+ continue;
43
+ targets.push({ harnessName: harness.name, roots });
44
+ }
45
+ return targets;
46
+ }
31
47
  export function normalizeSessionTopic(text) {
32
48
  const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
33
49
  if (normalized.length < 10)
@@ -103,35 +103,65 @@ function retryBackoffMs() {
103
103
  return RETRY_BACKOFF_MIN_MS + Math.random() * (RETRY_BACKOFF_MAX_MS - RETRY_BACKOFF_MIN_MS);
104
104
  }
105
105
  /**
106
- * Detect whether an error message indicates a context-size-exceeded condition.
107
- * Mirrors the heuristic in `graph-extract.ts` retrying a context overflow
108
- * cannot shrink the input, so it must not be retried.
106
+ * Detect whether an error message indicates a context size exceeded condition.
107
+ * Covers common patterns from OpenAI-compatible APIs (LM Studio, Ollama, etc).
108
+ *
109
+ * Requires BOTH a context keyword AND token-count/overflow evidence so that
110
+ * model prose merely mentioning "context size" / "context length" (e.g. gemma
111
+ * narrating about a document) does not get misclassified as a provider
112
+ * context-limit error (#496).
113
+ *
114
+ * Canonical home: `graph-extract.ts` re-exports this so the index-pass
115
+ * graph extractor and the retry classifier (`isRetryable`) share one
116
+ * definition — retrying a context overflow cannot shrink the input, so it
117
+ * must never be retried.
109
118
  */
110
- function looksLikeContextOverflow(message) {
119
+ export function isContextSizeError(message) {
111
120
  const lower = message.toLowerCase();
112
- return (lower.includes("context") &&
113
- (lower.includes("context size") ||
114
- lower.includes("context length") ||
115
- lower.includes("context_window") ||
116
- lower.includes("prompt too long") ||
117
- lower.includes("exceeds")));
121
+ const contextKw = /context (size|length|window)|prompt too long|exceeds.*context/.test(lower);
122
+ if (!contextKw) {
123
+ return false;
124
+ }
125
+ const evidence = /\b\d+\s*(token|tokens|tk)\b/.test(lower) ||
126
+ /max(imum)?\s+(context|token|input)/.test(lower) ||
127
+ /exceeded|over.*limit|too.*long/.test(lower);
128
+ return evidence;
118
129
  }
119
130
  /**
120
131
  * Decide whether a first-attempt {@link LlmCallError} is eligible for a single
121
132
  * retry. Retryable: HTTP 5xx (`provider_error` with statusCode >= 500) and
122
- * `network_error` whose message looks like a transient connection reset
123
- * (ECONNRESET / EPIPE / "fetch failed"). NOT retryable: 4xx, `rate_limited`
124
- * (429), `timeout`, `parse_error`, and context-overflow-classified errors.
133
+ * `network_error` whose message looks like a transient connection drop.
134
+ * NOT retryable: 4xx, `rate_limited` (429), `timeout`, `parse_error`, and
135
+ * context-overflow-classified errors.
136
+ *
137
+ * The connection-drop heuristic covers the substrings emitted across runtimes
138
+ * for a mid-flight socket close:
139
+ * - `ECONNRESET` / `EPIPE` — Node/libuv socket reset codes
140
+ * - `fetch failed` — undici's generic wrapper message
141
+ * - `socket connection was closed` — Bun's message for a dropped connection
142
+ * (e.g. "The socket connection was closed unexpectedly.")
143
+ * - `terminated` / `other side closed` — undici's phrasings for the same
144
+ *
145
+ * These all describe a transient transport failure where a second attempt can
146
+ * legitimately succeed, which is exactly the case a single bounded retry is
147
+ * meant to absorb. Before this list was widened, Bun's "socket connection was
148
+ * closed unexpectedly" fell through unretried and surfaced as a recurring
149
+ * failure in the improve/reflect and capability-probe flows.
125
150
  */
126
151
  function isRetryable(err) {
127
- if (looksLikeContextOverflow(err.message))
152
+ if (isContextSizeError(err.message))
128
153
  return false;
129
154
  if (err.code === "provider_error") {
130
155
  return typeof err.statusCode === "number" && err.statusCode >= 500;
131
156
  }
132
157
  if (err.code === "network_error") {
133
158
  const lower = err.message.toLowerCase();
134
- return lower.includes("econnreset") || lower.includes("epipe") || lower.includes("fetch failed");
159
+ return (lower.includes("econnreset") ||
160
+ lower.includes("epipe") ||
161
+ lower.includes("fetch failed") ||
162
+ lower.includes("socket connection was closed") ||
163
+ lower.includes("terminated") ||
164
+ lower.includes("other side closed"));
135
165
  }
136
166
  return false;
137
167
  }
@@ -2,7 +2,8 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { embedCacheKey, getCachedEmbedding, setCachedEmbedding } from "./embedders/cache.js";
5
- import { isTransformersAvailable, LocalEmbedder } from "./embedders/local.js";
5
+ import { DETERMINISTIC_EMBED_MODEL_ID, deterministicEmbed, isDeterministicEmbedEnabled, } from "./embedders/deterministic.js";
6
+ import { DEFAULT_LOCAL_MODEL, isTransformersAvailable, LocalEmbedder } from "./embedders/local.js";
6
7
  import { hasRemoteEndpoint, RemoteEmbedder } from "./embedders/remote.js";
7
8
  // ── Re-exports (public API) ─────────────────────────────────────────────────
8
9
  export { clearEmbeddingCache } from "./embedders/cache.js";
@@ -39,6 +40,10 @@ export function resetLocalEmbedder() {
39
40
  * and embedding config. Repeated identical queries return the cached vector.
40
41
  */
41
42
  export async function embed(text, embeddingConfig, signal) {
43
+ // Deterministic mode (env-gated, test/bench only): model-free, stable.
44
+ if (isDeterministicEmbedEnabled()) {
45
+ return deterministicEmbed(text);
46
+ }
42
47
  const key = embedCacheKey(text, embeddingConfig);
43
48
  const cached = getCachedEmbedding(key);
44
49
  if (cached)
@@ -52,16 +57,26 @@ export async function embed(text, embeddingConfig, signal) {
52
57
  /**
53
58
  * Generate embeddings for multiple texts in batch.
54
59
  * Uses the OpenAI-compatible batch API for remote endpoints (batches of 100).
55
- * Falls back to sequential embedding for the local transformer pipeline.
60
+ * Uses the LocalEmbedder.embedBatch path for the local transformer pipeline,
61
+ * which processes texts in chunks of 32 for genuine batched inference.
56
62
  */
57
63
  export async function embedBatch(texts, embeddingConfig, signal) {
58
64
  if (texts.length === 0)
59
65
  return [];
66
+ // Deterministic mode (env-gated, test/bench only): model-free, stable.
67
+ if (isDeterministicEmbedEnabled()) {
68
+ return texts.map((t) => deterministicEmbed(t));
69
+ }
60
70
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
61
71
  return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal);
62
72
  }
63
- // Local transformer: process sequentially (pipeline handles one at a time)
73
+ // Local transformer: use the batched path (chunks of 32 via LocalEmbedder).
74
+ // When a localModel override is set we cannot share the singleton (which uses
75
+ // the default model), so fall back to per-text embedWithModel in that case.
64
76
  const localModel = embeddingConfig?.localModel;
77
+ if (!localModel) {
78
+ return getLocalEmbedder().embedBatch(texts, signal);
79
+ }
65
80
  const results = [];
66
81
  for (const text of texts) {
67
82
  if (signal?.aborted) {
@@ -77,11 +92,35 @@ export async function embedBatch(texts, embeddingConfig, signal) {
77
92
  // facade and its `@huggingface/transformers` import chain. Re-export
78
93
  // preserves the existing public API.
79
94
  export { cosineSimilarity } from "./embedders/types.js";
95
+ // ── Model ID resolution ─────────────────────────────────────────────────────
96
+ /**
97
+ * Derive a stable string identifier for the embedding model in use.
98
+ * This is the `model_id` stored in `body_embeddings` (and used for the
99
+ * drop-all-on-mismatch purge when the model changes).
100
+ *
101
+ * Rules:
102
+ * - Remote endpoint: use `config.model` (the API-level model name).
103
+ * - Local transformers: use `config.localModel ?? DEFAULT_LOCAL_MODEL`.
104
+ * - No config: use `DEFAULT_LOCAL_MODEL` (the shared singleton model).
105
+ */
106
+ export function resolveEmbeddingModelId(embeddingConfig) {
107
+ if (isDeterministicEmbedEnabled())
108
+ return DETERMINISTIC_EMBED_MODEL_ID;
109
+ if (!embeddingConfig)
110
+ return DEFAULT_LOCAL_MODEL;
111
+ if (hasRemoteEndpoint(embeddingConfig))
112
+ return embeddingConfig.model ?? "remote";
113
+ return embeddingConfig.localModel ?? DEFAULT_LOCAL_MODEL;
114
+ }
80
115
  // ── Availability check ──────────────────────────────────────────────────────
81
116
  /**
82
117
  * Check whether embedding is available with a detailed reason on failure.
83
118
  */
84
119
  export async function checkEmbeddingAvailability(embeddingConfig) {
120
+ // Deterministic mode (env-gated): always available — no model, no network.
121
+ if (isDeterministicEmbedEnabled()) {
122
+ return { available: true };
123
+ }
85
124
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
86
125
  try {
87
126
  await new RemoteEmbedder(embeddingConfig).embed("test");
@@ -0,0 +1,66 @@
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/.
4
+ /** Env var that switches the whole embedding facade into deterministic mode. */
5
+ export const DETERMINISTIC_EMBED_ENV = "AKM_EMBED_DETERMINISTIC";
6
+ /**
7
+ * Vector width. Matches the default local model (`bge-small`, 384 dims) so the
8
+ * index DB's embedding column and sqlite-vec table dimensions line up without
9
+ * any extra config.
10
+ */
11
+ export const DETERMINISTIC_EMBED_DIM = 384;
12
+ /**
13
+ * Stable model id reported for deterministic mode. Used as the embedding
14
+ * `model_id` and folded into the provider fingerprint so a deterministic index
15
+ * is never confused with a real-model index (and vice versa).
16
+ */
17
+ export const DETERMINISTIC_EMBED_MODEL_ID = "akm-deterministic-hash-v1";
18
+ /** True when deterministic embedding is enabled via env. */
19
+ export function isDeterministicEmbedEnabled() {
20
+ return process.env[DETERMINISTIC_EMBED_ENV] === "1";
21
+ }
22
+ /** FNV-1a 32-bit hash. Platform- and version-stable. */
23
+ function fnv1a(str) {
24
+ let h = 0x811c9dc5;
25
+ for (let i = 0; i < str.length; i++) {
26
+ h ^= str.charCodeAt(i);
27
+ // 32-bit FNV prime multiply via shifts to stay in uint32.
28
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
29
+ }
30
+ return h >>> 0;
31
+ }
32
+ /** Lowercase, split on non-alphanumeric, drop empties. */
33
+ function tokenize(text) {
34
+ return text
35
+ .toLowerCase()
36
+ .split(/[^a-z0-9]+/)
37
+ .filter((t) => t.length > 0);
38
+ }
39
+ /**
40
+ * Deterministically embed `text` into a unit-length vector of width `dim`
41
+ * using feature hashing. Empty / token-less input returns a fixed unit
42
+ * vector so cosine similarity never sees a zero vector (NaN guard).
43
+ */
44
+ export function deterministicEmbed(text, dim = DETERMINISTIC_EMBED_DIM) {
45
+ const vec = new Array(dim).fill(0);
46
+ const tokens = tokenize(text);
47
+ for (const tok of tokens) {
48
+ const h = fnv1a(tok);
49
+ const idx = h % dim;
50
+ // Use a higher bit for the sign so it is independent of the bucket index.
51
+ const sign = (h >>> 16) & 1 ? 1 : -1;
52
+ vec[idx] += sign;
53
+ }
54
+ let norm = 0;
55
+ for (const v of vec)
56
+ norm += v * v;
57
+ norm = Math.sqrt(norm);
58
+ if (norm === 0) {
59
+ // No usable tokens — return a fixed, stable unit vector.
60
+ vec[0] = 1;
61
+ return vec;
62
+ }
63
+ for (let i = 0; i < dim; i++)
64
+ vec[i] /= norm;
65
+ return vec;
66
+ }
@@ -19,8 +19,24 @@ import { getDirname, resolveModule } from "../../runtime.js";
19
19
  * `all-MiniLM-L6-v2` at the same 384-dimension footprint.
20
20
  */
21
21
  export const DEFAULT_LOCAL_MODEL = "Xenova/bge-small-en-v1.5";
22
+ /** Type-guard: true when the value looks like a batch Tensor (has .dims). */
23
+ function isBatchTensor(v) {
24
+ return (v !== null &&
25
+ typeof v === "object" &&
26
+ "data" in v &&
27
+ "dims" in v &&
28
+ Array.isArray(v.dims) &&
29
+ v.dims.length >= 2);
30
+ }
22
31
  const LOCAL_EMBEDDER_DTYPE = "fp32";
23
32
  const LOCAL_EMBEDDER_FALLBACK_DTYPE = "auto";
33
+ /**
34
+ * Maximum texts per batch for the local transformers pipeline. The pipeline
35
+ * can run genuine batched inference over a string array; 32 is a safe default
36
+ * that fits well inside most model context budgets while providing 10–50×
37
+ * throughput improvement over one-at-a-time calls on the cold minority.
38
+ */
39
+ const LOCAL_BATCH_SIZE = 32;
24
40
  /**
25
41
  * Return the local model name that will be used for embedding.
26
42
  * When `overrideModel` is provided it takes precedence; otherwise
@@ -77,15 +93,63 @@ export class LocalEmbedder {
77
93
  }
78
94
  return this.embedWithModel(text, this.defaultModel);
79
95
  }
96
+ /**
97
+ * Embed a batch of texts. Processes in chunks of `LOCAL_BATCH_SIZE` (32) so
98
+ * the transformers pipeline can run genuine batched inference rather than one
99
+ * call per text. Falls back to one-at-a-time if the pipeline does not support
100
+ * array input (older versions of @huggingface/transformers). Each chunk is
101
+ * checked against the AbortSignal between calls.
102
+ */
80
103
  async embedBatch(texts, signal) {
81
104
  if (texts.length === 0)
82
105
  return [];
106
+ if (signal?.aborted) {
107
+ throw signal.reason instanceof Error ? signal.reason : new Error("embedding interrupted");
108
+ }
109
+ const pipeline = await this.getPipeline(this.defaultModel);
83
110
  const results = [];
84
- for (const text of texts) {
111
+ for (let i = 0; i < texts.length; i += LOCAL_BATCH_SIZE) {
85
112
  if (signal?.aborted) {
86
113
  throw signal.reason instanceof Error ? signal.reason : new Error("embedding interrupted");
87
114
  }
88
- results.push(await this.embedWithModel(text, this.defaultModel));
115
+ const chunk = texts.slice(i, i + LOCAL_BATCH_SIZE);
116
+ try {
117
+ // @huggingface/transformers feature-extraction pipeline accepts a
118
+ // string[] and returns a batch Tensor (NOT an Array<{data}>).
119
+ // The Tensor has .data (flat Float32Array, length = batch * dim) and
120
+ // .dims = [batch, dim]. Slice .data into per-row vectors using .dims.
121
+ const batchResult = await pipeline(chunk, {
122
+ pooling: "mean",
123
+ normalize: true,
124
+ });
125
+ if (isBatchTensor(batchResult)) {
126
+ const dim = batchResult.dims[1];
127
+ for (let row = 0; row < chunk.length; row++) {
128
+ results.push(Array.from(batchResult.data.subarray(row * dim, (row + 1) * dim)));
129
+ }
130
+ }
131
+ else if (Array.isArray(batchResult)) {
132
+ // Older versions of @huggingface/transformers returned Array<{data}>.
133
+ for (const r of batchResult) {
134
+ results.push(Array.from(r.data));
135
+ }
136
+ }
137
+ else {
138
+ // Single-text result returned for a chunk — should not happen for
139
+ // string[] input, but handle defensively.
140
+ throw new Error("unexpected pipeline return shape for batch input");
141
+ }
142
+ }
143
+ catch {
144
+ // Fallback: process one-at-a-time (older pipeline versions or mismatched
145
+ // return type). Fail-open per text: a single failure aborts the chunk.
146
+ for (const text of chunk) {
147
+ if (signal?.aborted) {
148
+ throw signal.reason instanceof Error ? signal.reason : new Error("embedding interrupted");
149
+ }
150
+ results.push(await this.embedWithModel(text, this.defaultModel));
151
+ }
152
+ }
89
153
  }
90
154
  return results;
91
155
  }
@@ -30,10 +30,14 @@ const FEATURE_LOCATION = {
30
30
  proposal_quality_gate: (cfg) => cfg.profiles?.improve?.default?.processes?.reflect?.qualityGate?.enabled ?? false,
31
31
  // Legacy default: false
32
32
  memory_contradiction_detection: (cfg) => cfg.profiles?.improve?.default?.processes?.consolidate?.contradictionDetection?.enabled ?? false,
33
- // Default: true. Session extraction replaces the akm-plugin checkpoint hook
34
- // and is the primary path for capturing durable signal from real sessions.
35
- // Opt out via `profiles.improve.default.processes.extract.enabled: false`.
36
- session_extraction: (cfg) => cfg.profiles?.improve?.default?.processes?.extract?.enabled ?? true,
33
+ // Always on at the LLM-wrapper level. Enablement is decided ONCE at the
34
+ // extract entry point (`akmExtract`): the `extract.enabled` process toggle
35
+ // gates extract as a STAGE of `akm improve` (the active improve profile, per
36
+ // #593/#594), while an explicit `akm extract` command always runs. Gating the
37
+ // inner LLM calls on `default.processes.extract.enabled` here was a footgun —
38
+ // dropping extract from the daily improve profile silently disabled the
39
+ // standalone `akm extract` command. (cfg unused — kept for resolver signature.)
40
+ session_extraction: (_cfg) => true,
37
41
  };
38
42
  /**
39
43
  * Pure predicate: is the named feature gate enabled in `config`?
@@ -20,11 +20,13 @@
20
20
  * the connection via `resolveIndexPassLLM("graph", config)` and pass it
21
21
  * straight through.
22
22
  */
23
+ import systemPromptTemplate from "../assets/prompts/graph-extract-system.md" with { type: "text" };
23
24
  import userPromptTemplate from "../assets/prompts/graph-extract-user-prompt.md" with { type: "text" };
24
25
  import { toErrorMessage } from "../core/common.js";
25
26
  import { warn, warnVerbose } from "../core/warn.js";
26
- import { chatCompletion, LlmCallError, parseEmbeddedJsonResponse } from "./client.js";
27
+ import { chatCompletion, isContextSizeError, parseEmbeddedJsonResponse } from "./client.js";
27
28
  import { tryLlmFeature } from "./feature-gate.js";
29
+ import { callStructured } from "./structured-call.js";
28
30
  /**
29
31
  * Separator token used between assets in a batch prompt.
30
32
  * Chosen to be visually clear and unlikely to appear verbatim in asset bodies.
@@ -41,30 +43,15 @@ const NON_ARRAY_BATCH_DISABLE_THRESHOLD = 2;
41
43
  const MAX_ENTITIES_PER_ASSET = 32;
42
44
  /** Hard cap on relations returned per asset. */
43
45
  const MAX_RELATIONS_PER_ASSET = 32;
44
- const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return ONLY valid JSON — no prose, no markdown fences, no preamble.";
46
+ const SYSTEM_PROMPT = systemPromptTemplate;
45
47
  const USER_PROMPT_PREFIX = userPromptTemplate
46
48
  .replace("{{MAX_ENTITIES}}", String(MAX_ENTITIES_PER_ASSET))
47
49
  .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
- * Requires BOTH a context keyword AND token-count/overflow evidence so that
53
- * model prose merely mentioning "context size" / "context length" (e.g. gemma
54
- * narrating about a document) does not get misclassified as a provider
55
- * context-limit error (#496).
56
- */
57
- export function isContextSizeError(message) {
58
- const lower = message.toLowerCase();
59
- const contextKw = /context (size|length|window)|prompt too long|exceeds.*context/.test(lower);
60
- if (!contextKw) {
61
- return false;
62
- }
63
- const evidence = /\b\d+\s*(token|tokens|tk)\b/.test(lower) ||
64
- /max(imum)?\s+(context|token|input)/.test(lower) ||
65
- /exceeded|over.*limit|too.*long/.test(lower);
66
- return evidence;
67
- }
50
+ // `isContextSizeError` is defined in `./client` and re-exported here so the
51
+ // graph extractor and the retry classifier (`isRetryable`) share one
52
+ // definition (#496). Re-exported (not just imported) to preserve existing
53
+ // importers of this module — including its unit test.
54
+ export { isContextSizeError } from "./client.js";
68
55
  const GENERIC_ENTITIES = new Set([
69
56
  "agent",
70
57
  "application",
@@ -327,7 +314,13 @@ function parseGraphExtraction(raw) {
327
314
  if (!normalized)
328
315
  continue;
329
316
  const normalizedKey = normalized.toLowerCase();
330
- if (!/[a-z0-9]/i.test(normalized) || GENERIC_ENTITIES.has(normalizedKey)) {
317
+ // Drop generic/empty entities AND raw file/dir paths (anything with a
318
+ // path separator) — the prompt no longer asks for them and isJunkEntity
319
+ // discards them downstream, so emitting them is pure waste/junk (#632).
320
+ if (!/[a-z0-9]/i.test(normalized) ||
321
+ GENERIC_ENTITIES.has(normalizedKey) ||
322
+ normalized.includes("/") ||
323
+ normalized.includes("\\")) {
331
324
  filteredGenericEntities += 1;
332
325
  continue;
333
326
  }
@@ -432,6 +425,18 @@ function buildBatchSystemPrompt() {
432
425
  "The array length MUST equal the number of assets provided. " +
433
426
  'Use {"entities":[],"relations":[]} for assets with no extractable graph content.');
434
427
  }
428
+ /**
429
+ * Hardened system prompt for the single batch retry (#635). Used only after a
430
+ * first response failed array salvage — leans harder on "raw array only" so a
431
+ * model that wrapped the array in prose/fences corrects itself before we pay
432
+ * the per-asset fallback.
433
+ */
434
+ function buildBatchRetrySystemPrompt() {
435
+ return (`${buildBatchSystemPrompt()} ` +
436
+ "Your previous response could NOT be parsed as a JSON array. " +
437
+ "Respond with ONLY the raw JSON array — start with '[' and end with ']'. " +
438
+ "No prose, no explanation, no markdown code fences, no preamble.");
439
+ }
435
440
  function buildBatchUserPrompt(bodies) {
436
441
  const count = bodies.length;
437
442
  const assetBlocks = bodies.map((body, i) => `${BATCH_ASSET_SEPARATOR} ${i + 1} ===\n${body.trim()}`).join("\n\n");
@@ -541,7 +546,21 @@ export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfi
541
546
  });
542
547
  if (!raw)
543
548
  return null;
544
- const parsed = parseEmbeddedJsonResponse(raw);
549
+ // Array-preferring salvage (#635): the batch contract is a top-level
550
+ // JSON array. A leading/example `{…}` object in the response must not
551
+ // mask a valid `[…]` array as a false "non-array" failure.
552
+ let parsed = parseEmbeddedJsonResponse(raw, { expect: "array" });
553
+ if (!Array.isArray(parsed)) {
554
+ // One stricter-reprompt retry before paying the per-asset fallback
555
+ // (#635). Many genuine non-array responses recover when the model is
556
+ // told explicitly to emit only the raw array.
557
+ bumpTelemetry(options.telemetry, "retryAttempts");
558
+ const retryRaw = await chatCompletion(llmConfig, [
559
+ { role: "system", content: buildBatchRetrySystemPrompt() },
560
+ { role: "user", content: userPrompt },
561
+ ], { temperature: 0, timeoutMs: llmConfig.timeoutMs, signal });
562
+ parsed = retryRaw ? parseEmbeddedJsonResponse(retryRaw, { expect: "array" }) : undefined;
563
+ }
545
564
  if (!Array.isArray(parsed)) {
546
565
  nonArrayResponse = true;
547
566
  bumpTelemetry(options.telemetry, "nonArrayBatchFailures");
@@ -551,8 +570,9 @@ export async function extractGraphFromBodies(llmConfig, bodies, signal, akmConfi
551
570
  batchState.batchingDisabled = true;
552
571
  }
553
572
  }
554
- warn(`graph extraction (batch): LLM response was not a JSON array for ${nonEmptyBodies.length} asset(s); ` +
555
- `will fall back per-asset. promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
573
+ warn(`graph extraction (batch): LLM response was not a JSON array for ${nonEmptyBodies.length} asset(s) ` +
574
+ `even after a stricter retry; will fall back per-asset. ` +
575
+ `promptChars=${userPrompt.length}${formatContextHint(llmConfig)}`);
556
576
  return null;
557
577
  }
558
578
  return parsed;
@@ -675,17 +695,21 @@ export async function extractGraphFromBody(llmConfig, body, signal, akmConfig, o
675
695
  return merged;
676
696
  }
677
697
  const userPrompt = `${USER_PROMPT_PREFIX}${trimmedBody}`;
678
- return tryLlmFeature("graph_extraction", akmConfig, async () => {
679
- try {
680
- const raw = await chatCompletion(llmConfig, [
681
- { role: "system", content: SYSTEM_PROMPT },
682
- { role: "user", content: userPrompt },
683
- ], {
684
- temperature: 0.1,
685
- timeoutMs: llmConfig.timeoutMs,
686
- signal,
687
- onRetryAttempt: () => bumpTelemetry(options.telemetry, "retryAttempts"),
688
- });
698
+ return callStructured({
699
+ feature: "graph_extraction",
700
+ akmConfig,
701
+ config: llmConfig,
702
+ messages: [
703
+ { role: "system", content: SYSTEM_PROMPT },
704
+ { role: "user", content: userPrompt },
705
+ ],
706
+ request: {
707
+ temperature: 0.1,
708
+ timeoutMs: llmConfig.timeoutMs,
709
+ signal,
710
+ onRetryAttempt: () => bumpTelemetry(options.telemetry, "retryAttempts"),
711
+ },
712
+ parse: (raw) => {
689
713
  if (!raw)
690
714
  return empty();
691
715
  const parsed = parseEmbeddedJsonResponse(raw);
@@ -701,16 +725,16 @@ export async function extractGraphFromBody(llmConfig, body, signal, akmConfig, o
701
725
  if (extraction.status === "failed")
702
726
  bumpTelemetry(options.telemetry, "failureCount");
703
727
  return extraction;
704
- }
705
- catch (err) {
728
+ },
729
+ onError: (cls, err) => {
706
730
  const errMsg = toErrorMessage(err);
707
- if (isContextSizeError(errMsg)) {
731
+ if (cls === "context_limit") {
708
732
  bumpTelemetry(options.telemetry, "failureCount");
709
733
  warn(`graph extraction: context size exceeded for asset; promptChars=${userPrompt.length}${formatContextHint(llmConfig)}. ` +
710
734
  `Consider increasing llm.contextLength in config.json.`);
711
735
  return empty("context_limit", "failed");
712
736
  }
713
- else if (err instanceof LlmCallError && err.code === "provider_html_error") {
737
+ else if (cls === "html") {
714
738
  bumpTelemetry(options.telemetry, "htmlErrorCount");
715
739
  warn(`graph extraction: provider returned HTML instead of JSON for asset; promptChars=${userPrompt.length}${formatContextHint(llmConfig)}: ${errMsg}`);
716
740
  return empty("llm_error", "failed");
@@ -720,9 +744,8 @@ export async function extractGraphFromBody(llmConfig, body, signal, akmConfig, o
720
744
  warn(`graph extraction failed for asset; promptChars=${userPrompt.length}${formatContextHint(llmConfig)}: ${errMsg}`);
721
745
  return empty("llm_error", "failed");
722
746
  }
723
- }
724
- }, empty(), {
725
- timeoutMs: llmConfig.timeoutMs,
747
+ },
748
+ fallback: empty(),
726
749
  onFallback,
727
750
  });
728
751
  }