akm-cli 0.9.16 → 0.9.17-alpha.1

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 (46) hide show
  1. package/CHANGELOG.md +478 -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/core/asset/frontmatter.js +106 -1
  27. package/dist/core/config/schema/improve-processes.js +29 -2
  28. package/dist/core/improve-result.js +9 -0
  29. package/dist/core/paths.js +7 -0
  30. package/dist/indexer/ensure-index.js +52 -7
  31. package/dist/indexer/graph/graph-extraction.js +82 -8
  32. package/dist/indexer/passes/memory-inference.js +16 -1
  33. package/dist/llm/client.js +16 -2
  34. package/dist/llm/graph-extract.js +162 -18
  35. package/dist/scripts/akm-migrate-node.js +20 -4
  36. package/dist/scripts/akm-migrate.js +20 -4
  37. package/dist/storage/repositories/index-entries-repository.js +43 -0
  38. package/dist/storage/repositories/proposals-repository.js +4 -1
  39. package/dist/storage/state-db-integrity.js +123 -0
  40. package/dist/workflows/program/schema.js +1 -0
  41. package/docs/reference/cli.md +4 -3
  42. package/docs/reference/data-and-telemetry.md +1 -0
  43. package/package.json +1 -1
  44. package/schemas/akm-config.json +44 -0
  45. package/schemas/akm-workflow.json +1 -0
  46. package/dist/commands/improve/eval-cases.js +0 -52
@@ -7,12 +7,13 @@ import { parseRefInput } from "../../core/asset/resolve-ref.js";
7
7
  import { daysToMs } from "../../core/common.js";
8
8
  import { DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE, loadConfig } from "../../core/config/config.js";
9
9
  import { UsageError } from "../../core/errors.js";
10
- import { appendEvent } from "../../core/events.js";
10
+ import { appendEvent, readEvents } from "../../core/events.js";
11
11
  import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
12
12
  import { getDbPath, getTaskLogDir } from "../../core/paths.js";
13
13
  import { withStateDb } from "../../core/state-db.js";
14
14
  import { info } from "../../core/warn.js";
15
15
  import { DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES, runGraphExtractionPass, } from "../../indexer/graph/graph-extraction.js";
16
+ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
16
17
  import { deriveWritableBundleIds } from "../../indexer/installations.js";
17
18
  import { collectPendingMemories, runMemoryInferencePass, } from "../../indexer/passes/memory-inference.js";
18
19
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
@@ -22,21 +23,23 @@ import { purgeOldCycleMetrics } from "../../storage/repositories/canaries-reposi
22
23
  import { purgeOldEvents } from "../../storage/repositories/events-repository.js";
23
24
  import { purgeOldImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
24
25
  import { closeDatabase, openIndexDatabase } from "../../storage/repositories/index-connection.js";
25
- import { getEntryByRef } from "../../storage/repositories/index-entries-repository.js";
26
+ import { getLiveRefSnapshot, isRefLiveInSnapshot, } from "../../storage/repositories/index-entries-repository.js";
26
27
  import { clearAssetOutcomeMissing, countAssetOutcomeMissing, deleteAssetOutcomeMissingBefore, listAssetOutcomeMissingState, stampAssetOutcomeMissing, } from "../../storage/repositories/outcome-repository.js";
27
28
  import { clearAssetSalienceMissing, countAssetSalienceMissing, deleteAssetSalienceMissingBefore, listAssetSalienceMissingState, stampAssetSalienceMissing, } from "../../storage/repositories/salience-repository.js";
29
+ import { readFreelistInfo, vacuumStateDbIfReclaimable } from "../../storage/state-db-integrity.js";
28
30
  import { purgeOldTaskLogFiles } from "../../tasks/run/task-log.js";
29
- import { expireStaleProposals, listProposals, purgeOrphanProposals } from "../proposal/repository.js";
31
+ import { checkProposalGuard, expireStaleProposals, listProposals, purgeOrphanProposals } from "../proposal/repository.js";
30
32
  import { checkDeadUrls } from "../url-checker.js";
31
33
  import { DEFAULT_RETENTION_DAYS as CYCLE_METRICS_RETENTION_DAYS, runCollapseDetector } from "./collapse-detector.js";
32
- import { deriveLessonRef } from "./distill.js";
34
+ import { defaultLookup, deriveLessonRef } from "./distill.js";
35
+ import { wouldPromoteMemoryToKnowledge } from "./distill/promote-memory.js";
33
36
  import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
34
37
  // Eligibility / candidate-selection predicates live in ./eligibility.
35
38
  import { findAssetFilePath, isDistillCandidateRef } from "./eligibility.js";
36
- import { writeEvalCase } from "./eval-cases.js";
37
39
  import { shouldSkipRef } from "./improve-strategies.js";
40
+ import { readOnlyEventsContext } from "./reflect.js";
38
41
  import { recordNoOp, resetConsecutiveNoOps } from "./salience.js";
39
- import { errMessage, refSlug } from "./shared.js";
42
+ import { errMessage } from "./shared.js";
40
43
  import { bareImproveRef, durableImproveRef } from "./source-identity.js";
41
44
  // ── improve loop / post-loop / maintenance stages ───────────────────
42
45
  // The cycle stages run by akmImprove, extracted from improve.ts.
@@ -179,6 +182,8 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
179
182
  // O-1 (#364): pass remaining budget as timeoutMs so the agent spawn is
180
183
  // bounded by the wall-clock deadline rather than the default per-profile timeout.
181
184
  const reflectBudgetMs = env.remainingBudgetMs();
185
+ const reflectEngine = resolvedPlan.processes.reflect.runner?.engine;
186
+ const reflectTarget = options.sourceName && primaryStashDir ? { source: options.sourceName, root: primaryStashDir } : undefined;
182
187
  // Re-enter canonical named-engine lowering with the config snapshot and
183
188
  // process profile frozen into the invocation plan. The loop never injects
184
189
  // a RunnerSpec seam or observes later caller-config mutations.
@@ -192,9 +197,7 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
192
197
  ...(improveProfile ? { improveProfile } : {}),
193
198
  config: resolvedPlan.config,
194
199
  ...(primaryStashDir ? { stashDir: primaryStashDir } : {}),
195
- ...(options.sourceName && primaryStashDir
196
- ? { target: { source: options.sourceName, root: primaryStashDir } }
197
- : {}),
200
+ ...(reflectTarget ? { target: reflectTarget } : {}),
198
201
  ...(reflectErrors.length > 0 ? { avoidPatterns: [...reflectErrors] } : {}),
199
202
  eventSource: "improve",
200
203
  // #639 — resolve the low-value filter from the ACTIVE improve profile
@@ -208,10 +211,73 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
208
211
  // the reflect_invoked event and the persisted proposal.
209
212
  ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
210
213
  };
211
- const reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs), {
212
- engine: resolvedPlan.processes.reflect.runner?.engine,
213
- process: "reflect",
214
- });
214
+ // R9 (tier2-0917): the fingerprint/rejection-backoff guard `createProposal`
215
+ // runs AFTER reflect's ~39s generation + judge is computable from inputs
216
+ // available before dispatch. Check it here first — on a hit, skip the LLM
217
+ // call entirely and synthesize the same "cooldown" envelope reflect.ts's
218
+ // createProposal-skip branch returns, so everything below (mode
219
+ // classification, improve_reflect_outcome, plasticity) is unchanged. This
220
+ // pre-check is an optimisation only — createProposal's post-generation
221
+ // check stays authoritative (see checkProposalGuard's doc comment).
222
+ const guardStash = primaryStashDir ?? options.stashDir;
223
+ const guardSkip = guardStash
224
+ ? checkProposalGuard({
225
+ stash: guardStash,
226
+ ref: planned.ref,
227
+ source: "reflect",
228
+ ...(reflectTarget ? { target: reflectTarget } : {}),
229
+ ...(reflectEngine ? { modelId: reflectEngine } : {}),
230
+ })
231
+ : undefined;
232
+ let reflectResult;
233
+ if (guardSkip) {
234
+ // Mirror reflect.ts's buildReflectEventEmitters().emitInvoked(): the
235
+ // signal-delta cursor (buildLatestProposalTsMap) reads `reflect_invoked`
236
+ // events regardless of outcome, so it must still advance for this ref
237
+ // even though reflectFn was never called.
238
+ appendEvent({
239
+ eventType: "reflect_invoked",
240
+ ref: planned.itemRef ?? durableImproveRef(planned.ref),
241
+ metadata: {
242
+ ...(options.task ? { task: options.task } : {}),
243
+ ...(reflectEngine ? { engine: reflectEngine } : {}),
244
+ ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
245
+ },
246
+ }, eventsCtx);
247
+ // Mirror reflect.ts's buildReflectEventEmitters().emitFailed(): every
248
+ // reflect_invoked must be paired with a reflect_completed so observers
249
+ // building closed-loop telemetry see balanced invoke/complete pairs.
250
+ // reflectFn is never called on this path, so reflect.ts's own
251
+ // emitFailed (fired from its post-generation cooldown branch) never
252
+ // runs either — this is the pre-generation guard's own pairing.
253
+ appendEvent({
254
+ eventType: "reflect_completed",
255
+ ref: planned.itemRef ?? durableImproveRef(planned.ref),
256
+ metadata: {
257
+ source: "reflect",
258
+ ok: false,
259
+ reason: "cooldown",
260
+ subreason: "pre_generation_guard",
261
+ proposalSkipReason: guardSkip.reason,
262
+ ...(guardSkip.existingProposalId ? { existingProposalId: guardSkip.existingProposalId } : {}),
263
+ },
264
+ }, eventsCtx);
265
+ reflectResult = {
266
+ schemaVersion: 2,
267
+ ok: false,
268
+ reason: "cooldown",
269
+ error: `Proposal skipped (${guardSkip.reason}): ${guardSkip.message}`,
270
+ ref: planned.ref,
271
+ ...(reflectEngine ? { engine: reflectEngine } : {}),
272
+ exitCode: null,
273
+ };
274
+ }
275
+ else {
276
+ reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs), {
277
+ engine: reflectEngine,
278
+ process: "reflect",
279
+ });
280
+ }
215
281
  const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
216
282
  // Content-policy guard hits (reflect size-rail rejections) are NOT
217
283
  // LLM faults — the agent responded fine, the downstream guard
@@ -233,6 +299,13 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
233
299
  // fault — so it routes to the `reflect-skipped` bucket and stays
234
300
  // out of recentErrors/avoidPatterns.
235
301
  const isNoChange = !reflectResult.ok && reflectResult.reason === "no_change";
302
+ // Quality-gate rejection (R3): the judge rejected an otherwise
303
+ // well-parsed proposal. Stays in the `reflect-failed` bucket for
304
+ // metrics continuity, but — like the deterministic skips above —
305
+ // must not be injected into recentErrors/avoidPatterns: the judge's
306
+ // rejection text is not a reusable "avoid this pattern" lesson, and
307
+ // feeding it back in poisoned later prompts in the same run.
308
+ const isQualityRejected = !reflectResult.ok && reflectResult.reason === "quality_rejected";
236
309
  tally.actions.push({
237
310
  ref: planned.ref,
238
311
  mode: reflectResult.ok
@@ -246,14 +319,15 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
246
319
  : "reflect-failed",
247
320
  result: reflectResult,
248
321
  });
249
- // Cooldown skips, guard rejects, type-refused skips, and noise-gate
250
- // skips are not failures — do not pollute recentErrors with them
251
- // (those get injected as `avoidPatterns` into the next reflect
252
- // prompt). Guard rejects ARE worth showing the LLM as a learn-signal
253
- // so the next iteration sees "your last expansion was too large";
254
- // type-refused and no-change are deterministic and add no learning
322
+ // Cooldown skips, guard rejects, type-refused skips, noise-gate
323
+ // skips, and quality-gate rejections are not failures — do not
324
+ // pollute recentErrors with them (those get injected as
325
+ // `avoidPatterns` into the next reflect prompt). Guard rejects ARE
326
+ // worth showing the LLM as a learn-signal so the next iteration sees
327
+ // "your last expansion was too large"; type-refused, no-change, and
328
+ // quality-rejected are deterministic/judge-side and add no learning
255
329
  // signal.
256
- if (!reflectResult.ok && !isCooldown && !isTypeRefused && !isNoChange) {
330
+ if (!reflectResult.ok && !isCooldown && !isTypeRefused && !isNoChange && !isQualityRejected) {
257
331
  const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
258
332
  tally.recentErrorPushes.push({ originator: "reflect", message: errMsg });
259
333
  }
@@ -315,7 +389,7 @@ async function runLoopReflectPass(planned, isDistillOnly, env, tally) {
315
389
  * that was a `continue` in the old inline loop body is an early `return` here.
316
390
  */
317
391
  async function runLoopDistillPass(planned, parsedPlannedRef, isDistillOnly, env, tally) {
318
- const { options, primaryStashDir, eventsCtx, improveProfile } = env;
392
+ const { options, primaryStashDir, eventsCtx, improveProfile, resolvedPlan } = env;
319
393
  const hasRecentFeedbackSignal = env.signalBearingSet.has(planned.ref);
320
394
  const explicitRefScope = env.scope.mode === "ref";
321
395
  // Profile gate: apply the full type-filter / raw-wiki / disabled rules to
@@ -405,6 +479,97 @@ async function runLoopDistillPass(planned, parsedPlannedRef, isDistillOnly, env,
405
479
  return;
406
480
  }
407
481
  }
482
+ // R9 extension (r2-6, tier2-0917; PRECHECK, tier3-0917): the
483
+ // fingerprint/rejection-backoff guard `createProposal` runs AFTER
484
+ // distill's ~generation + judge is computable from inputs available
485
+ // before dispatch — mirror the reflect pre-check above so a guard hit
486
+ // skips the LLM call entirely. Distill's real `createProposal` call
487
+ // always targets the derived lesson/knowledge ref (`effectiveLessonRef`
488
+ // in distill.ts), never the input ref.
489
+ // Which ref that is: for every non-memory distill-candidate type,
490
+ // `targetKind` defaults to "lesson" (distill.ts ~L882, `invokeDistill
491
+ // AndRecord` above only ever sets `proposalKind: "auto"` for memory
492
+ // refs) and is never overridden to "knowledge", so lessonRef is the
493
+ // ONLY real target. For memory refs (`proposalKind: "auto"`), the
494
+ // target is decided at dispatch by `planMemoryKnowledgePromotion`
495
+ // (knowledgeRef via promotion, lessonRef as fallback) — that decision
496
+ // IS cheap and LLM-free (a deterministic score over the asset content
497
+ // + its feedback history, plus one lookup for an existing knowledge
498
+ // file), so it is pre-checked exactly via `wouldPromoteMemoryToKnowledge`,
499
+ // a thin wrapper that delegates to `planMemoryKnowledgePromotion`
500
+ // itself so this can never drift from distill's real decision. A
501
+ // guard hit on the ref distill would NOT have targeted must never
502
+ // suppress a legitimate dispatch.
503
+ // §23.6 fingerprint model-id term: distill resolves models, not
504
+ // engines (unlike reflect), so this must match `distillRunner?.
505
+ // connection.model` in distill.ts, not the engine name.
506
+ const distillModelId = resolvedPlan.processes.distill.runner?.connection.model;
507
+ let realTargetRef = lessonRef;
508
+ if (parsedPlannedRef.type === "memory") {
509
+ // distill.ts's real dispatch (akmDistill) always derives
510
+ // durableInputRef from options.ref alone (durableImproveRef(inputRef),
511
+ // never itemRef) and reads/scores content via that ref
512
+ // (loadAndScoreInputSalience's `lookup(durableInputRef)`); mirror
513
+ // that here so the pre-check can never read/score a different file
514
+ // than the real dispatch would. itemRef is preferred only for the
515
+ // feedback-events query, matching readDistillFeedback's
516
+ // `ref: options.itemRef ?? durableInputRef`.
517
+ const durableInputRef = durableImproveRef(planned.ref);
518
+ const feedbackRef = planned.itemRef ?? durableInputRef;
519
+ const lookup = (ref) => defaultLookup(ref, dedupeStashDir);
520
+ const filePath = await lookup(durableInputRef);
521
+ const assetContent = filePath && fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
522
+ // PRECHECK (tier3-0917-r3, r3-4): reuse the loop's long-lived
523
+ // eventsCtx.db handle when one is open, instead of opening a fresh
524
+ // read-only state.db connection per memory ref (R25). Degrades to
525
+ // the previous readOnly-open when no live handle is present (e.g.
526
+ // this function invoked without a run-scoped eventsCtx), via the
527
+ // same readOnlyEventsContext helper reflect.ts's read call sites use.
528
+ const { events: feedbackEvents } = readEvents({ ref: feedbackRef, type: "feedback" }, readOnlyEventsContext(eventsCtx));
529
+ const promotesToKnowledge = await wouldPromoteMemoryToKnowledge({
530
+ inputRef: planned.ref,
531
+ durableInputRef,
532
+ assetContent,
533
+ feedbackEvents,
534
+ config: options.config ?? loadConfig(),
535
+ stash: dedupeStashDir,
536
+ lookup,
537
+ });
538
+ if (promotesToKnowledge)
539
+ realTargetRef = knowledgeRef;
540
+ }
541
+ const guardSkip = checkProposalGuard({
542
+ stash: dedupeStashDir,
543
+ ref: realTargetRef,
544
+ source: "distill",
545
+ ...(distillModelId ? { modelId: distillModelId } : {}),
546
+ });
547
+ if (guardSkip) {
548
+ tally.actions.push({
549
+ ref: planned.ref,
550
+ mode: "distill-skipped",
551
+ result: { ok: true, reason: guardSkip.reason },
552
+ });
553
+ // Mirror distill.ts's own proposal-skip branch (the post-generation
554
+ // guard `createProposal` hits): emit `distill_invoked` with a
555
+ // `skipped` outcome so the signal-delta cursor
556
+ // (buildLatestProposalTsMap, eligibility.ts) advances for this ref
557
+ // even though distillFn was never called.
558
+ appendEvent({
559
+ eventType: "distill_invoked",
560
+ // Use item_ref when resolved, otherwise the input conceptId —
561
+ // matches distill.ts's own distill_invoked key.
562
+ ref: planned.itemRef ?? durableImproveRef(planned.ref),
563
+ metadata: {
564
+ outcome: "skipped",
565
+ proposalRef: realTargetRef,
566
+ message: guardSkip.message,
567
+ skipReason: guardSkip.reason,
568
+ ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
569
+ },
570
+ }, eventsCtx);
571
+ return;
572
+ }
408
573
  }
409
574
  await invokeDistillAndRecord(planned, parsedPlannedRef, env, tally);
410
575
  }
@@ -423,8 +588,7 @@ async function runLoopDistillPass(planned, parsedPlannedRef, isDistillOnly, env,
423
588
  }
424
589
  /**
425
590
  * The distill invocation for one ref that passed every gate: the `distillFn`
426
- * call, memory-inference queueing, plasticity counters, and the
427
- * quality-rejected / proposal-rejected eval-case writes.
591
+ * call, memory-inference queueing, and plasticity counters.
428
592
  */
429
593
  async function invokeDistillAndRecord(planned, parsedPlannedRef, env, tally) {
430
594
  const { options, primaryStashDir, distillFn, eventsCtx, improveProfile, resolvedPlan, budgetSignal } = env;
@@ -470,30 +634,6 @@ async function invokeDistillAndRecord(planned, parsedPlannedRef, env, tally) {
470
634
  // best-effort: plasticity counter failure never blocks the run
471
635
  }
472
636
  }
473
- if (distillResult.outcome === "quality_rejected" && primaryStashDir) {
474
- const slug = refSlug(planned.ref);
475
- writeEvalCase(primaryStashDir, {
476
- ref: planned.ref,
477
- failureReason: distillResult.reason ?? "quality gate rejected",
478
- assetType: parseRefInput(planned.ref).type ?? "unknown",
479
- rejectedAt: Date.now(),
480
- source: "distill_quality_rejected",
481
- slug: `${slug}-${Date.now()}`,
482
- });
483
- }
484
- // D6: use pre-loaded map instead of per-iteration DB query
485
- const rejectedProposalEvent = env.rejectedProposalsByRef.get(planned.ref);
486
- if (rejectedProposalEvent && primaryStashDir) {
487
- const slug = refSlug(planned.ref);
488
- writeEvalCase(primaryStashDir, {
489
- ref: planned.ref,
490
- failureReason: rejectedProposalEvent.metadata?.reason ?? "proposal rejected",
491
- assetType: parseRefInput(planned.ref).type ?? "unknown",
492
- rejectedAt: new Date(rejectedProposalEvent.ts).getTime(),
493
- source: "proposal_rejected",
494
- slug: `${slug}-rejected`,
495
- });
496
- }
497
637
  }
498
638
  /**
499
639
  * Wall-clock budget exhausted mid-loop (O-1 / #364): emit the improve_skipped
@@ -552,7 +692,7 @@ export async function runImproveLoopStage(args) {
552
692
  return { reflectsWithErrorContext, memoryRefsForInference };
553
693
  }
554
694
  export async function runImprovePostLoopStage(args) {
555
- const { scope, options, primaryStashDir, actionableRefs, appliedCleanup, cleanupWarnings, memoryRefsForInference, reindexFn, eventsCtx, budgetSignal, improveProfile, resolvedPlan, consolidationRan, } = args;
695
+ const { scope, options, primaryStashDir, actionableRefs, appliedCleanup, cleanupWarnings, memoryRefsForInference, eventsCtx, budgetSignal, improveProfile, resolvedPlan, consolidationRan, } = args;
556
696
  const allWarnings = [...cleanupWarnings, ...(appliedCleanup?.warnings ?? [])];
557
697
  info("[improve] post-loop maintenance starting");
558
698
  const maintenanceResult = await runImproveMaintenancePasses({
@@ -561,8 +701,6 @@ export async function runImprovePostLoopStage(args) {
561
701
  actionableRefs,
562
702
  memoryRefsForInference,
563
703
  allWarnings,
564
- reindexFn,
565
- consolidationRan,
566
704
  // O-1 (#364): forward the budget signal to memory inference + graph extraction.
567
705
  budgetSignal,
568
706
  eventsCtx,
@@ -617,8 +755,7 @@ export async function runImprovePostLoopStage(args) {
617
755
  }
618
756
  }
619
757
  // ── R5: collapse/churn detector ────────────────────────────────────────────
620
- // One snapshot per QUALIFYING cycle: consolidate processed work. Runs AFTER
621
- // the maintenance reindex so FTS sees the post-merge index. Deterministic,
758
+ // One snapshot per QUALIFYING cycle: consolidate processed work. Deterministic,
622
759
  // observe-only, fail-open (the orchestrator catches everything) — and inert
623
760
  // on the ~9-in-10 default-profile runs that touch no merges.
624
761
  let cycleMetrics;
@@ -651,7 +788,7 @@ export async function runImprovePostLoopStage(args) {
651
788
  // Exported for tests (#584/#585 DB-locking regression coverage); production
652
789
  // callers reach it only through akmImprove → runImprovePostLoopStage.
653
790
  export async function runImproveMaintenancePasses(args) {
654
- const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, budgetSignal, eventsCtx } = args;
791
+ const { options, primaryStashDir, memoryRefsForInference, allWarnings, budgetSignal, eventsCtx } = args;
655
792
  if (!primaryStashDir)
656
793
  return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
657
794
  if (budgetSignal?.aborted)
@@ -662,21 +799,6 @@ export async function runImproveMaintenancePasses(args) {
662
799
  const graphExtractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
663
800
  const openIndexDb = () => openIndexDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
664
801
  const dbCell = {};
665
- // #584: see the MaintenanceCtx.reindexWithIndexDbReleased doc — close before
666
- // every reindex, reopen in `finally` so a failed reindex still leaves a
667
- // usable handle in the cell.
668
- const reindexWithIndexDbReleased = async (stashDir) => {
669
- if (dbCell.current) {
670
- closeDatabase(dbCell.current);
671
- dbCell.current = undefined;
672
- }
673
- try {
674
- await reindexFn({ stashDir, signal: budgetSignal });
675
- }
676
- finally {
677
- dbCell.current = openIndexDb();
678
- }
679
- };
680
802
  const ctx = {
681
803
  config,
682
804
  sources,
@@ -687,12 +809,10 @@ export async function runImproveMaintenancePasses(args) {
687
809
  resolvedPlan: args.resolvedPlan,
688
810
  memoryInferenceFn,
689
811
  graphExtractionFn,
690
- reindexWithIndexDbReleased,
691
812
  };
692
813
  const collected = await runMaintenancePassesUnderLease(ctx, dbCell, {
693
814
  actionableRefs: args.actionableRefs,
694
815
  memoryRefsForInference,
695
- consolidationRan: args.consolidationRan,
696
816
  allWarnings,
697
817
  openIndexDb,
698
818
  });
@@ -717,7 +837,6 @@ export async function runImproveMaintenancePasses(args) {
717
837
  async function runMaintenancePassesUnderLease(ctx, dbCell, args) {
718
838
  const { allWarnings } = args;
719
839
  const actions = [];
720
- let reindexedAfterInference = false;
721
840
  try {
722
841
  dbCell.current = args.openIndexDb();
723
842
  const inference = await runMemoryInferenceMaintenancePass(ctx, dbCell, args.memoryRefsForInference);
@@ -725,22 +844,34 @@ async function runMaintenancePassesUnderLease(ctx, dbCell, args) {
725
844
  actions.push(inference.action);
726
845
  allWarnings.push(...inference.warnings);
727
846
  const memoryInference = inference.memoryInference;
728
- if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
729
- info("[improve] reindexing after memory inference writes");
847
+ // R78 (tier1-0917): index exactly the files memory inference wrote (derived children
848
+ // + rewritten parents) instead of a full reindex — typically one written
849
+ // fact per run, which used to pay a full-corpus reindex regardless.
850
+ if (memoryInference && memoryInference.writtenPaths.length > 0) {
851
+ info(`[improve] indexing ${memoryInference.writtenPaths.length} file(s) written by memory inference`);
730
852
  try {
731
- await ctx.reindexWithIndexDbReleased(ctx.primaryStashDir);
732
- reindexedAfterInference = true;
733
- info("[improve] reindex after memory inference complete");
853
+ // #584: indexWrittenAssets opens its own write handle on the same
854
+ // index.db WAL file, so the maintenance handle must be closed first
855
+ // and a fresh one reopened after, even on failure.
856
+ if (dbCell.current) {
857
+ closeDatabase(dbCell.current);
858
+ dbCell.current = undefined;
859
+ }
860
+ try {
861
+ await indexWrittenAssets(ctx.primaryStashDir, memoryInference.writtenPaths);
862
+ }
863
+ finally {
864
+ dbCell.current = args.openIndexDb();
865
+ }
866
+ info("[improve] indexing after memory inference complete");
734
867
  }
735
868
  catch (err) {
736
- allWarnings.push(`reindex after memory inference failed: ${errMessage(err)}`);
869
+ allWarnings.push(`indexing after memory inference failed: ${errMessage(err)}`);
737
870
  }
738
871
  }
739
872
  const graph = await runGraphExtractionMaintenancePass(ctx, dbCell, {
740
873
  actionableRefs: args.actionableRefs,
741
874
  memoryRefsForInference: args.memoryRefsForInference,
742
- consolidationRan: args.consolidationRan,
743
- reindexedAfterInference,
744
875
  });
745
876
  if (graph.action)
746
877
  actions.push(graph.action);
@@ -868,7 +999,6 @@ export async function runGraphExtractionMaintenancePass(ctx, dbCell, args) {
868
999
  let graphExtraction;
869
1000
  let durationMs = 0;
870
1001
  let action;
871
- let reindexedAfterInference = args.reindexedAfterInference;
872
1002
  const graphEnabled = resolvedPlan ? true : isProcessEnabled("index", "graph_extraction", config);
873
1003
  const graphExtractionDisabledByProfile = improveProfile?.processes?.graphExtraction?.enabled === false;
874
1004
  const graphExtractionFullScan = improveProfile?.processes?.graphExtraction?.fullScan === true;
@@ -879,6 +1009,7 @@ export async function runGraphExtractionMaintenancePass(ctx, dbCell, args) {
879
1009
  ...DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES,
880
1010
  ];
881
1011
  const graphExtractionBatchSize = improveProfile?.processes?.graphExtraction?.batchSize ?? DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE;
1012
+ const graphExtractionMaxChunksPerAsset = improveProfile?.processes?.graphExtraction?.maxChunksPerAsset;
882
1013
  // Build the set of refs actually touched this run.
883
1014
  const touchedRefs = new Set();
884
1015
  for (const r of args.actionableRefs)
@@ -892,21 +1023,6 @@ export async function runGraphExtractionMaintenancePass(ctx, dbCell, args) {
892
1023
  info(`[improve] graph extraction starting${graphExtractionFullScan ? " (full-corpus scan)" : ""}`);
893
1024
  const extractionStart = Date.now();
894
1025
  try {
895
- // D9: if consolidation ran but memory inference did not reindex, force a reindex
896
- // so graph extraction sees current DB state after consolidation writes.
897
- if (args.consolidationRan && !reindexedAfterInference) {
898
- info("[improve] reindexing after consolidation (graph extraction needs current state)");
899
- try {
900
- await ctx.reindexWithIndexDbReleased(primaryStashDir);
901
- reindexedAfterInference = true;
902
- info("[improve] reindex after consolidation complete");
903
- }
904
- catch (err) {
905
- warnings.push(`reindex after consolidation failed: ${errMessage(err)}`);
906
- }
907
- }
908
- // #584: no close/reopen needed here — reindexWithIndexDbReleased
909
- // already swapped in a fresh post-reindex handle.
910
1026
  // Resolve touched refs to absolute file paths. Skipped for fullScan
911
1027
  // (candidatePaths stays undefined → extractor processes all files).
912
1028
  let candidatePaths;
@@ -943,6 +1059,9 @@ export async function runGraphExtractionMaintenancePass(ctx, dbCell, args) {
943
1059
  includeTypes: graphExtractionIncludeTypes,
944
1060
  batchSize: graphExtractionBatchSize,
945
1061
  ...(graphExtractionTopN != null ? { topN: graphExtractionTopN } : {}),
1062
+ ...(graphExtractionMaxChunksPerAsset != null
1063
+ ? { maxChunksPerAsset: graphExtractionMaxChunksPerAsset }
1064
+ : {}),
946
1065
  },
947
1066
  }), { engine: resolvedPlan?.processes.graphExtraction.runner?.engine, process: "graphExtraction" });
948
1067
  durationMs = Date.now() - extractionStart;
@@ -1089,6 +1208,13 @@ export function runRetentionPurgePass(ctx) {
1089
1208
  metadata: { purgedCount: cycleMetricsPurged, retentionDays: cycleRetention },
1090
1209
  }, eventsCtx);
1091
1210
  }
1211
+ // R0 step 3: opportunistic post-purge VACUUM. Reads the freelist
1212
+ // off this same connection (no second state.db handle) and only
1213
+ // runs when reclaimable space crosses STATE_DB_FREELIST_WARN_RATIO.
1214
+ const vacuumOutcome = vacuumStateDbIfReclaimable(stateDb, readFreelistInfo(stateDb), eventsCtx);
1215
+ if (vacuumOutcome.ran) {
1216
+ info(`[improve] state.db vacuum: ${vacuumOutcome.pagesBefore} -> ${vacuumOutcome.pagesAfter} pages`);
1217
+ }
1092
1218
  }, { path: eventsCtx?.dbPath, borrowed: eventsCtx?.db });
1093
1219
  }
1094
1220
  catch (err) {
@@ -1165,12 +1291,13 @@ export const STATE_GC_GRACE_MS = daysToMs(7);
1165
1291
  * Resolve one state-table's stored `asset_ref` against the live index.
1166
1292
  *
1167
1293
  * "ref not present in entries.item_ref" is the authoritative-deletion
1168
- * predicate (see the pass doc comment below), so this is a thin wrapper
1169
- * around the same single-ref probe the rest of improve uses
1170
- * (`getEntryByRef`, index-entries-repository.ts) — which already resolves
1171
- * both storage spellings a write can produce (`salienceWriteKey`/
1172
- * `outcomeWriteKey` = `itemRef ?? ref`): an exact bundle-qualified item_ref,
1173
- * or a bare conceptId matched by suffix across all bundles.
1294
+ * predicate (see the pass doc comment below), so this checks the same two
1295
+ * spellings `getEntryByRef` (index-entries-repository.ts) resolves — an exact
1296
+ * bundle-qualified item_ref, or a bare conceptId matched by suffix across all
1297
+ * bundles — but against a prebuilt {@link LiveRefSnapshot}
1298
+ * (`getLiveRefSnapshot`) instead of a database round trip per row: with up to
1299
+ * a few thousand pending rows per run, one probe per row was the dominant
1300
+ * cost R78 (tier1-0917).
1174
1301
  *
1175
1302
  * On top of that, falls back to the BARE conceptId form (`bareImproveRef` —
1176
1303
  * the same primitive `preparation.ts`'s `normalizeStoredKey` map is built
@@ -1182,11 +1309,11 @@ export const STATE_GC_GRACE_MS = daysToMs(7);
1182
1309
  * "never delete a live row" over "never miss a genuinely dead one" mirrors
1183
1310
  * `getEntryByRef`'s own bare-conceptId suffix-match trade-off.
1184
1311
  */
1185
- function isStateRefLive(indexDb, storedRef) {
1186
- if (getEntryByRef(indexDb, storedRef) !== null)
1312
+ function isStateRefLive(snapshot, storedRef) {
1313
+ if (isRefLiveInSnapshot(snapshot, storedRef))
1187
1314
  return true;
1188
1315
  const bare = bareImproveRef(storedRef);
1189
- return bare !== storedRef && getEntryByRef(indexDb, bare) !== null;
1316
+ return bare !== storedRef && isRefLiveInSnapshot(snapshot, bare);
1190
1317
  }
1191
1318
  /**
1192
1319
  * Sweep ONE state table: stamp refs that just went unresolved, clear refs
@@ -1198,11 +1325,11 @@ function isStateRefLive(indexDb, storedRef) {
1198
1325
  * proof" (close-out plan, Workstream C).
1199
1326
  */
1200
1327
  function gcOneStateTable(args) {
1201
- const { refRows, indexDb, now, collect, stamp, clear, deleteOlderThan, countPending } = args;
1328
+ const { refRows, liveRefs, now, collect, stamp, clear, deleteOlderThan, countPending } = args;
1202
1329
  const toStamp = [];
1203
1330
  const toClear = [];
1204
1331
  for (const row of refRows) {
1205
- const live = isStateRefLive(indexDb, row.asset_ref);
1332
+ const live = isStateRefLive(liveRefs, row.asset_ref);
1206
1333
  if (!live && row.missing_since == null)
1207
1334
  toStamp.push(row.asset_ref);
1208
1335
  else if (live && row.missing_since != null)
@@ -1257,10 +1384,16 @@ export function runOrphanStateGcPass(ctx, dbCell) {
1257
1384
  let pending = 0;
1258
1385
  let collected = 0;
1259
1386
  try {
1387
+ // R78 (tier1-0917): one query for every live item_ref, shared by both tables' sweeps
1388
+ // below — replaces a `getEntryByRef` round trip per pending row. Inside
1389
+ // the try so a schema mismatch (e.g. a DB version upgrade that dropped
1390
+ // `entries`) degrades to the "orphan state GC failed" warning below
1391
+ // instead of escaping this pass and failing the whole maintenance run.
1392
+ const liveRefs = getLiveRefSnapshot(indexDb);
1260
1393
  withStateDb((stateDb) => {
1261
1394
  const salienceResult = gcOneStateTable({
1262
1395
  refRows: listAssetSalienceMissingState(stateDb),
1263
- indexDb,
1396
+ liveRefs,
1264
1397
  now,
1265
1398
  collect,
1266
1399
  stamp: (refs, ts) => stampAssetSalienceMissing(stateDb, refs, ts),
@@ -1270,7 +1403,7 @@ export function runOrphanStateGcPass(ctx, dbCell) {
1270
1403
  });
1271
1404
  const outcomeResult = gcOneStateTable({
1272
1405
  refRows: listAssetOutcomeMissingState(stateDb),
1273
- indexDb,
1406
+ liveRefs,
1274
1407
  now,
1275
1408
  collect,
1276
1409
  stamp: (refs, ts) => stampAssetOutcomeMissing(stateDb, refs, ts),