akm-cli 0.9.0-beta.2 → 0.9.0-beta.27

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 (120) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +2079 -608
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/fact-linter.js +39 -0
  55. package/dist/commands/lint/index.js +31 -13
  56. package/dist/commands/lint/memory-linter.js +1 -1
  57. package/dist/commands/lint/registry.js +7 -2
  58. package/dist/commands/lint/task-linter.js +3 -3
  59. package/dist/commands/lint/workflow-linter.js +26 -1
  60. package/dist/commands/proposal/drain.js +73 -6
  61. package/dist/commands/proposal/proposal-cli.js +22 -10
  62. package/dist/commands/proposal/proposal.js +17 -1
  63. package/dist/commands/proposal/validators/proposals.js +369 -329
  64. package/dist/commands/read/curate.js +344 -80
  65. package/dist/commands/read/search-cli.js +7 -0
  66. package/dist/commands/read/search.js +1 -0
  67. package/dist/commands/read/show.js +67 -2
  68. package/dist/commands/remember.js +6 -2
  69. package/dist/commands/sources/installed-stashes.js +5 -1
  70. package/dist/commands/sources/stash-cli.js +10 -2
  71. package/dist/core/asset/asset-registry.js +2 -0
  72. package/dist/core/asset/asset-spec.js +14 -0
  73. package/dist/core/asset/frontmatter.js +166 -167
  74. package/dist/core/asset/markdown.js +8 -0
  75. package/dist/core/config/config-schema.js +255 -2
  76. package/dist/core/config/config.js +2 -2
  77. package/dist/core/logs-db.js +305 -0
  78. package/dist/core/paths.js +3 -0
  79. package/dist/core/state-db.js +706 -42
  80. package/dist/indexer/db/db.js +364 -38
  81. package/dist/indexer/db/graph-db.js +129 -86
  82. package/dist/indexer/ensure-index.js +152 -17
  83. package/dist/indexer/graph/graph-boost.js +51 -41
  84. package/dist/indexer/graph/graph-extraction.js +203 -3
  85. package/dist/indexer/index-writer-lock.js +99 -0
  86. package/dist/indexer/indexer.js +114 -111
  87. package/dist/indexer/passes/memory-inference.js +71 -25
  88. package/dist/indexer/passes/staleness-detect.js +2 -5
  89. package/dist/indexer/search/db-search.js +15 -4
  90. package/dist/indexer/search/ranking-contributors.js +22 -0
  91. package/dist/indexer/search/ranking.js +4 -0
  92. package/dist/indexer/walk/matchers.js +9 -0
  93. package/dist/integrations/agent/prompts.js +1 -0
  94. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  95. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  96. package/dist/integrations/session-logs/index.js +16 -0
  97. package/dist/llm/client.js +38 -4
  98. package/dist/llm/embedder.js +27 -3
  99. package/dist/llm/embedders/local.js +66 -2
  100. package/dist/llm/graph-extract.js +2 -1
  101. package/dist/llm/memory-infer.js +4 -8
  102. package/dist/llm/metadata-enhance.js +9 -1
  103. package/dist/llm/usage-persist.js +77 -0
  104. package/dist/llm/usage-telemetry.js +103 -0
  105. package/dist/output/context.js +3 -2
  106. package/dist/output/html-render.js +73 -0
  107. package/dist/output/renderers.js +73 -1
  108. package/dist/output/shapes/curate.js +14 -2
  109. package/dist/output/shapes/helpers.js +17 -1
  110. package/dist/output/text/helpers.js +78 -1
  111. package/dist/runtime.js +25 -1
  112. package/dist/scripts/migrate-storage.js +1262 -591
  113. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +485 -270
  114. package/dist/sources/providers/tar-utils.js +16 -8
  115. package/dist/storage/sqlite-pragmas.js +146 -0
  116. package/dist/tasks/runner.js +99 -16
  117. package/dist/workflows/db.js +5 -2
  118. package/dist/workflows/validate-summary.js +2 -7
  119. package/docs/data-and-telemetry.md +1 -0
  120. package/package.json +9 -6
@@ -53,6 +53,7 @@ import fs from "node:fs";
53
53
  import path from "node:path";
54
54
  import { openDatabase } from "../storage/database.js";
55
55
  import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
56
+ import { applyStandardPragmas } from "../storage/sqlite-pragmas.js";
56
57
  import { classifyImproveAction } from "./improve-types.js";
57
58
  import { getDataDir } from "./paths.js";
58
59
  import { error } from "./warn.js";
@@ -86,11 +87,12 @@ export function getStateDbPath() {
86
87
  * backwards compatibility; enabling them prevents orphaned rows in tables
87
88
  * that reference each other (not used in v1 schema but guards future ones).
88
89
  *
89
- * busy_timeout = 5000
90
+ * busy_timeout = 30000
90
91
  * When another connection holds a write lock, SQLite retries for up to
91
- * 5 000 ms before returning SQLITE_BUSY. Without this, the default timeout
92
- * is 0 ms — any concurrent writer causes an immediate error. 5 s matches
93
- * the same value used in openDatabase() for index.db.
92
+ * 30 000 ms before returning SQLITE_BUSY. Without this, the default timeout
93
+ * is 0 ms — any concurrent writer causes an immediate error. 30 s (#589)
94
+ * matches the value used in openDatabase() for index.db; 5 s proved too
95
+ * narrow when a post-inference reindex overlapped a parallel event write.
94
96
  */
95
97
  export function openStateDatabase(dbPath) {
96
98
  const resolvedPath = dbPath ?? getStateDbPath();
@@ -100,9 +102,7 @@ export function openStateDatabase(dbPath) {
100
102
  }
101
103
  const db = openDatabase(resolvedPath);
102
104
  // PRAGMAs must run before any DDL or DML.
103
- db.exec("PRAGMA journal_mode = WAL");
104
- db.exec("PRAGMA foreign_keys = ON");
105
- db.exec("PRAGMA busy_timeout = 5000");
105
+ applyStandardPragmas(db, { dataDir: dir });
106
106
  runMigrations(db);
107
107
  return db;
108
108
  }
@@ -190,7 +190,9 @@ const MIGRATIONS = [
190
190
  --
191
191
  -- Extensible (metadata_json) columns:
192
192
  -- metadata_json TEXT — JSON object for future proposal fields.
193
- -- Current fields stored here: sourceRun, review.
193
+ -- Current fields stored here: sourceRun,
194
+ -- review, confidence, gateDecision (#577),
195
+ -- backupContent, eligibilitySource.
194
196
  --
195
197
  -- ADD COLUMN extension points (future migrations):
196
198
  -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
@@ -458,6 +460,337 @@ const MIGRATIONS = [
458
460
  ON extract_sessions_seen(processed_at);
459
461
  `,
460
462
  },
463
+ // ── Migration 005 — proposal_fs_imports ─────────────────────────────────────
464
+ //
465
+ // One-shot ledger for the legacy filesystem→SQLite proposal import (#578).
466
+ //
467
+ // Before 0.9.0 the proposal queue lived as per-uuid JSON directories under
468
+ // `<stashDir>/.akm/proposals/` and the `proposals` table (created in 001) was
469
+ // dead weight. 0.9.0 makes the table canonical; the first proposal operation
470
+ // against a stash imports any legacy `proposal.json` files it finds (INSERT
471
+ // OR IGNORE, so re-runs never duplicate) and records the stash here so later
472
+ // invocations skip the directory walk entirely.
473
+ //
474
+ // Indexed (query) columns:
475
+ // stash_dir TEXT PK — absolute stash root the import ran against.
476
+ //
477
+ // Non-indexed columns:
478
+ // imported_at TEXT — ISO-8601 UTC; when the import completed.
479
+ // imported_count INTEGER — rows actually inserted by the import.
480
+ {
481
+ id: "005-proposal-fs-imports",
482
+ up: `
483
+ CREATE TABLE IF NOT EXISTS proposal_fs_imports (
484
+ stash_dir TEXT PRIMARY KEY,
485
+ imported_at TEXT NOT NULL,
486
+ imported_count INTEGER NOT NULL DEFAULT 0
487
+ );
488
+ `,
489
+ },
490
+ // ── Migration 006 — pending proposal lookup index ──────────────────────────
491
+ //
492
+ // Supports the transaction-scoped dedup / queue-mutation hardening added in
493
+ // 0.9.x. The queue now acquires an IMMEDIATE write transaction before it
494
+ // reads pending proposals, so the hot path is a stash-scoped `status='pending'
495
+ // AND ref=?` probe followed by an update/insert. This composite index keeps
496
+ // that lookup index-covered under contention.
497
+ {
498
+ id: "006-proposals-pending-ref-source",
499
+ up: `
500
+ CREATE INDEX IF NOT EXISTS idx_proposals_stash_status_ref_source
501
+ ON proposals(stash_dir, status, ref, source);
502
+ `,
503
+ },
504
+ // ── Migration 007 — consolidation_judged ────────────────────────────────────
505
+ //
506
+ // Judged-state cache for nightly consolidation (#581). Lets one consolidation
507
+ // run cover the FULL memory corpus cheaply by SKIPPING memories already judged
508
+ // with unchanged content, instead of narrowing to a recent time-window slice
509
+ // (which leaves a near-duplicate backlog the corpus can never clear).
510
+ //
511
+ // The consolidate LLM judging loop UPSERTs a row for every memory it saw in a
512
+ // successfully-judged chunk; the next run hashes each candidate's current
513
+ // content and skips it when the hash equals the cached `content_hash`
514
+ // (judged-unchanged → no re-judge). A memory whose content changed produces a
515
+ // new hash and is re-judged. This converts coverage from O(window) to
516
+ // O(changed/new). DEFAULT OFF — gated behind
517
+ // `processes.consolidate.judgedCache.enabled`; when the feature is off this
518
+ // table is never read or written and behaviour is byte-identical to today.
519
+ //
520
+ // Indexed (query) columns:
521
+ // entry_key TEXT PK — `memory:<name>` ref; one row per judged memory.
522
+ //
523
+ // Non-indexed columns:
524
+ // content_hash TEXT — sha256 of the frontmatter-stripped, trimmed body.
525
+ // judged_at TEXT — ISO-8601 UTC; when the memory was last judged.
526
+ // outcome TEXT — coarse outcome of the last judge ("actioned" |
527
+ // "no_action"); observability only, never gates.
528
+ //
529
+ // TTL: no automatic deletion. Rows for memories deleted on disk become
530
+ // harmless dead entries (their entry_key never recurs); operators can prune
531
+ // with `DELETE FROM consolidation_judged WHERE judged_at < ?` if desired.
532
+ {
533
+ id: "007-consolidation-judged",
534
+ up: `
535
+ CREATE TABLE IF NOT EXISTS consolidation_judged (
536
+ entry_key TEXT PRIMARY KEY,
537
+ content_hash TEXT NOT NULL,
538
+ judged_at TEXT NOT NULL,
539
+ outcome TEXT NOT NULL
540
+ );
541
+ `,
542
+ },
543
+ // ── Migration 008 — body_embeddings ─────────────────────────────────────────
544
+ //
545
+ // cacheHash-keyed body-embedding cache (WS-3a). Stores the embedding of the
546
+ // case-preserving stripped body so the dedup pre-pass and the consolidation
547
+ // clustering step share one computed vector per unique body, eliminating
548
+ // redundant embedding calls across runs.
549
+ //
550
+ // Design:
551
+ // - PK is the `cacheHash` (sha256 of the stripped, case-preserving body).
552
+ // - `embedding` is a raw BLOB storing a Float32 array (384 floats × 4 B =
553
+ // 1 536 B per entry for the default bge-small-en-v1.5 model; ~20 MB at
554
+ // 13 k memories). This matches the native wire format and avoids JSON
555
+ // round-trip overhead.
556
+ // - `model_id` is MANDATORY. On mismatch (model changed) the entire table
557
+ // is dropped and rebuilt — stale vectors from the wrong metric space would
558
+ // produce silent cosine errors.
559
+ // - `created_at` is an INTEGER Unix ms timestamp for lazy orphan purges.
560
+ //
561
+ // Writes: one bulk `WHERE content_hash IN (…)` lookup → embed only misses →
562
+ // upsert all results in one transaction per run.
563
+ //
564
+ // TTL: no automatic row deletion. Orphaned rows for bodies no longer in the
565
+ // stash stay until an operator prunes them. The table is ~1.5 KB per row
566
+ // (~20 MB at 13 k memories — acceptable).
567
+ {
568
+ id: "008-body-embeddings",
569
+ up: `
570
+ CREATE TABLE IF NOT EXISTS body_embeddings (
571
+ content_hash TEXT PRIMARY KEY,
572
+ embedding BLOB NOT NULL,
573
+ model_id TEXT NOT NULL,
574
+ created_at INTEGER NOT NULL
575
+ );
576
+ `,
577
+ },
578
+ // ── Migration 009 — asset_salience (WS-1 salience vector) ───────────────────
579
+ //
580
+ // Per-asset salience vector persisted in state.db (canonical store).
581
+ //
582
+ // Three independently-stored, independently-decayable sub-scores:
583
+ // encoding_salience — intrinsic importance (Gap 1; v1 = type-weight stub).
584
+ // outcome_salience — differential usefulness (WS-2; 0 until that lands).
585
+ // retrieval_salience — frequency × recency (the decayable term).
586
+ //
587
+ // Plus the scalar projection for ranking:
588
+ // rank_score = (w_e·encoding + w_o·outcome + w_r·retrieval) × sizePenalty,
589
+ // normalized [0,1]. Every selector reads rank_score; individual sub-scores
590
+ // are available for telemetry and per-dimension thresholding.
591
+ //
592
+ // Plasticity column:
593
+ // consecutive_no_ops INTEGER — number of consecutive improve cycles where
594
+ // this asset produced a no-op (reflect/distill produced no change).
595
+ // Dampens CONSOLIDATION-SELECTION only — intentionally NOT applied to
596
+ // rank_score (stable assets stay retrievable but skip LLM merge passes).
597
+ //
598
+ // updated_at is an INTEGER Unix-ms timestamp for recency queries.
599
+ //
600
+ // The canonical store is state.db, not frontmatter. An optional frontmatter
601
+ // mirror of the stable encodingSalience is allowed for portability (#608).
602
+ //
603
+ // TTL: rows are overwritten on every run; orphaned rows for deleted assets
604
+ // accumulate harmlessly until an operator prunes them.
605
+ {
606
+ id: "009-asset-salience",
607
+ up: `
608
+ CREATE TABLE IF NOT EXISTS asset_salience (
609
+ asset_ref TEXT PRIMARY KEY,
610
+ encoding_salience REAL NOT NULL DEFAULT 0.5,
611
+ outcome_salience REAL NOT NULL DEFAULT 0.0,
612
+ retrieval_salience REAL NOT NULL DEFAULT 0.0,
613
+ rank_score REAL NOT NULL DEFAULT 0.0,
614
+ consecutive_no_ops INTEGER NOT NULL DEFAULT 0,
615
+ updated_at INTEGER NOT NULL DEFAULT 0
616
+ );
617
+
618
+ -- Hot path: sort / filter by rank_score for selector queries.
619
+ CREATE INDEX IF NOT EXISTS idx_asset_salience_rank
620
+ ON asset_salience(rank_score DESC);
621
+ `,
622
+ },
623
+ // ── Migration 010 — asset_outcome (WS-2 outcome loop) ───────────────────────
624
+ //
625
+ // Per-asset outcome loop persisted in state.db (S2 seam, WS-2).
626
+ //
627
+ // Stores the differential "was this retrieval useful" signal so the salience
628
+ // vector's `outcomeSalience` sub-score (WS-1 `W_OUTCOME` term) is non-zero.
629
+ //
630
+ // Columns:
631
+ // asset_ref TEXT PK — `type:name` asset ref (FK to asset_salience).
632
+ // last_retrieved_at INTEGER — Unix-ms of the most recent retrieval.
633
+ // retrieval_count INTEGER — total retrieval count from the index DB.
634
+ // expected_retrieval_rate REAL — EMA-smoothed expected count per cycle.
635
+ // negative_feedback_count INTEGER — cumulative negative-feedback events.
636
+ // accepted_change_count INTEGER — cumulative accepted proposals.
637
+ // review_pressure INTEGER — #613 pressure counter: repeated low-satisfaction
638
+ // retrievals increment it; feeds outcomeSalience.
639
+ // outcome_score REAL — differential outcome signal (can be negative).
640
+ // updated_at INTEGER — Unix-ms timestamp of last update.
641
+ //
642
+ // Design:
643
+ // - outcome_score is differential (prediction-error shaped), NOT a raw count,
644
+ // so it rewards assets that are retrieved MORE than their rolling mean AND
645
+ // accepted for change when retrieved. See outcome-loop.ts for the formula.
646
+ // - review_pressure (#613): repeated negative-feedback retrievals raise it,
647
+ // non-negative cycles decay it. Never mutates asset content directly.
648
+ // - warm_start: seeded from utility EMA at row creation, clipped to [0, 0.3]
649
+ // so the first negative delta does not cause a spurious rank inversion.
650
+ // - Orphaned rows (deleted assets) accumulate harmlessly; operators can prune
651
+ // with `DELETE FROM asset_outcome WHERE updated_at < ?` if desired.
652
+ {
653
+ id: "010-asset-outcome",
654
+ up: `
655
+ CREATE TABLE IF NOT EXISTS asset_outcome (
656
+ asset_ref TEXT PRIMARY KEY,
657
+ last_retrieved_at INTEGER NOT NULL DEFAULT 0,
658
+ retrieval_count INTEGER NOT NULL DEFAULT 0,
659
+ expected_retrieval_rate REAL NOT NULL DEFAULT 0.0,
660
+ negative_feedback_count INTEGER NOT NULL DEFAULT 0,
661
+ accepted_change_count INTEGER NOT NULL DEFAULT 0,
662
+ review_pressure INTEGER NOT NULL DEFAULT 0,
663
+ outcome_score REAL NOT NULL DEFAULT 0.0,
664
+ updated_at INTEGER NOT NULL DEFAULT 0
665
+ );
666
+
667
+ -- Hot path: sort assets by review_pressure DESC for #613 admission.
668
+ CREATE INDEX IF NOT EXISTS idx_asset_outcome_review_pressure
669
+ ON asset_outcome(review_pressure DESC);
670
+
671
+ -- Secondary: sort by outcome_score DESC for outcomeSalience reads.
672
+ CREATE INDEX IF NOT EXISTS idx_asset_outcome_score
673
+ ON asset_outcome(outcome_score DESC);
674
+ `,
675
+ },
676
+ // ── Migration 011 — asset_salience: homeostatic_demoted_at column ─────────────
677
+ //
678
+ // WS-3b step 0a (homeostatic demotion). Records the last time `retrievalSalience`
679
+ // was demoted for this asset so:
680
+ // (a) Each run can identify assets that have been demoted but not yet
681
+ // re-retrieved (they stay in the demoted state until a retrieval
682
+ // re-promotes them via `upsertAssetSalience`).
683
+ // (b) The homeostatic pass can log "N assets demoted this run".
684
+ //
685
+ // NULL = never demoted (or was re-promoted after last demotion, since a fresh
686
+ // `upsertAssetSalience` call clears the flag by updating retrieval_salience
687
+ // from live data rather than the demoted value — the column is informational,
688
+ // not the canonical source of the salience value).
689
+ {
690
+ id: "011-asset-salience-homeostatic-demoted-at",
691
+ up: `
692
+ ALTER TABLE asset_salience ADD COLUMN homeostatic_demoted_at INTEGER DEFAULT NULL;
693
+ `,
694
+ },
695
+ // ── Migration 012 — improve_gate_thresholds (WS-4 per-phase threshold store) ─
696
+ //
697
+ // Persists the auto-tuned accept-gate threshold PER PHASE so that each phase
698
+ // (reflect, distill, extract, consolidate) maintains its own calibrated
699
+ // threshold rather than sharing a single global `options.autoAccept`.
700
+ //
701
+ // Schema:
702
+ // phase TEXT PK — phase label, e.g. "reflect", "distill", "extract",
703
+ // "consolidate".
704
+ // threshold INTEGER — tuned threshold (0-100), matches the integer
705
+ // scale used everywhere else in the gate pipeline.
706
+ // updated_at INTEGER — Unix milliseconds of the last update.
707
+ //
708
+ // `makeGateConfig` reads the stored threshold for its phase (falling back to
709
+ // the caller-supplied `globalThreshold` when no row exists yet). WS-4's
710
+ // `persistPhaseThreshold` writes it after each auto-tune step.
711
+ {
712
+ id: "012-improve-gate-thresholds",
713
+ up: `
714
+ CREATE TABLE IF NOT EXISTS improve_gate_thresholds (
715
+ phase TEXT NOT NULL PRIMARY KEY,
716
+ threshold INTEGER NOT NULL,
717
+ updated_at INTEGER NOT NULL
718
+ );
719
+ `,
720
+ },
721
+ // ── Migration 013 — extract_sessions_seen: content_hash column (#602) ────────
722
+ //
723
+ // Replaces the brittle timestamp-based incrementality (`session_ended_at`,
724
+ // compared against the live session metadata) with a content hash. The old
725
+ // clock/timestamp logic caused the Jun 11-12 double-extract + over-throttle
726
+ // incident (clock skew / out-of-order endedAt both double-processed AND
727
+ // over-suppressed sessions). The hash makes the skip decision byte-exact and
728
+ // clock-independent.
729
+ //
730
+ // Additive ADD COLUMN (migration-safe; mirrors migration 011's style). All
731
+ // pre-existing rows read back `content_hash = NULL`, which the skip logic
732
+ // treats as "seen before content-hash tracking existed → process once to
733
+ // backfill", after which the row gets a real hash and becomes hash-stable.
734
+ // Never mutate migration 004 (the original table) — this column is appended.
735
+ {
736
+ id: "013-extract-sessions-content-hash",
737
+ up: `
738
+ ALTER TABLE extract_sessions_seen ADD COLUMN content_hash TEXT DEFAULT NULL;
739
+ `,
740
+ },
741
+ // ── Migration 014 — recombine_hypotheses (#625 confirmation count) ───────────
742
+ //
743
+ // Second-pass promotion ledger for the recombine pass. The first pass (#609)
744
+ // only ever emits `type: hypothesis` proposals; this table tracks how many
745
+ // CONSECUTIVE runs re-induced the SAME generalization (keyed by the
746
+ // deterministic `deriveRecombineLessonRef` value — a hash of the sorted
747
+ // member entryKeys). Once `consecutive_count >= confirmThreshold`, the run
748
+ // promotes the generalization to a `type: lesson` proposal (through the same
749
+ // proposal queue + quality gate). A hypothesis NOT re-induced in a run has its
750
+ // consecutive streak reset (decay-to-zero), so confirmation is per exact
751
+ // member-set and conservative.
752
+ //
753
+ // Indexed columns:
754
+ // hypothesis_ref TEXT PK — the `lesson:recombined/<slug>-<hash>` ref; one
755
+ // row per re-inducible generalization. The ref is
756
+ // the promotion TARGET (a lesson in both the
757
+ // hypothesis and promoted states), so the ref never
758
+ // encodes the proposal type.
759
+ // last_seen_at TEXT (idx) — for forensic / pruning queries.
760
+ //
761
+ // Non-indexed columns:
762
+ // signature TEXT — the cluster's shared relatedness signal (tag /
763
+ // entity) at induction time; forensics only.
764
+ // member_key TEXT — sorted member entryKeys joined; the membership
765
+ // fingerprint behind the ref hash. Stored so a
766
+ // membership change (which yields a DIFFERENT ref)
767
+ // is auditable.
768
+ // consecutive_count INTEGER — current confirmation streak (reset on decay
769
+ // and on promotion).
770
+ // first_seen_at TEXT — ISO-8601 UTC of the first induction.
771
+ // last_run TEXT — sourceRun token of the last induction; the
772
+ // same-run idempotency guard.
773
+ // promoted_at TEXT — non-null once promoted; guards against
774
+ // double-promoting on every subsequent run.
775
+ // metadata_json TEXT — reserved for future forensics; defaults to '{}'.
776
+ {
777
+ id: "014-recombine-hypotheses",
778
+ up: `
779
+ CREATE TABLE IF NOT EXISTS recombine_hypotheses (
780
+ hypothesis_ref TEXT PRIMARY KEY,
781
+ signature TEXT NOT NULL,
782
+ member_key TEXT NOT NULL,
783
+ consecutive_count INTEGER NOT NULL DEFAULT 0,
784
+ first_seen_at TEXT NOT NULL,
785
+ last_seen_at TEXT NOT NULL,
786
+ last_run TEXT,
787
+ promoted_at TEXT,
788
+ metadata_json TEXT NOT NULL DEFAULT '{}'
789
+ );
790
+ CREATE INDEX IF NOT EXISTS idx_recombine_hypotheses_last_seen
791
+ ON recombine_hypotheses(last_seen_at);
792
+ `,
793
+ },
461
794
  ];
462
795
  /**
463
796
  * Apply every pending migration in a single transaction per migration.
@@ -529,6 +862,12 @@ export function proposalRowToProposal(row) {
529
862
  ...(frontmatter !== undefined ? { frontmatter } : {}),
530
863
  },
531
864
  ...(meta.review !== undefined ? { review: meta.review } : {}),
865
+ ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
866
+ ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
867
+ ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
868
+ ...(typeof meta.eligibilitySource === "string"
869
+ ? { eligibilitySource: meta.eligibilitySource }
870
+ : {}),
532
871
  };
533
872
  }
534
873
  /**
@@ -542,6 +881,14 @@ export function proposalToRowValues(proposal, stashDir) {
542
881
  metaObj.sourceRun = proposal.sourceRun;
543
882
  if (proposal.review !== undefined)
544
883
  metaObj.review = proposal.review;
884
+ if (proposal.confidence !== undefined)
885
+ metaObj.confidence = proposal.confidence;
886
+ if (proposal.gateDecision !== undefined)
887
+ metaObj.gateDecision = proposal.gateDecision;
888
+ if (proposal.backupContent !== undefined)
889
+ metaObj.backupContent = proposal.backupContent;
890
+ if (proposal.eligibilitySource !== undefined)
891
+ metaObj.eligibilitySource = proposal.eligibilitySource;
545
892
  return {
546
893
  id: proposal.id,
547
894
  stash_dir: stashDir,
@@ -656,7 +1003,10 @@ export function upsertProposal(db, proposal, stashDir) {
656
1003
  }
657
1004
  /**
658
1005
  * List proposals, optionally filtered by stashDir, status, and/or ref.
659
- * Results are sorted by created_at ASC to match the existing listProposals() behaviour.
1006
+ *
1007
+ * Results are ordered by `created_at ASC` (matching the historical
1008
+ * `listProposals()` sort), with `rowid` as a deterministic tiebreak so two
1009
+ * proposals created in the same millisecond list in insertion order.
660
1010
  */
661
1011
  export function listStateProposals(db, options = {}) {
662
1012
  const conditions = [];
@@ -677,21 +1027,149 @@ export function listStateProposals(db, options = {}) {
677
1027
  const rows = db
678
1028
  .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
679
1029
  content, frontmatter_json, metadata_json
680
- FROM proposals ${where} ORDER BY created_at ASC`)
1030
+ FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
681
1031
  .all(...params);
682
1032
  return rows.map(proposalRowToProposal);
683
1033
  }
684
1034
  /**
685
- * Look up a single proposal by id. Returns undefined when not found.
1035
+ * Read every proposal's `gateDecision` record across all stashes (#612).
1036
+ *
1037
+ * Calibration reads the auto-accept gate's per-proposal decisions regardless of
1038
+ * the proposal's current lifecycle status — a proposal that was auto-accepted
1039
+ * is now `accepted`, an auto-rejected one stays `pending`, so filtering by
1040
+ * status would drop half the join. Rows without a `gateDecision` (created
1041
+ * before #577, or never gated) are skipped. The result is ordered by
1042
+ * `decidedAt ASC` for deterministic downstream aggregation, falling back to
1043
+ * `created_at` ordering from the SQL layer for rows with equal/missing
1044
+ * timestamps.
686
1045
  */
687
- export function getStateProposal(db, id) {
688
- const row = db
689
- .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
1046
+ export function listProposalGateDecisions(db) {
1047
+ const rows = db.prepare("SELECT metadata_json FROM proposals ORDER BY created_at ASC, rowid ASC").all();
1048
+ const decisions = [];
1049
+ for (const row of rows) {
1050
+ let meta;
1051
+ try {
1052
+ meta = JSON.parse(row.metadata_json);
1053
+ }
1054
+ catch {
1055
+ continue;
1056
+ }
1057
+ const decision = meta.gateDecision;
1058
+ if (decision && typeof decision === "object" && typeof decision.outcome === "string") {
1059
+ decisions.push(decision);
1060
+ }
1061
+ }
1062
+ decisions.sort((a, b) => new Date(a.decidedAt).getTime() - new Date(b.decidedAt).getTime());
1063
+ return decisions;
1064
+ }
1065
+ // ── WS-4: Per-phase gate threshold store (Migration 012) ─────────────────────
1066
+ /**
1067
+ * Read the persisted auto-tuned threshold for a gate phase.
1068
+ *
1069
+ * Returns `undefined` when no row exists yet (first run, or the phase has
1070
+ * never been tuned). The caller falls back to the global `options.autoAccept`
1071
+ * in that case.
1072
+ */
1073
+ export function getPhaseThreshold(db, phase) {
1074
+ const row = db.prepare("SELECT threshold FROM improve_gate_thresholds WHERE phase = ?").get(phase);
1075
+ return row?.threshold;
1076
+ }
1077
+ /**
1078
+ * Persist the auto-tuned threshold for a gate phase.
1079
+ * Uses INSERT OR REPLACE so the call is idempotent (upsert semantics).
1080
+ */
1081
+ export function persistPhaseThreshold(db, phase, threshold) {
1082
+ db.prepare(`INSERT OR REPLACE INTO improve_gate_thresholds (phase, threshold, updated_at)
1083
+ VALUES (?, ?, ?)`).run(phase, Math.round(threshold), Date.now());
1084
+ }
1085
+ /**
1086
+ * Look up a single proposal by id, optionally scoped to one stash root.
1087
+ * Returns undefined when not found.
1088
+ */
1089
+ export function getStateProposal(db, id, stashDir) {
1090
+ const sql = `SELECT id, stash_dir, ref, status, source, created_at, updated_at,
690
1091
  content, frontmatter_json, metadata_json
691
- FROM proposals WHERE id = ?`)
692
- .get(id);
1092
+ FROM proposals WHERE id = ?${stashDir ? " AND stash_dir = ?" : ""}`;
1093
+ const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
693
1094
  return row ? proposalRowToProposal(row) : undefined;
694
1095
  }
1096
+ /**
1097
+ * Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
1098
+ * Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
1099
+ * prefix resolution is deliberately scoped to the live (pending) queue,
1100
+ * mirroring the historical behaviour of scanning only the live directory.
1101
+ *
1102
+ * `%` / `_` / `\` in the prefix are escaped so the LIKE pattern is literal.
1103
+ */
1104
+ export function listStateProposalIdsByPrefix(db, stashDir, idPrefix) {
1105
+ const escaped = idPrefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
1106
+ const rows = db
1107
+ .prepare(`SELECT id FROM proposals
1108
+ WHERE stash_dir = ? AND status = 'pending' AND id LIKE ? ESCAPE '\\'
1109
+ ORDER BY id ASC`)
1110
+ .all(stashDir, `${escaped}%`);
1111
+ return rows.map((r) => r.id);
1112
+ }
1113
+ /**
1114
+ * Whether the legacy filesystem proposal import has already run for `stashDir`.
1115
+ * See migration 005 (`proposal_fs_imports`).
1116
+ */
1117
+ export function hasImportedFsProposals(db, stashDir) {
1118
+ // Drivers disagree on the no-row sentinel (bun:sqlite → null,
1119
+ // better-sqlite3 → undefined) — Boolean() covers both.
1120
+ return Boolean(db.prepare("SELECT 1 FROM proposal_fs_imports WHERE stash_dir = ?").get(stashDir));
1121
+ }
1122
+ /**
1123
+ * Record that the legacy filesystem proposal import completed for `stashDir`
1124
+ * so subsequent invocations skip the directory walk. INSERT OR REPLACE keeps
1125
+ * the call idempotent.
1126
+ */
1127
+ export function recordFsProposalsImport(db, stashDir, importedCount) {
1128
+ db.prepare("INSERT OR REPLACE INTO proposal_fs_imports (stash_dir, imported_at, imported_count) VALUES (?, ?, ?)").run(stashDir, new Date().toISOString(), importedCount);
1129
+ }
1130
+ /**
1131
+ * Insert a proposal row ONLY when the id is not already present (used by the
1132
+ * legacy filesystem import so re-runs never clobber rows that have since been
1133
+ * mutated through the canonical store). Returns true when a row was inserted.
1134
+ */
1135
+ export function insertProposalIfAbsent(db, proposal, stashDir) {
1136
+ const v = proposalToRowValues(proposal, stashDir);
1137
+ const result = db
1138
+ .prepare(`
1139
+ INSERT OR IGNORE INTO proposals
1140
+ (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
1141
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1142
+ `)
1143
+ .run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
1144
+ const changes = result.changes ?? 0;
1145
+ return Number(changes) > 0;
1146
+ }
1147
+ /**
1148
+ * Run `fn` inside a `BEGIN IMMEDIATE` transaction.
1149
+ *
1150
+ * `db.transaction()` is DEFERRED by default on both Bun and better-sqlite3,
1151
+ * which means two writers can both perform stale preflight reads and only race
1152
+ * when they finally attempt the write. Proposal creation and queue mutation
1153
+ * need the write lock BEFORE those reads so concurrent processes serialize on
1154
+ * the live queue state rather than clobbering each other.
1155
+ */
1156
+ export function withImmediateTransaction(db, fn) {
1157
+ db.exec("BEGIN IMMEDIATE");
1158
+ try {
1159
+ const result = fn();
1160
+ db.exec("COMMIT");
1161
+ return result;
1162
+ }
1163
+ catch (err) {
1164
+ try {
1165
+ db.exec("ROLLBACK");
1166
+ }
1167
+ catch {
1168
+ // Ignore rollback failures so the original error is preserved.
1169
+ }
1170
+ throw err;
1171
+ }
1172
+ }
695
1173
  // ── task_history table helpers ───────────────────────────────────────────────
696
1174
  /**
697
1175
  * Upsert a task history row.
@@ -989,9 +1467,11 @@ export function purgeOldImproveRuns(db, retentionDays = 90) {
989
1467
  }
990
1468
  /**
991
1469
  * Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
992
- * row reflects the most recent run; downstream skip-logic compares
993
- * `session_ended_at` against the live session metadata to decide if anything
994
- * new arrived since `processed_at`.
1470
+ * row reflects the most recent run. The `content_hash` persisted here is what
1471
+ * the NEXT run compares against (#602): a byte-identical session is skipped, a
1472
+ * changed session is re-processed, and a NULL-backfill row becomes hash-stable
1473
+ * after its one reprocess. `session_ended_at` is still written for
1474
+ * telemetry/forensics but is no longer the skip authority.
995
1475
  */
996
1476
  export function upsertExtractedSession(db, input) {
997
1477
  const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
@@ -1000,9 +1480,10 @@ export function upsertExtractedSession(db, input) {
1000
1480
  db.prepare(`
1001
1481
  INSERT OR REPLACE INTO extract_sessions_seen
1002
1482
  (harness, session_id, processed_at, session_ended_at, outcome,
1003
- candidate_count, proposal_count, rationale, source_run, metadata_json)
1004
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1005
- `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}));
1483
+ candidate_count, proposal_count, rationale, source_run, metadata_json,
1484
+ content_hash)
1485
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1486
+ `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}), input.contentHash);
1006
1487
  }
1007
1488
  /**
1008
1489
  * Fetch a single session's last extract record, or `undefined` when the
@@ -1040,31 +1521,214 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
1040
1521
  return out;
1041
1522
  }
1042
1523
  /**
1043
- * Decide whether a session should be skipped because the extractor has
1044
- * already processed it AND nothing has changed since. The "anything new since
1045
- * last extract?" rule is: the live `sessionEndedAtMs` is strictly later than
1046
- * the recorded `session_ended_at`. Same-or-earlier endedAt means we'd be
1047
- * re-processing the exact same content for no gain.
1524
+ * Decide whether a session should be skipped because the extractor has already
1525
+ * processed BYTE-IDENTICAL content (#602). The skip authority is the content
1526
+ * hash, NOT `session_ended_at` — this is clock-independent, so it is immune to
1527
+ * the clock-skew / out-of-order-endedAt problems that caused the Jun 11-12
1528
+ * double-extract + over-throttle incident.
1048
1529
  *
1049
- * Returns:
1050
- * - `false` — no prior row, or session has new content since last extract.
1051
- * The caller should process it.
1052
- * - `true` — the session was already processed and hasn't been updated.
1053
- * The caller should skip.
1530
+ * Rules:
1531
+ * - no prior row `false` (never seen process; AC3).
1532
+ * - prior.content_hash == null → `false` (legacy / hash-less row → process
1533
+ * exactly once to backfill the hash, then it becomes hash-stable; AC4).
1534
+ * - hashes equal → `true` (unchanged content → skip; AC1).
1535
+ * - hashes differ → `false` (changed content → re-process; AC2).
1054
1536
  */
1055
- export function shouldSkipAlreadyExtractedSession(prior, liveSessionEndedAtMs) {
1537
+ export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
1056
1538
  if (!prior)
1057
1539
  return false;
1058
- // No live timestamp → can't tell if anything's new. Be conservative and
1059
- // skip — the operator can pass --force later if we add it.
1060
- if (typeof liveSessionEndedAtMs !== "number" || !Number.isFinite(liveSessionEndedAtMs)) {
1061
- return true;
1062
- }
1063
- const priorMs = prior.session_ended_at ? Date.parse(prior.session_ended_at) : Number.NaN;
1064
- if (!Number.isFinite(priorMs))
1540
+ if (prior.content_hash == null)
1065
1541
  return false;
1066
- // Re-process when there's new content; skip when the session is unchanged.
1067
- return liveSessionEndedAtMs <= priorMs;
1542
+ return prior.content_hash === currentContentHash;
1543
+ }
1544
+ /**
1545
+ * Bulk-fetch the judged-state cache for a set of entry keys in one query.
1546
+ * Returns a Map keyed by entry_key so the consolidate pool-selection loop can
1547
+ * do O(1) "has this memory been judged at this content hash?" lookups.
1548
+ * Empty input → empty map (no query issued).
1549
+ */
1550
+ export function getConsolidationJudgedMap(db, entryKeys) {
1551
+ const out = new Map();
1552
+ if (entryKeys.length === 0)
1553
+ return out;
1554
+ // SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that.
1555
+ const CHUNK = 500;
1556
+ for (let i = 0; i < entryKeys.length; i += CHUNK) {
1557
+ const chunk = entryKeys.slice(i, i + CHUNK);
1558
+ const placeholders = chunk.map(() => "?").join(",");
1559
+ const rows = db
1560
+ .prepare(`SELECT * FROM consolidation_judged WHERE entry_key IN (${placeholders})`)
1561
+ .all(...chunk);
1562
+ for (const row of rows)
1563
+ out.set(row.entry_key, row);
1564
+ }
1565
+ return out;
1566
+ }
1567
+ /**
1568
+ * Record (or update) the judged state for one memory. INSERT-OR-REPLACE so the
1569
+ * row always reflects the most recent judge of that entry_key. Called once per
1570
+ * memory the consolidate LLM saw in a successfully-judged chunk.
1571
+ */
1572
+ export function upsertConsolidationJudged(db, input) {
1573
+ db.prepare(`
1574
+ INSERT OR REPLACE INTO consolidation_judged
1575
+ (entry_key, content_hash, judged_at, outcome)
1576
+ VALUES (?, ?, ?, ?)
1577
+ `).run(input.entryKey, input.contentHash, input.judgedAt, input.outcome);
1578
+ }
1579
+ /**
1580
+ * Record an induction of a recombine hypothesis and return the new consecutive
1581
+ * count. INSERT … ON CONFLICT increments the streak, but the `last_run` guard
1582
+ * makes a repeated call within the SAME run idempotent (no double-increment if
1583
+ * the same ref appears twice in one run). On insert the streak starts at 1.
1584
+ */
1585
+ export function recordRecombineInduction(db, input) {
1586
+ const row = db
1587
+ .prepare(`
1588
+ INSERT INTO recombine_hypotheses
1589
+ (hypothesis_ref, signature, member_key, consecutive_count, first_seen_at, last_seen_at, last_run)
1590
+ VALUES (?, ?, ?, 1, ?, ?, ?)
1591
+ ON CONFLICT(hypothesis_ref) DO UPDATE SET
1592
+ consecutive_count = consecutive_count + (CASE WHEN last_run IS excluded.last_run THEN 0 ELSE 1 END),
1593
+ last_seen_at = excluded.last_seen_at,
1594
+ last_run = excluded.last_run,
1595
+ signature = excluded.signature,
1596
+ member_key = excluded.member_key
1597
+ RETURNING consecutive_count
1598
+ `)
1599
+ .get(input.hypothesisRef, input.signature, input.memberKey, input.seenAt, input.seenAt, input.run);
1600
+ return row?.consecutive_count ?? 0;
1601
+ }
1602
+ /**
1603
+ * Fetch a single recombine hypothesis row, or `undefined` when the ref has
1604
+ * never been induced. Normalizes bun:sqlite null → undefined like
1605
+ * {@link getExtractedSession}.
1606
+ */
1607
+ export function getRecombineHypothesis(db, hypothesisRef) {
1608
+ const row = db
1609
+ .prepare("SELECT * FROM recombine_hypotheses WHERE hypothesis_ref = ?")
1610
+ .get(hypothesisRef);
1611
+ return row ?? undefined;
1612
+ }
1613
+ /**
1614
+ * Mark a hypothesis promoted: stamp `promoted_at` and reset the consecutive
1615
+ * count to 0, so it must re-accumulate a full confirmation streak before it can
1616
+ * promote again. The `promoted_at` non-null state is the double-promotion guard.
1617
+ */
1618
+ export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
1619
+ db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
1620
+ }
1621
+ /**
1622
+ * Decay-to-zero every NON-promoted hypothesis NOT re-induced in the current run.
1623
+ *
1624
+ * A generalization that stops being supported by the corpus has lost its
1625
+ * confirmation streak, so we hard-reset `consecutive_count` to 0 (the
1626
+ * alternative — `count - 1` floored at 0 — tolerates a single noisy run but
1627
+ * blurs the "consecutive" semantics; hard-reset is the conservative choice).
1628
+ *
1629
+ * Only rows whose `hypothesis_ref` is NOT in `seenRefs` AND whose `last_run` is
1630
+ * NOT the current run are decayed. Already-promoted rows are left alone.
1631
+ * Returns the number of rows reset.
1632
+ */
1633
+ export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
1634
+ // Reset every eligible row, then exclude the seen refs in chunks to respect
1635
+ // the ~999 SQLite param ceiling. With no seen refs we reset all non-promoted
1636
+ // rows from prior runs in a single statement.
1637
+ if (seenRefs.length === 0) {
1638
+ const res = db
1639
+ .prepare("UPDATE recombine_hypotheses SET consecutive_count = 0 WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0")
1640
+ .run(currentRun);
1641
+ return Number(res.changes);
1642
+ }
1643
+ // A single NOT IN keeps the exclusion atomic (a chunked NOT IN would let a ref
1644
+ // excluded by one chunk still be reset by another chunk's statement). The
1645
+ // recombine pass caps re-induced clusters at `maxClustersPerRun` (a handful),
1646
+ // so `seenRefs` is far under SQLite's ~999 param ceiling in practice; we cap
1647
+ // defensively and fall back to resetting all when somehow exceeded.
1648
+ if (seenRefs.length > 900) {
1649
+ const res = db
1650
+ .prepare("UPDATE recombine_hypotheses SET consecutive_count = 0 WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0")
1651
+ .run(currentRun);
1652
+ return Number(res.changes);
1653
+ }
1654
+ const placeholders = seenRefs.map(() => "?").join(",");
1655
+ const res = db
1656
+ .prepare(`UPDATE recombine_hypotheses SET consecutive_count = 0
1657
+ WHERE promoted_at IS NULL
1658
+ AND (last_run IS NULL OR last_run != ?)
1659
+ AND consecutive_count != 0
1660
+ AND hypothesis_ref NOT IN (${placeholders})`)
1661
+ .run(currentRun, ...seenRefs);
1662
+ return Number(res.changes);
1663
+ }
1664
+ /**
1665
+ * Convert a `number[]` embedding vector to the `Float32Array` byte
1666
+ * representation stored in the `body_embeddings.embedding` BLOB column.
1667
+ */
1668
+ export function embeddingToBlob(vec) {
1669
+ const f32 = new Float32Array(vec);
1670
+ return new Uint8Array(f32.buffer);
1671
+ }
1672
+ /**
1673
+ * Convert the raw `Uint8Array` bytes from the `body_embeddings.embedding`
1674
+ * BLOB column back to a `number[]` embedding vector.
1675
+ */
1676
+ export function blobToEmbedding(blob) {
1677
+ // SQLite BLOB columns are returned as Uint8Array; re-interpret as Float32.
1678
+ const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
1679
+ return Array.from(f32);
1680
+ }
1681
+ /**
1682
+ * Bulk-fetch cached body embeddings for a set of content hashes.
1683
+ * Returns a Map keyed by `content_hash` (embedding decoded to `number[]`).
1684
+ * Empty input → empty map (no query issued).
1685
+ *
1686
+ * If the stored `model_id` does not match `expectedModelId` the entire table
1687
+ * is cleared (drop-all on model mismatch) and an empty map is returned so
1688
+ * callers re-embed everything on this run.
1689
+ */
1690
+ export function getBodyEmbeddings(db, contentHashes, expectedModelId) {
1691
+ const out = new Map();
1692
+ if (contentHashes.length === 0)
1693
+ return out;
1694
+ // Model-id mismatch: vectors are in the wrong metric space — drop all rows.
1695
+ const firstRow = db.prepare("SELECT model_id FROM body_embeddings LIMIT 1").get();
1696
+ if (firstRow && firstRow.model_id !== expectedModelId) {
1697
+ db.exec("DELETE FROM body_embeddings");
1698
+ return out;
1699
+ }
1700
+ // SQLite has a ~999 param ceiling; chunk if needed.
1701
+ const CHUNK = 500;
1702
+ for (let i = 0; i < contentHashes.length; i += CHUNK) {
1703
+ const chunk = contentHashes.slice(i, i + CHUNK);
1704
+ const placeholders = chunk.map(() => "?").join(",");
1705
+ const rows = db
1706
+ .prepare(`SELECT content_hash, embedding FROM body_embeddings WHERE content_hash IN (${placeholders})`)
1707
+ .all(...chunk);
1708
+ for (const row of rows) {
1709
+ out.set(row.content_hash, blobToEmbedding(row.embedding));
1710
+ }
1711
+ }
1712
+ return out;
1713
+ }
1714
+ /**
1715
+ * Upsert body-embedding rows in a single transaction.
1716
+ * Each entry maps a `cacheHash` → `number[]` vector. `model_id` is stored
1717
+ * so a future model change can trigger a drop-all purge.
1718
+ */
1719
+ export function upsertBodyEmbeddings(db, entries) {
1720
+ if (entries.length === 0)
1721
+ return;
1722
+ const now = Date.now();
1723
+ const stmt = db.prepare(`
1724
+ INSERT OR REPLACE INTO body_embeddings (content_hash, embedding, model_id, created_at)
1725
+ VALUES (?, ?, ?, ?)
1726
+ `);
1727
+ db.transaction(() => {
1728
+ for (const { contentHash, embedding, modelId } of entries) {
1729
+ stmt.run(contentHash, embeddingToBlob(embedding), modelId, now);
1730
+ }
1731
+ })();
1068
1732
  }
1069
1733
  // ── registry_index_cache (goes in index.db, not state.db) ───────────────────
1070
1734
  /**