akm-cli 0.9.0-beta.4 → 0.9.0-beta.41

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 (132) hide show
  1. package/CHANGELOG.md +646 -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 +6 -2
  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/stash-skeleton/facts/conventions/assets/agent.md +22 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +22 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +24 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +22 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +25 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +21 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +21 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +23 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +22 -0
  24. package/dist/assets/templates/html/health.html +281 -111
  25. package/dist/cli.js +14 -3
  26. package/dist/commands/agent/contribute-cli.js +16 -3
  27. package/dist/commands/feedback-cli.js +15 -6
  28. package/dist/commands/graph/graph.js +75 -71
  29. package/dist/commands/health/checks.js +48 -0
  30. package/dist/commands/health/html-report.js +422 -80
  31. package/dist/commands/health.js +381 -9
  32. package/dist/commands/improve/calibration.js +161 -0
  33. package/dist/commands/improve/consolidate.js +631 -111
  34. package/dist/commands/improve/dedup.js +482 -0
  35. package/dist/commands/improve/distill.js +163 -69
  36. package/dist/commands/improve/encoding-salience.js +205 -0
  37. package/dist/commands/improve/extract-cli.js +115 -1
  38. package/dist/commands/improve/extract-prompt.js +39 -2
  39. package/dist/commands/improve/extract-watch.js +140 -0
  40. package/dist/commands/improve/extract.js +403 -40
  41. package/dist/commands/improve/feedback-valence.js +54 -0
  42. package/dist/commands/improve/homeostatic.js +467 -0
  43. package/dist/commands/improve/improve-auto-accept.js +113 -6
  44. package/dist/commands/improve/improve-profiles.js +12 -0
  45. package/dist/commands/improve/improve.js +2042 -612
  46. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  47. package/dist/commands/improve/outcome-loop.js +256 -0
  48. package/dist/commands/improve/proactive-maintenance.js +115 -0
  49. package/dist/commands/improve/procedural.js +418 -0
  50. package/dist/commands/improve/recombine.js +602 -0
  51. package/dist/commands/improve/reflect-noise.js +0 -0
  52. package/dist/commands/improve/reflect.js +46 -4
  53. package/dist/commands/improve/related-sessions.js +120 -0
  54. package/dist/commands/improve/salience.js +438 -0
  55. package/dist/commands/improve/triage.js +93 -0
  56. package/dist/commands/lint/agent-linter.js +19 -24
  57. package/dist/commands/lint/base-linter.js +173 -60
  58. package/dist/commands/lint/command-linter.js +19 -24
  59. package/dist/commands/lint/env-key-rules.js +34 -1
  60. package/dist/commands/lint/fact-linter.js +39 -0
  61. package/dist/commands/lint/index.js +31 -13
  62. package/dist/commands/lint/memory-linter.js +1 -1
  63. package/dist/commands/lint/registry.js +7 -2
  64. package/dist/commands/lint/task-linter.js +3 -3
  65. package/dist/commands/lint/workflow-linter.js +26 -1
  66. package/dist/commands/proposal/drain-policies.js +5 -0
  67. package/dist/commands/proposal/drain.js +17 -1
  68. package/dist/commands/proposal/proposal.js +5 -0
  69. package/dist/commands/proposal/propose.js +5 -0
  70. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  71. package/dist/commands/proposal/validators/proposals.js +187 -57
  72. package/dist/commands/read/curate.js +344 -80
  73. package/dist/commands/read/search-cli.js +7 -0
  74. package/dist/commands/read/search.js +1 -0
  75. package/dist/commands/read/show.js +67 -2
  76. package/dist/commands/sources/init.js +36 -9
  77. package/dist/commands/sources/installed-stashes.js +5 -1
  78. package/dist/commands/sources/schema-repair.js +13 -1
  79. package/dist/commands/sources/stash-cli.js +19 -3
  80. package/dist/commands/sources/stash-skeleton.js +23 -8
  81. package/dist/core/asset/asset-registry.js +2 -0
  82. package/dist/core/asset/asset-spec.js +14 -0
  83. package/dist/core/asset/frontmatter.js +166 -167
  84. package/dist/core/asset/markdown.js +8 -0
  85. package/dist/core/authoring-rules.js +83 -0
  86. package/dist/core/config/config-schema.js +274 -2
  87. package/dist/core/config/config.js +2 -2
  88. package/dist/core/logs-db.js +4 -3
  89. package/dist/core/paths.js +3 -0
  90. package/dist/core/standards/resolve-standards-context.js +87 -0
  91. package/dist/core/standards/resolve-stash-standards.js +99 -0
  92. package/dist/core/standards/resolve-type-conventions.js +66 -0
  93. package/dist/core/state-db.js +691 -30
  94. package/dist/indexer/db/db.js +364 -38
  95. package/dist/indexer/db/graph-db.js +129 -86
  96. package/dist/indexer/ensure-index.js +152 -17
  97. package/dist/indexer/graph/graph-boost.js +51 -41
  98. package/dist/indexer/graph/graph-extraction.js +203 -3
  99. package/dist/indexer/index-writer-lock.js +99 -0
  100. package/dist/indexer/indexer.js +114 -111
  101. package/dist/indexer/passes/memory-inference.js +10 -3
  102. package/dist/indexer/passes/staleness-detect.js +2 -5
  103. package/dist/indexer/search/db-search.js +15 -4
  104. package/dist/indexer/search/ranking-contributors.js +22 -0
  105. package/dist/indexer/search/ranking.js +4 -0
  106. package/dist/indexer/walk/matchers.js +9 -0
  107. package/dist/integrations/agent/prompts.js +33 -0
  108. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  109. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  110. package/dist/integrations/session-logs/index.js +16 -0
  111. package/dist/llm/client.js +23 -4
  112. package/dist/llm/embedder.js +27 -3
  113. package/dist/llm/embedders/local.js +66 -2
  114. package/dist/llm/feature-gate.js +8 -4
  115. package/dist/llm/graph-extract.js +2 -1
  116. package/dist/llm/memory-infer.js +4 -8
  117. package/dist/llm/metadata-enhance.js +9 -1
  118. package/dist/output/renderers.js +73 -1
  119. package/dist/output/shapes/curate.js +14 -2
  120. package/dist/output/text/helpers.js +16 -1
  121. package/dist/runtime.js +25 -1
  122. package/dist/scripts/migrate-storage.js +1378 -599
  123. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +479 -270
  124. package/dist/setup/setup.js +3 -3
  125. package/dist/sources/providers/git.js +71 -61
  126. package/dist/sources/providers/tar-utils.js +16 -8
  127. package/dist/storage/sqlite-pragmas.js +146 -0
  128. package/dist/wiki/wiki.js +37 -0
  129. package/dist/workflows/db.js +3 -4
  130. package/dist/workflows/validate-summary.js +2 -7
  131. package/docs/data-and-telemetry.md +1 -0
  132. package/package.json +8 -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";
@@ -101,9 +102,7 @@ export function openStateDatabase(dbPath) {
101
102
  }
102
103
  const db = openDatabase(resolvedPath);
103
104
  // PRAGMAs must run before any DDL or DML.
104
- db.exec("PRAGMA journal_mode = WAL");
105
- db.exec("PRAGMA foreign_keys = ON");
106
- db.exec("PRAGMA busy_timeout = 30000");
105
+ applyStandardPragmas(db, { dataDir: dir });
107
106
  runMigrations(db);
108
107
  return db;
109
108
  }
@@ -193,7 +192,7 @@ const MIGRATIONS = [
193
192
  -- metadata_json TEXT — JSON object for future proposal fields.
194
193
  -- Current fields stored here: sourceRun,
195
194
  -- review, confidence, gateDecision (#577),
196
- -- backupContent.
195
+ -- backupContent, eligibilitySource.
197
196
  --
198
197
  -- ADD COLUMN extension points (future migrations):
199
198
  -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
@@ -488,6 +487,335 @@ const MIGRATIONS = [
488
487
  );
489
488
  `,
490
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
+ },
794
+ // ── Migration 015 — asset_salience: encoding_source provenance column (#644) ──
795
+ //
796
+ // Records HOW the stored `encoding_salience` was derived so an improve run's
797
+ // type-weight fallback can no longer clobber a real content-derived score:
798
+ //
799
+ // "content" — written by the distill path from `scoreEncodingSalience`
800
+ // (novelty·0.40 + magnitude·0.35 + predictionError·0.25).
801
+ // "type-stub" — written by `computeSalience`'s `DEFAULT_TYPE_ENCODING_WEIGHTS`
802
+ // fallback (no content-based score available for this ref yet).
803
+ // NULL — legacy row written before this migration; provenance unknown.
804
+ // Treated as a stub by readers UNLESS its stored value differs
805
+ // from the pure type-weight (best-effort heuristic for old data).
806
+ //
807
+ // Why a column rather than inference: before #644, every improve run overwrote
808
+ // the distill-written content score with the type-weight stub, so the stored
809
+ // value alone cannot distinguish a real score from a stub. The provenance flag
810
+ // is the single source of truth going forward; `upsertAssetSalience` refuses to
811
+ // lower a "content" row to a "type-stub" so the high-salience gate (#608) keys
812
+ // on genuine novelty/magnitude/prediction-error, not the asset type.
813
+ {
814
+ id: "015-asset-salience-encoding-source",
815
+ up: `
816
+ ALTER TABLE asset_salience ADD COLUMN encoding_source TEXT DEFAULT NULL;
817
+ `,
818
+ },
491
819
  ];
492
820
  /**
493
821
  * Apply every pending migration in a single transaction per migration.
@@ -562,6 +890,9 @@ export function proposalRowToProposal(row) {
562
890
  ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
563
891
  ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
564
892
  ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
893
+ ...(typeof meta.eligibilitySource === "string"
894
+ ? { eligibilitySource: meta.eligibilitySource }
895
+ : {}),
565
896
  };
566
897
  }
567
898
  /**
@@ -581,6 +912,8 @@ export function proposalToRowValues(proposal, stashDir) {
581
912
  metaObj.gateDecision = proposal.gateDecision;
582
913
  if (proposal.backupContent !== undefined)
583
914
  metaObj.backupContent = proposal.backupContent;
915
+ if (proposal.eligibilitySource !== undefined)
916
+ metaObj.eligibilitySource = proposal.eligibilitySource;
584
917
  return {
585
918
  id: proposal.id,
586
919
  stash_dir: stashDir,
@@ -723,6 +1056,57 @@ export function listStateProposals(db, options = {}) {
723
1056
  .all(...params);
724
1057
  return rows.map(proposalRowToProposal);
725
1058
  }
1059
+ /**
1060
+ * Read every proposal's `gateDecision` record across all stashes (#612).
1061
+ *
1062
+ * Calibration reads the auto-accept gate's per-proposal decisions regardless of
1063
+ * the proposal's current lifecycle status — a proposal that was auto-accepted
1064
+ * is now `accepted`, an auto-rejected one stays `pending`, so filtering by
1065
+ * status would drop half the join. Rows without a `gateDecision` (created
1066
+ * before #577, or never gated) are skipped. The result is ordered by
1067
+ * `decidedAt ASC` for deterministic downstream aggregation, falling back to
1068
+ * `created_at` ordering from the SQL layer for rows with equal/missing
1069
+ * timestamps.
1070
+ */
1071
+ export function listProposalGateDecisions(db) {
1072
+ const rows = db.prepare("SELECT metadata_json FROM proposals ORDER BY created_at ASC, rowid ASC").all();
1073
+ const decisions = [];
1074
+ for (const row of rows) {
1075
+ let meta;
1076
+ try {
1077
+ meta = JSON.parse(row.metadata_json);
1078
+ }
1079
+ catch {
1080
+ continue;
1081
+ }
1082
+ const decision = meta.gateDecision;
1083
+ if (decision && typeof decision === "object" && typeof decision.outcome === "string") {
1084
+ decisions.push(decision);
1085
+ }
1086
+ }
1087
+ decisions.sort((a, b) => new Date(a.decidedAt).getTime() - new Date(b.decidedAt).getTime());
1088
+ return decisions;
1089
+ }
1090
+ // ── WS-4: Per-phase gate threshold store (Migration 012) ─────────────────────
1091
+ /**
1092
+ * Read the persisted auto-tuned threshold for a gate phase.
1093
+ *
1094
+ * Returns `undefined` when no row exists yet (first run, or the phase has
1095
+ * never been tuned). The caller falls back to the global `options.autoAccept`
1096
+ * in that case.
1097
+ */
1098
+ export function getPhaseThreshold(db, phase) {
1099
+ const row = db.prepare("SELECT threshold FROM improve_gate_thresholds WHERE phase = ?").get(phase);
1100
+ return row?.threshold;
1101
+ }
1102
+ /**
1103
+ * Persist the auto-tuned threshold for a gate phase.
1104
+ * Uses INSERT OR REPLACE so the call is idempotent (upsert semantics).
1105
+ */
1106
+ export function persistPhaseThreshold(db, phase, threshold) {
1107
+ db.prepare(`INSERT OR REPLACE INTO improve_gate_thresholds (phase, threshold, updated_at)
1108
+ VALUES (?, ?, ?)`).run(phase, Math.round(threshold), Date.now());
1109
+ }
726
1110
  /**
727
1111
  * Look up a single proposal by id, optionally scoped to one stash root.
728
1112
  * Returns undefined when not found.
@@ -785,6 +1169,32 @@ export function insertProposalIfAbsent(db, proposal, stashDir) {
785
1169
  const changes = result.changes ?? 0;
786
1170
  return Number(changes) > 0;
787
1171
  }
1172
+ /**
1173
+ * Run `fn` inside a `BEGIN IMMEDIATE` transaction.
1174
+ *
1175
+ * `db.transaction()` is DEFERRED by default on both Bun and better-sqlite3,
1176
+ * which means two writers can both perform stale preflight reads and only race
1177
+ * when they finally attempt the write. Proposal creation and queue mutation
1178
+ * need the write lock BEFORE those reads so concurrent processes serialize on
1179
+ * the live queue state rather than clobbering each other.
1180
+ */
1181
+ export function withImmediateTransaction(db, fn) {
1182
+ db.exec("BEGIN IMMEDIATE");
1183
+ try {
1184
+ const result = fn();
1185
+ db.exec("COMMIT");
1186
+ return result;
1187
+ }
1188
+ catch (err) {
1189
+ try {
1190
+ db.exec("ROLLBACK");
1191
+ }
1192
+ catch {
1193
+ // Ignore rollback failures so the original error is preserved.
1194
+ }
1195
+ throw err;
1196
+ }
1197
+ }
788
1198
  // ── task_history table helpers ───────────────────────────────────────────────
789
1199
  /**
790
1200
  * Upsert a task history row.
@@ -1082,9 +1492,11 @@ export function purgeOldImproveRuns(db, retentionDays = 90) {
1082
1492
  }
1083
1493
  /**
1084
1494
  * Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
1085
- * row reflects the most recent run; downstream skip-logic compares
1086
- * `session_ended_at` against the live session metadata to decide if anything
1087
- * new arrived since `processed_at`.
1495
+ * row reflects the most recent run. The `content_hash` persisted here is what
1496
+ * the NEXT run compares against (#602): a byte-identical session is skipped, a
1497
+ * changed session is re-processed, and a NULL-backfill row becomes hash-stable
1498
+ * after its one reprocess. `session_ended_at` is still written for
1499
+ * telemetry/forensics but is no longer the skip authority.
1088
1500
  */
1089
1501
  export function upsertExtractedSession(db, input) {
1090
1502
  const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
@@ -1093,9 +1505,10 @@ export function upsertExtractedSession(db, input) {
1093
1505
  db.prepare(`
1094
1506
  INSERT OR REPLACE INTO extract_sessions_seen
1095
1507
  (harness, session_id, processed_at, session_ended_at, outcome,
1096
- candidate_count, proposal_count, rationale, source_run, metadata_json)
1097
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1098
- `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}));
1508
+ candidate_count, proposal_count, rationale, source_run, metadata_json,
1509
+ content_hash)
1510
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1511
+ `).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);
1099
1512
  }
1100
1513
  /**
1101
1514
  * Fetch a single session's last extract record, or `undefined` when the
@@ -1133,31 +1546,279 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
1133
1546
  return out;
1134
1547
  }
1135
1548
  /**
1136
- * Decide whether a session should be skipped because the extractor has
1137
- * already processed it AND nothing has changed since. The "anything new since
1138
- * last extract?" rule is: the live `sessionEndedAtMs` is strictly later than
1139
- * the recorded `session_ended_at`. Same-or-earlier endedAt means we'd be
1140
- * re-processing the exact same content for no gain.
1549
+ * The most recent extract-run time for a harness `MAX(processed_at)` across
1550
+ * its ledger rows, as ms epoch or `null` when the harness has never been
1551
+ * extracted. Used to default the discovery window to "since the last run" so an
1552
+ * intermittently-online host that was off for days still rediscovers sessions
1553
+ * that ended during the gap (the content-hash ledger keeps the widened window
1554
+ * free of redundant LLM cost).
1555
+ */
1556
+ export function getLastExtractRunAt(db, harness) {
1557
+ const row = db
1558
+ .prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?")
1559
+ .get(harness);
1560
+ if (!row?.last)
1561
+ return null;
1562
+ const ms = Date.parse(row.last);
1563
+ return Number.isFinite(ms) ? ms : null;
1564
+ }
1565
+ /**
1566
+ * Decide whether a session should be skipped because the extractor has already
1567
+ * processed BYTE-IDENTICAL content (#602). The skip authority is the content
1568
+ * hash, NOT `session_ended_at` — this is clock-independent, so it is immune to
1569
+ * the clock-skew / out-of-order-endedAt problems that caused the Jun 11-12
1570
+ * double-extract + over-throttle incident.
1141
1571
  *
1142
- * Returns:
1143
- * - `false` — no prior row, or session has new content since last extract.
1144
- * The caller should process it.
1145
- * - `true` — the session was already processed and hasn't been updated.
1146
- * The caller should skip.
1572
+ * Rules:
1573
+ * - no prior row `false` (never seen process; AC3).
1574
+ * - prior.content_hash == null → `false` (legacy / hash-less row → process
1575
+ * exactly once to backfill the hash, then it becomes hash-stable; AC4).
1576
+ * - hashes equal → `true` (unchanged content → skip; AC1).
1577
+ * - hashes differ → `false` (changed content → re-process; AC2).
1147
1578
  */
1148
- export function shouldSkipAlreadyExtractedSession(prior, liveSessionEndedAtMs) {
1579
+ export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
1149
1580
  if (!prior)
1150
1581
  return false;
1151
- // No live timestamp → can't tell if anything's new. Be conservative and
1152
- // skip — the operator can pass --force later if we add it.
1153
- if (typeof liveSessionEndedAtMs !== "number" || !Number.isFinite(liveSessionEndedAtMs)) {
1154
- return true;
1155
- }
1156
- const priorMs = prior.session_ended_at ? Date.parse(prior.session_ended_at) : Number.NaN;
1157
- if (!Number.isFinite(priorMs))
1582
+ if (prior.content_hash == null)
1158
1583
  return false;
1159
- // Re-process when there's new content; skip when the session is unchanged.
1160
- return liveSessionEndedAtMs <= priorMs;
1584
+ return prior.content_hash === currentContentHash;
1585
+ }
1586
+ /**
1587
+ * Bulk-fetch the judged-state cache for a set of entry keys in one query.
1588
+ * Returns a Map keyed by entry_key so the consolidate pool-selection loop can
1589
+ * do O(1) "has this memory been judged at this content hash?" lookups.
1590
+ * Empty input → empty map (no query issued).
1591
+ */
1592
+ export function getConsolidationJudgedMap(db, entryKeys) {
1593
+ const out = new Map();
1594
+ if (entryKeys.length === 0)
1595
+ return out;
1596
+ // SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that.
1597
+ const CHUNK = 500;
1598
+ for (let i = 0; i < entryKeys.length; i += CHUNK) {
1599
+ const chunk = entryKeys.slice(i, i + CHUNK);
1600
+ const placeholders = chunk.map(() => "?").join(",");
1601
+ const rows = db
1602
+ .prepare(`SELECT * FROM consolidation_judged WHERE entry_key IN (${placeholders})`)
1603
+ .all(...chunk);
1604
+ for (const row of rows)
1605
+ out.set(row.entry_key, row);
1606
+ }
1607
+ return out;
1608
+ }
1609
+ /**
1610
+ * Record (or update) the judged state for one memory. INSERT-OR-REPLACE so the
1611
+ * row always reflects the most recent judge of that entry_key. Called once per
1612
+ * memory the consolidate LLM saw in a successfully-judged chunk.
1613
+ */
1614
+ export function upsertConsolidationJudged(db, input) {
1615
+ db.prepare(`
1616
+ INSERT OR REPLACE INTO consolidation_judged
1617
+ (entry_key, content_hash, judged_at, outcome)
1618
+ VALUES (?, ?, ?, ?)
1619
+ `).run(input.entryKey, input.contentHash, input.judgedAt, input.outcome);
1620
+ }
1621
+ /**
1622
+ * Record an induction of a recombine hypothesis and return the new consecutive
1623
+ * count. INSERT … ON CONFLICT increments the streak, but the `last_run` guard
1624
+ * makes a repeated call within the SAME run idempotent (no double-increment if
1625
+ * the same ref appears twice in one run). On insert the streak starts at 1.
1626
+ */
1627
+ export function recordRecombineInduction(db, input) {
1628
+ const row = db
1629
+ .prepare(`
1630
+ INSERT INTO recombine_hypotheses
1631
+ (hypothesis_ref, signature, member_key, consecutive_count, first_seen_at, last_seen_at, last_run)
1632
+ VALUES (?, ?, ?, 1, ?, ?, ?)
1633
+ ON CONFLICT(hypothesis_ref) DO UPDATE SET
1634
+ consecutive_count = consecutive_count + (CASE WHEN last_run IS excluded.last_run THEN 0 ELSE 1 END),
1635
+ last_seen_at = excluded.last_seen_at,
1636
+ last_run = excluded.last_run,
1637
+ signature = excluded.signature,
1638
+ member_key = excluded.member_key
1639
+ RETURNING consecutive_count
1640
+ `)
1641
+ .get(input.hypothesisRef, input.signature, input.memberKey, input.seenAt, input.seenAt, input.run);
1642
+ return row?.consecutive_count ?? 0;
1643
+ }
1644
+ /**
1645
+ * #633 — find an existing pending (non-promoted) hypothesis row whose cluster
1646
+ * is the SAME generalization as a newly-induced one, matched by SIGNATURE plus
1647
+ * a Jaccard membership-overlap test, rather than an exact member-set hash.
1648
+ *
1649
+ * In a growing stash any added/removed memory changes the exact member set, so
1650
+ * the ref hash (and member_key) shift every run → a fresh row at count=1 → the
1651
+ * streak never reaches `confirmThreshold` and nothing ever promotes. Matching
1652
+ * on overlap lets a drifting-but-stable cluster keep accumulating under one row.
1653
+ *
1654
+ * Returns the matched row with the HIGHEST overlap (ties broken by most-recent
1655
+ * `last_seen_at`), or `undefined` when none clears `minOverlap`. Already-promoted
1656
+ * rows are ignored so a confirmed lesson is not reopened by a later induction.
1657
+ *
1658
+ * @param memberKey the candidate cluster's membership fingerprint
1659
+ * (sorted member entryKeys joined by `|`).
1660
+ * @param minOverlap Jaccard threshold in [0,1]; a candidate matches when
1661
+ * |A∩B| / |A∪B| >= minOverlap.
1662
+ */
1663
+ export function findMatchingRecombineHypothesis(db, input) {
1664
+ const candidateMembers = new Set(input.memberKey.split("|").filter((m) => m.length > 0));
1665
+ if (candidateMembers.size === 0)
1666
+ return undefined;
1667
+ const rows = db
1668
+ .prepare("SELECT * FROM recombine_hypotheses WHERE signature = ? AND promoted_at IS NULL ORDER BY last_seen_at DESC")
1669
+ .all(input.signature);
1670
+ let best;
1671
+ let bestOverlap = -1;
1672
+ for (const row of rows) {
1673
+ const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
1674
+ if (rowMembers.length === 0)
1675
+ continue;
1676
+ let intersection = 0;
1677
+ for (const m of rowMembers) {
1678
+ if (candidateMembers.has(m))
1679
+ intersection += 1;
1680
+ }
1681
+ const union = candidateMembers.size + rowMembers.length - intersection;
1682
+ const overlap = union === 0 ? 0 : intersection / union;
1683
+ // rows are ordered last_seen_at DESC, so a strict `>` keeps the most-recent
1684
+ // row on ties.
1685
+ if (overlap >= input.minOverlap && overlap > bestOverlap) {
1686
+ best = row;
1687
+ bestOverlap = overlap;
1688
+ }
1689
+ }
1690
+ return best;
1691
+ }
1692
+ /**
1693
+ * Fetch a single recombine hypothesis row, or `undefined` when the ref has
1694
+ * never been induced. Normalizes bun:sqlite null → undefined like
1695
+ * {@link getExtractedSession}.
1696
+ */
1697
+ export function getRecombineHypothesis(db, hypothesisRef) {
1698
+ const row = db
1699
+ .prepare("SELECT * FROM recombine_hypotheses WHERE hypothesis_ref = ?")
1700
+ .get(hypothesisRef);
1701
+ return row ?? undefined;
1702
+ }
1703
+ /**
1704
+ * Mark a hypothesis promoted: stamp `promoted_at` and reset the consecutive
1705
+ * count to 0, so it must re-accumulate a full confirmation streak before it can
1706
+ * promote again. The `promoted_at` non-null state is the double-promotion guard.
1707
+ */
1708
+ export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
1709
+ db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
1710
+ }
1711
+ /**
1712
+ * Decay-to-zero every NON-promoted hypothesis NOT re-induced in the current run.
1713
+ *
1714
+ * A generalization that stops being supported by the corpus has lost its
1715
+ * confirmation streak, so we hard-reset `consecutive_count` to 0 (the
1716
+ * alternative — `count - 1` floored at 0 — tolerates a single noisy run but
1717
+ * blurs the "consecutive" semantics; hard-reset is the conservative choice).
1718
+ *
1719
+ * Only rows whose `hypothesis_ref` is NOT in `seenRefs` AND whose `last_run` is
1720
+ * NOT the current run are decayed. Already-promoted rows are left alone.
1721
+ * Returns the number of rows reset.
1722
+ */
1723
+ export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
1724
+ // Reset every eligible row, then exclude the seen refs in chunks to respect
1725
+ // the ~999 SQLite param ceiling. With no seen refs we reset all non-promoted
1726
+ // rows from prior runs in a single statement.
1727
+ if (seenRefs.length === 0) {
1728
+ const res = db
1729
+ .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")
1730
+ .run(currentRun);
1731
+ return Number(res.changes);
1732
+ }
1733
+ // A single NOT IN keeps the exclusion atomic (a chunked NOT IN would let a ref
1734
+ // excluded by one chunk still be reset by another chunk's statement). The
1735
+ // recombine pass caps re-induced clusters at `maxClustersPerRun` (a handful),
1736
+ // so `seenRefs` is far under SQLite's ~999 param ceiling in practice; we cap
1737
+ // defensively and fall back to resetting all when somehow exceeded.
1738
+ if (seenRefs.length > 900) {
1739
+ const res = db
1740
+ .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")
1741
+ .run(currentRun);
1742
+ return Number(res.changes);
1743
+ }
1744
+ const placeholders = seenRefs.map(() => "?").join(",");
1745
+ const res = db
1746
+ .prepare(`UPDATE recombine_hypotheses SET consecutive_count = 0
1747
+ WHERE promoted_at IS NULL
1748
+ AND (last_run IS NULL OR last_run != ?)
1749
+ AND consecutive_count != 0
1750
+ AND hypothesis_ref NOT IN (${placeholders})`)
1751
+ .run(currentRun, ...seenRefs);
1752
+ return Number(res.changes);
1753
+ }
1754
+ /**
1755
+ * Convert a `number[]` embedding vector to the `Float32Array` byte
1756
+ * representation stored in the `body_embeddings.embedding` BLOB column.
1757
+ */
1758
+ export function embeddingToBlob(vec) {
1759
+ const f32 = new Float32Array(vec);
1760
+ return new Uint8Array(f32.buffer);
1761
+ }
1762
+ /**
1763
+ * Convert the raw `Uint8Array` bytes from the `body_embeddings.embedding`
1764
+ * BLOB column back to a `number[]` embedding vector.
1765
+ */
1766
+ export function blobToEmbedding(blob) {
1767
+ // SQLite BLOB columns are returned as Uint8Array; re-interpret as Float32.
1768
+ const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
1769
+ return Array.from(f32);
1770
+ }
1771
+ /**
1772
+ * Bulk-fetch cached body embeddings for a set of content hashes.
1773
+ * Returns a Map keyed by `content_hash` (embedding decoded to `number[]`).
1774
+ * Empty input → empty map (no query issued).
1775
+ *
1776
+ * If the stored `model_id` does not match `expectedModelId` the entire table
1777
+ * is cleared (drop-all on model mismatch) and an empty map is returned so
1778
+ * callers re-embed everything on this run.
1779
+ */
1780
+ export function getBodyEmbeddings(db, contentHashes, expectedModelId) {
1781
+ const out = new Map();
1782
+ if (contentHashes.length === 0)
1783
+ return out;
1784
+ // Model-id mismatch: vectors are in the wrong metric space — drop all rows.
1785
+ const firstRow = db.prepare("SELECT model_id FROM body_embeddings LIMIT 1").get();
1786
+ if (firstRow && firstRow.model_id !== expectedModelId) {
1787
+ db.exec("DELETE FROM body_embeddings");
1788
+ return out;
1789
+ }
1790
+ // SQLite has a ~999 param ceiling; chunk if needed.
1791
+ const CHUNK = 500;
1792
+ for (let i = 0; i < contentHashes.length; i += CHUNK) {
1793
+ const chunk = contentHashes.slice(i, i + CHUNK);
1794
+ const placeholders = chunk.map(() => "?").join(",");
1795
+ const rows = db
1796
+ .prepare(`SELECT content_hash, embedding FROM body_embeddings WHERE content_hash IN (${placeholders})`)
1797
+ .all(...chunk);
1798
+ for (const row of rows) {
1799
+ out.set(row.content_hash, blobToEmbedding(row.embedding));
1800
+ }
1801
+ }
1802
+ return out;
1803
+ }
1804
+ /**
1805
+ * Upsert body-embedding rows in a single transaction.
1806
+ * Each entry maps a `cacheHash` → `number[]` vector. `model_id` is stored
1807
+ * so a future model change can trigger a drop-all purge.
1808
+ */
1809
+ export function upsertBodyEmbeddings(db, entries) {
1810
+ if (entries.length === 0)
1811
+ return;
1812
+ const now = Date.now();
1813
+ const stmt = db.prepare(`
1814
+ INSERT OR REPLACE INTO body_embeddings (content_hash, embedding, model_id, created_at)
1815
+ VALUES (?, ?, ?, ?)
1816
+ `);
1817
+ db.transaction(() => {
1818
+ for (const { contentHash, embedding, modelId } of entries) {
1819
+ stmt.run(contentHash, embeddingToBlob(embedding), modelId, now);
1820
+ }
1821
+ })();
1161
1822
  }
1162
1823
  // ── registry_index_cache (goes in index.db, not state.db) ───────────────────
1163
1824
  /**