akm-cli 0.9.16 → 0.9.17-alpha.2

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 (50) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/dist/assets/prompts/consolidate-system.md +4 -11
  3. package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
  4. package/dist/commands/health/accept-rate.js +6 -0
  5. package/dist/commands/health/checks.js +54 -0
  6. package/dist/commands/health/improve-metrics.js +1 -5
  7. package/dist/commands/health/report-view-model.js +0 -1
  8. package/dist/commands/health.js +10 -0
  9. package/dist/commands/improve/consolidate/chunking.js +19 -35
  10. package/dist/commands/improve/consolidate/merge.js +6 -9
  11. package/dist/commands/improve/consolidate.js +104 -91
  12. package/dist/commands/improve/distill/promote-memory.js +40 -2
  13. package/dist/commands/improve/distill/quality-gate.js +186 -23
  14. package/dist/commands/improve/distill.js +42 -8
  15. package/dist/commands/improve/eligibility.js +13 -3
  16. package/dist/commands/improve/improve-cli.js +32 -9
  17. package/dist/commands/improve/improve-strategies.js +23 -1
  18. package/dist/commands/improve/improve.js +121 -84
  19. package/dist/commands/improve/loop-stages.js +241 -108
  20. package/dist/commands/improve/preparation.js +50 -17
  21. package/dist/commands/improve/reflect.js +16 -5
  22. package/dist/commands/improve/shared.js +0 -10
  23. package/dist/commands/proposal/drain.js +79 -10
  24. package/dist/commands/proposal/proposal-types.js +21 -0
  25. package/dist/commands/proposal/repository.js +108 -29
  26. package/dist/commands/tasks/tasks.js +19 -2
  27. package/dist/core/asset/frontmatter.js +106 -1
  28. package/dist/core/config/config.js +5 -2
  29. package/dist/core/config/retired-experimental-keys-shim.js +62 -0
  30. package/dist/core/config/schema/improve-processes.js +29 -2
  31. package/dist/core/improve-result.js +9 -0
  32. package/dist/core/paths.js +7 -0
  33. package/dist/core/write-source.js +10 -2
  34. package/dist/indexer/ensure-index.js +52 -7
  35. package/dist/indexer/graph/graph-extraction.js +82 -8
  36. package/dist/indexer/passes/memory-inference.js +16 -1
  37. package/dist/llm/client.js +16 -2
  38. package/dist/llm/graph-extract.js +162 -18
  39. package/dist/scripts/akm-migrate-node.js +97 -36
  40. package/dist/scripts/akm-migrate.js +97 -36
  41. package/dist/storage/repositories/index-entries-repository.js +43 -0
  42. package/dist/storage/repositories/proposals-repository.js +4 -1
  43. package/dist/storage/state-db-integrity.js +123 -0
  44. package/dist/workflows/program/schema.js +1 -0
  45. package/docs/reference/cli.md +17 -7
  46. package/docs/reference/data-and-telemetry.md +1 -0
  47. package/package.json +1 -1
  48. package/schemas/akm-config.json +44 -0
  49. package/schemas/akm-workflow.json +1 -0
  50. package/dist/commands/improve/eval-cases.js +0 -52
@@ -21,11 +21,13 @@
21
21
  */
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
+ import { hashContent } from "../core/adapter/adapters/shared.js";
24
25
  import { placementSpecList } from "../core/asset/asset-placement.js";
25
26
  import { classifyPathAccess } from "../core/path-access.js";
26
27
  import { getDbPath } from "../core/paths.js";
28
+ import { warnVerbose } from "../core/warn.js";
27
29
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
28
- import { getEntryCount, getIndexedFilePaths } from "../storage/repositories/index-entries-repository.js";
30
+ import { getEntryCount, getIndexedFileHashes, getIndexedFilePaths, } from "../storage/repositories/index-entries-repository.js";
29
31
  import { isCanonicalIndexGeneration } from "../storage/repositories/index-entry-schema.js";
30
32
  import { getMeta } from "../storage/repositories/index-meta-repository.js";
31
33
  import { warnOnBundleRenameDrift } from "./bundle-identity-guard.js";
@@ -77,11 +79,20 @@ function getIndexableFiles(root, spec) {
77
79
  * millisecond-truncated), so the mtime test alone silently misses
78
80
  * additions made within ~a millisecond of the previous build.
79
81
  *
82
+ * A file whose mtime is newer than `builtAt` is NOT automatically stale
83
+ * (R6): `indexWrittenAssets` upserts a fresh `content_hash` without bumping
84
+ * `builtAt`, so a file `ensureIndex` itself just incrementally re-indexed
85
+ * (for example a proposal triage just promoted into `knowledge/`) would
86
+ * otherwise keep tripping this check on every subsequent call, forcing the
87
+ * full rescan the write-path fast path exists to avoid. Only files newer
88
+ * than `builtAt` are hashed here, so this stays cheap — the common case is
89
+ * zero or a handful of such files.
90
+ *
80
91
  * `getIndexableFiles` applies each asset type's own relevance filter, so
81
92
  * non-indexed companion files (e.g. `package.json` next to a knowledge doc) are
82
93
  * never considered and do not produce false "new file" positives.
83
94
  */
84
- function hasNewerIndexableFiles(stashDir, builtAt, indexedPaths) {
95
+ function hasNewerIndexableFiles(stashDir, builtAt, indexedPaths, indexedHashes) {
85
96
  const builtAtMs = builtAt ? new Date(builtAt).getTime() : Number.NaN;
86
97
  const builtAtUsable = Number.isFinite(builtAtMs);
87
98
  for (const spec of placementSpecList()) {
@@ -92,13 +103,31 @@ function hasNewerIndexableFiles(stashDir, builtAt, indexedPaths) {
92
103
  return true;
93
104
  if (!builtAtUsable)
94
105
  return true;
106
+ let mtimeMs;
95
107
  try {
96
- if (fs.statSync(file).mtimeMs > builtAtMs)
97
- return true;
108
+ mtimeMs = fs.statSync(file).mtimeMs;
109
+ }
110
+ catch {
111
+ return true;
112
+ }
113
+ if (mtimeMs <= builtAtMs)
114
+ continue;
115
+ // Newer than the last full build — only stale if its current content
116
+ // actually differs from what is indexed. No stored hash means the row
117
+ // predates content-hash tracking (or a hash-less enrichment pass), so
118
+ // fall back to the conservative mtime-stale answer.
119
+ const indexedHash = indexedHashes.get(file);
120
+ if (indexedHash === undefined)
121
+ return true;
122
+ let currentHash;
123
+ try {
124
+ currentHash = hashContent(fs.readFileSync(file, "utf8"));
98
125
  }
99
126
  catch {
100
127
  return true;
101
128
  }
129
+ if (currentHash !== indexedHash)
130
+ return true;
102
131
  }
103
132
  }
104
133
  return false;
@@ -125,7 +154,7 @@ export function isIndexStale(stashDir) {
125
154
  if (entryCount === 0)
126
155
  return true;
127
156
  const builtAt = getMeta(db, "builtAt");
128
- if (hasNewerIndexableFiles(stashDir, builtAt, getIndexedFilePaths(db)))
157
+ if (hasNewerIndexableFiles(stashDir, builtAt, getIndexedFilePaths(db), getIndexedFileHashes(db)))
129
158
  return true;
130
159
  const storedStashDir = getMeta(db, "stashDir");
131
160
  if (storedStashDir !== stashDir) {
@@ -193,12 +222,24 @@ function indexCanServeStash(stashDir) {
193
222
  }
194
223
  async function runInlineReindex(stashDir, options = {}) {
195
224
  const { akmIndex } = await import("./indexer.js");
196
- await akmIndex({
225
+ const startedMs = Date.now();
226
+ const response = await akmIndex({
197
227
  stashDir,
198
228
  implicit: true,
199
229
  ...(options.signal ? { signal: options.signal } : {}),
200
230
  ...(options.hydrateSources === false ? { hydrateSources: false } : {}),
201
231
  });
232
+ // R6: the implicit reindex's cost was previously discarded entirely
233
+ // (`await akmIndex(...)` and nothing else), making a 27-minute blocking
234
+ // rebuild invisible to both the operator and the improve result. Fall back
235
+ // to a wall-clock measurement when the response carries no `timing` block.
236
+ const durationMs = response.timing?.totalMs ?? Date.now() - startedMs;
237
+ const timing = response.timing;
238
+ warnVerbose(`[ensure-index] implicit reindex completed in ${durationMs}ms` +
239
+ (timing
240
+ ? ` (walk=${timing.walkMs}ms llm=${timing.llmMs}ms embed=${timing.embedMs}ms finalize=${timing.finalizeMs}ms)`
241
+ : ""));
242
+ options.onReindexTiming?.({ durationMs, timing });
202
243
  return true;
203
244
  }
204
245
  /**
@@ -226,7 +267,10 @@ export async function ensureIndex(stashDir, options = {}) {
226
267
  // materialization point — hydrate cache-backed sources as usual.
227
268
  if (!isIndexStale(stashDir))
228
269
  return false;
229
- return runInlineReindex(stashDir, { ...(options.signal ? { signal: options.signal } : {}) });
270
+ return runInlineReindex(stashDir, {
271
+ ...(options.signal ? { signal: options.signal } : {}),
272
+ ...(options.onReindexTiming ? { onReindexTiming: options.onReindexTiming } : {}),
273
+ });
230
274
  }
231
275
  // Background = the READ path (`show` auto-index): query time must never clone/
232
276
  // pull/fetch (spec §14.3 / D11). Build from already-materialized content only;
@@ -236,5 +280,6 @@ export async function ensureIndex(stashDir, options = {}) {
236
280
  return runInlineReindex(stashDir, {
237
281
  ...(options.signal ? { signal: options.signal } : {}),
238
282
  hydrateSources: false,
283
+ ...(options.onReindexTiming ? { onReindexTiming: options.onReindexTiming } : {}),
239
284
  });
240
285
  }
@@ -128,7 +128,7 @@ function normalizeConfidence(raw) {
128
128
  return undefined;
129
129
  return Math.max(0, Math.min(1, raw));
130
130
  }
131
- function getGraphExtractorId(config) {
131
+ export function getGraphExtractorId(config) {
132
132
  const fingerprint = computeBodyHash(JSON.stringify({
133
133
  promptVersion: graphExtract.GRAPH_EXTRACT_PROMPT_VERSION,
134
134
  model: config.model,
@@ -152,6 +152,36 @@ function buildLowQualityWarnings(quality, telemetry) {
152
152
  }
153
153
  return warnings;
154
154
  }
155
+ /**
156
+ * Failure-rate abort for the extraction run (R2), modelled on consolidate's
157
+ * chunk-level guard (`ABORT_MIN_CHUNKS`/`ABORT_FAILURE_RATE` in
158
+ * consolidate.ts, C-6/#392): rate-based over a minimum sample so a couple of
159
+ * transient per-file failures cannot abort a run that would otherwise
160
+ * recover, while a systemically dead provider stops burning through the
161
+ * rest of the eligible set. The existing "one failure must not abort the
162
+ * rest" behaviour for individual files is untouched — this only stops
163
+ * further model calls once the failure rate itself is the signal.
164
+ */
165
+ const GRAPH_EXTRACTION_ABORT_MIN_ATTEMPTS = 4;
166
+ const GRAPH_EXTRACTION_ABORT_FAILURE_RATE = 0.5;
167
+ /** Records one attempted (non-cache-hit) model call and flips `aborted` once the failure-rate threshold is crossed. */
168
+ function recordGraphExtractionAttempt(state, failed) {
169
+ if (state.aborted)
170
+ return;
171
+ state.attempts += 1;
172
+ if (failed)
173
+ state.failures += 1;
174
+ if (state.attempts < GRAPH_EXTRACTION_ABORT_MIN_ATTEMPTS)
175
+ return;
176
+ const failureRate = state.failures / state.attempts;
177
+ if (failureRate < GRAPH_EXTRACTION_ABORT_FAILURE_RATE)
178
+ return;
179
+ state.aborted = true;
180
+ state.message =
181
+ `graph extraction aborted — failure rate ${(failureRate * 100).toFixed(0)}% over ${state.attempts} ` +
182
+ `attempt(s) (>= ${GRAPH_EXTRACTION_ABORT_FAILURE_RATE * 100}% threshold). LLM may be unavailable.`;
183
+ warn(state.message);
184
+ }
155
185
  export function getGraphExtractionIncludeTypes(config) {
156
186
  const configured = getIndexPassConfig(config.index, "graph")?.graphExtractionIncludeTypes;
157
187
  if (!configured || configured.length === 0)
@@ -200,6 +230,20 @@ function validateGraphCacheShape(raw) {
200
230
  ...(typeof obj.reason === "string" ? { reason: obj.reason } : {}),
201
231
  };
202
232
  }
233
+ /**
234
+ * A `"failed"` extraction (provider error, invalid JSON, context overflow —
235
+ * see {@link GraphExtractionStatus}) must never be reused as a cache hit or
236
+ * re-persisted as one. R2: a dead provider upserted ~30,900 rows shaped
237
+ * `{"entities":[],"relations":[],"status":"failed","reason":"llm_error"}`,
238
+ * and both hit paths (the `llm_enrichment_cache` lookup and `reuseGraphNode`
239
+ * over the previous graph) validated the shape without checking `status`, so
240
+ * 92% of the persisted graph became a permanent hit that never retried. A
241
+ * failed result becomes a miss naturally and is overwritten on the next
242
+ * successful extraction; existing failed rows are left on disk untouched.
243
+ */
244
+ function isFailedExtractionStatus(status) {
245
+ return status === "failed";
246
+ }
203
247
  function loadGraphFile(stashRoot, db) {
204
248
  if (!db)
205
249
  return { files: [] };
@@ -252,6 +296,8 @@ function reuseGraphNode(previousNodes, candidate, bodyHash) {
252
296
  return undefined;
253
297
  if (node.bodyHash !== bodyHash)
254
298
  return undefined;
299
+ if (isFailedExtractionStatus(node.status))
300
+ return undefined;
255
301
  const validated = validateGraphCacheShape({ entities: node.entities, relations: node.relations });
256
302
  if (!validated)
257
303
  return undefined;
@@ -276,8 +322,9 @@ function planEligibleGraphExtractions(args) {
276
322
  if (entry?.bodyHash === bodyHash) {
277
323
  try {
278
324
  const cached = validateGraphCacheShape(JSON.parse(entry.resultJson));
279
- if (cached)
325
+ if (cached && !isFailedExtractionStatus(cached.status)) {
280
326
  return { kind: "cache-hit", candidate, bodyHash, cached, persistCache: false };
327
+ }
281
328
  }
282
329
  catch {
283
330
  // Corrupt cache rows are immutable model plans for this pass.
@@ -305,7 +352,7 @@ function graphRecordFromCachePlan(plan) {
305
352
  };
306
353
  }
307
354
  async function extractGraphBatches(args) {
308
- const { plans, batchSize, signal, db, cacheVariant, telemetry, llmRunner, lease, featureConfig, onFallback, batchState, runtimeTelemetry, onNotices, reportProgress, } = args;
355
+ const { plans, batchSize, signal, db, cacheVariant, telemetry, llmRunner, lease, featureConfig, onFallback, abortState, batchState, runtimeTelemetry, onNotices, reportProgress, maxChunksPerAsset, } = args;
309
356
  const results = new Array(plans.length).fill(undefined);
310
357
  const chunkStarts = [];
311
358
  for (let start = 0; start < plans.length; start += batchSize)
@@ -328,12 +375,12 @@ async function extractGraphBatches(args) {
328
375
  continue;
329
376
  telemetry.cacheHits += 1;
330
377
  results[start + index] = graphRecordFromCachePlan(plan);
331
- if (db && plan.persistCache) {
378
+ if (db && plan.persistCache && !isFailedExtractionStatus(plan.cached.status)) {
332
379
  upsertLlmCacheEntry(db, plan.candidate.absPath, plan.bodyHash, JSON.stringify(plan.cached), cacheVariant);
333
380
  }
334
381
  }
335
382
  const modelPlans = chunk.filter((plan) => plan.kind === "model");
336
- if (modelPlans.length === 0) {
383
+ if (modelPlans.length === 0 || abortState.aborted) {
337
384
  reportChunkProgress();
338
385
  return;
339
386
  }
@@ -345,6 +392,7 @@ async function extractGraphBatches(args) {
345
392
  telemetry: runtimeTelemetry,
346
393
  onNotices,
347
394
  ...(lease ? { lease } : {}),
395
+ ...(maxChunksPerAsset != null ? { maxChunksPerAsset } : {}),
348
396
  });
349
397
  }
350
398
  catch (error) {
@@ -355,6 +403,8 @@ async function extractGraphBatches(args) {
355
403
  throw error;
356
404
  }
357
405
  let llmIndex = 0;
406
+ let dispatchHadResult = false;
407
+ let dispatchAllFailed = true;
358
408
  for (let index = 0; index < chunk.length; index++) {
359
409
  const plan = chunk[index];
360
410
  if (!plan || plan.kind !== "model")
@@ -369,7 +419,10 @@ async function extractGraphBatches(args) {
369
419
  ...(extraction.status ? { status: extraction.status } : {}),
370
420
  ...(extraction.reason ? { reason: extraction.reason } : {}),
371
421
  };
372
- if (db) {
422
+ dispatchHadResult = true;
423
+ if (!isFailedExtractionStatus(cacheShape.status))
424
+ dispatchAllFailed = false;
425
+ if (db && !isFailedExtractionStatus(cacheShape.status)) {
373
426
  upsertLlmCacheEntry(db, plan.candidate.absPath, plan.bodyHash, JSON.stringify(cacheShape), cacheVariant);
374
427
  }
375
428
  results[start + index] = {
@@ -379,6 +432,14 @@ async function extractGraphBatches(args) {
379
432
  ...cacheShape,
380
433
  };
381
434
  }
435
+ // One attempt per `extractGraphFromBodies` dispatch (this chunk's batch
436
+ // call), not one per file it covers — mirrors consolidate.ts's
437
+ // totalChunksProcessed++/totalChunksFailed, which count once per chunk
438
+ // regardless of how many memories are in it. Counting per file let a
439
+ // single batched provider_error satisfy GRAPH_EXTRACTION_ABORT_MIN_ATTEMPTS
440
+ // after one HTTP failure whenever graphExtractionBatchSize >= 4.
441
+ if (dispatchHadResult)
442
+ recordGraphExtractionAttempt(abortState, dispatchAllFailed);
382
443
  reportChunkProgress();
383
444
  }, llmRunner.connection.concurrency ?? 1);
384
445
  return { results, ...(configFailure ? { configFailure } : {}) };
@@ -670,6 +731,7 @@ export async function runGraphExtractionPass(ctx) {
670
731
  batchingDisabled: false,
671
732
  nonArrayBatchFailures: 0,
672
733
  };
734
+ const abortState = { attempts: 0, failures: 0, aborted: false };
673
735
  warnVerbose(`graph extraction: starting for ${considered} eligible file(s) under ${primary.path}; ` +
674
736
  `includeTypes=${includeTypes.join(",")}, batchSize=${batchSize}, concurrency=${llmRunner.connection.concurrency ?? 1}, ` +
675
737
  `reEnrich=${reEnrich === true}, candidateScoped=${options.candidatePaths ? "true" : "false"}.`);
@@ -690,11 +752,15 @@ export async function runGraphExtractionPass(ctx) {
690
752
  if (plan.kind === "cache-hit") {
691
753
  telemetry.cacheHits += 1;
692
754
  cached = plan.cached;
693
- if (db && plan.persistCache) {
755
+ if (db && plan.persistCache && !isFailedExtractionStatus(cached.status)) {
694
756
  upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(cached), cacheVariant);
695
757
  }
696
758
  }
697
759
  else {
760
+ if (abortState.aborted) {
761
+ reportProgress(candidate.absPath, undefined);
762
+ return undefined;
763
+ }
698
764
  telemetry.cacheMisses += 1;
699
765
  let extraction;
700
766
  try {
@@ -703,6 +769,7 @@ export async function runGraphExtractionPass(ctx) {
703
769
  telemetry: runtimeTelemetry,
704
770
  onNotices,
705
771
  ...(dispatchLease ? { lease: dispatchLease } : {}),
772
+ ...(options.maxChunksPerAsset != null ? { maxChunksPerAsset: options.maxChunksPerAsset } : {}),
706
773
  });
707
774
  }
708
775
  catch (err) {
@@ -719,7 +786,8 @@ export async function runGraphExtractionPass(ctx) {
719
786
  ...(extraction.status ? { status: extraction.status } : {}),
720
787
  ...(extraction.reason ? { reason: extraction.reason } : {}),
721
788
  };
722
- if (db) {
789
+ recordGraphExtractionAttempt(abortState, isFailedExtractionStatus(cached.status));
790
+ if (db && !isFailedExtractionStatus(cached.status)) {
723
791
  upsertLlmCacheEntry(db, candidate.absPath, bodyHash, JSON.stringify(cached), cacheVariant);
724
792
  }
725
793
  }
@@ -754,8 +822,10 @@ export async function runGraphExtractionPass(ctx) {
754
822
  onFallback,
755
823
  batchState,
756
824
  runtimeTelemetry,
825
+ abortState,
757
826
  onNotices,
758
827
  reportProgress,
828
+ ...(options.maxChunksPerAsset != null ? { maxChunksPerAsset: options.maxChunksPerAsset } : {}),
759
829
  });
760
830
  extractionResults = batch.results;
761
831
  configFailure ??= batch.configFailure;
@@ -797,14 +867,18 @@ export async function runGraphExtractionPass(ctx) {
797
867
  const assetRefs = mergedNodes.map((node) => node.path);
798
868
  const deduped = deduplicateGraph(mergedNodes.map((node) => ({ entities: node.entities, relations: node.relations })), assetRefs);
799
869
  telemetry.truncationCount = runtimeTelemetry.truncationCount ?? 0;
870
+ telemetry.truncatedChunks = runtimeTelemetry.truncatedChunks ?? 0;
800
871
  telemetry.failureCount = runtimeTelemetry.failureCount ?? 0;
801
872
  telemetry.htmlErrorCount = runtimeTelemetry.htmlErrorCount ?? 0;
802
873
  telemetry.retryAttempts = runtimeTelemetry.retryAttempts ?? 0;
803
874
  telemetry.nonArrayBatchFailures = runtimeTelemetry.nonArrayBatchFailures ?? 0;
875
+ telemetry.aborted = abortState.aborted;
804
876
  const qualityConsidered = mergedNodes.length;
805
877
  const qualityExtracted = mergedNodes.filter((node) => node.status === "extracted" && node.entities.length > 0).length;
806
878
  const quality = computeGraphQualityTelemetry(qualityConsidered, qualityExtracted, deduped.entities.length, deduped.relations.length);
807
879
  const warnings = buildLowQualityWarnings(quality, telemetry);
880
+ if (abortState.message)
881
+ warnings.push(abortState.message);
808
882
  for (const warning of warnings)
809
883
  warnVerbose(`graph extraction quality: ${warning}`);
810
884
  const graph = {
@@ -46,7 +46,7 @@ import { todayIso } from "../../core/common.js";
46
46
  import { concurrentMap } from "../../core/concurrent.js";
47
47
  import { ConfigError } from "../../core/errors.js";
48
48
  import { warn } from "../../core/warn.js";
49
- import { recordWrittenPath } from "../../core/write-provenance.js";
49
+ import { beginWriteProvenance, recordWrittenPath } from "../../core/write-provenance.js";
50
50
  import { writeAssetToSource } from "../../core/write-source.js";
51
51
  import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
52
52
  import { isProcessEnabled } from "../../llm/feature-gate.js";
@@ -188,6 +188,18 @@ async function inferPendingMemoryRecord(plan, ctx) {
188
188
  * short-circuits to a no-op result.
189
189
  */
190
190
  export async function runMemoryInferencePass(ctx) {
191
+ // R78 (tier1-0917): owns the write-provenance journal end-to-end so it closes on every
192
+ // exit path, including a throw out of the body below — an unclosed journal
193
+ // would keep reporting every later write in this process as this call's own.
194
+ const provenance = beginWriteProvenance();
195
+ try {
196
+ return await runMemoryInferencePassBody(ctx, provenance);
197
+ }
198
+ finally {
199
+ provenance.end();
200
+ }
201
+ }
202
+ async function runMemoryInferencePassBody(ctx, provenance) {
191
203
  const { config, sources, signal, db, reEnrich, onProgress, options = {} } = ctx;
192
204
  const invocationOwnsRunner = Object.hasOwn(ctx, "llmRunner");
193
205
  const compressMemoryToDerivedMemory = options.compressMemoryToDerivedMemory ?? memoryInfer.compressMemoryToDerivedMemory;
@@ -202,6 +214,7 @@ export async function runMemoryInferencePass(ctx) {
202
214
  skippedAborted: 0,
203
215
  unaccounted: 0,
204
216
  htmlErrorCount: 0,
217
+ writtenPaths: [],
205
218
  };
206
219
  // Mutable sink threaded into compressMemoryToDerivedMemory so the per-call
207
220
  // HTML-error categorization (which is otherwise swallowed inside the feature
@@ -215,6 +228,8 @@ export async function runMemoryInferencePass(ctx) {
215
228
  const completeResult = () => {
216
229
  if (noticesByKey.size > 0)
217
230
  result.notices = Object.freeze([...noticesByKey.values()]);
231
+ // Non-destructive read — the wrapper's `finally` owns closing the journal.
232
+ result.writtenPaths = provenance.writtenPaths();
218
233
  return result;
219
234
  };
220
235
  // Gate 1 — feature gate via isProcessEnabled, which reads the 0.8.0 path
@@ -117,12 +117,24 @@ export function isContextSizeError(message) {
117
117
  /exceeded|over.*limit|too.*long/.test(lower);
118
118
  return evidence;
119
119
  }
120
+ /**
121
+ * Codes describing a failure to reach or get a usable response from the
122
+ * provider transport itself, as opposed to a malformed-but-received response
123
+ * (`parse_error`) or a request-shape rejection (`rate_limited`). Shared by
124
+ * {@link isRetryable} (which additionally requires evidence the failure is
125
+ * transient) and the batch graph-extraction storm guard in `graph-extract.ts`
126
+ * (which treats any of these as "the provider is down, stop retrying
127
+ * per-asset") so the two classifications cannot drift apart.
128
+ */
129
+ export function isTransportFailure(err) {
130
+ return err.code === "provider_error" || err.code === "network_error" || err.code === "provider_html_error";
131
+ }
120
132
  /**
121
133
  * Decide whether a first-attempt {@link LlmCallError} is eligible for a single
122
134
  * retry. Retryable: HTTP 5xx (`provider_error` with statusCode >= 500) and
123
135
  * `network_error` whose message looks like a transient connection drop.
124
- * NOT retryable: 4xx, `rate_limited` (429), `timeout`, `parse_error`, and
125
- * context-overflow-classified errors.
136
+ * NOT retryable: 4xx, `rate_limited` (429), `timeout`, `parse_error`,
137
+ * `provider_html_error`, and context-overflow-classified errors.
126
138
  *
127
139
  * The connection-drop heuristic covers the substrings emitted across runtimes
128
140
  * for a mid-flight socket close:
@@ -141,6 +153,8 @@ export function isContextSizeError(message) {
141
153
  function isRetryable(err) {
142
154
  if (isContextSizeError(err.message))
143
155
  return false;
156
+ if (!isTransportFailure(err))
157
+ return false;
144
158
  if (err.code === "provider_error") {
145
159
  return typeof err.statusCode === "number" && err.statusCode >= 500;
146
160
  }