akm-cli 0.9.0-beta.3 → 0.9.0-beta.31
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.
- package/CHANGELOG.md +613 -0
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +5 -1
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/cli.js +14 -3
- package/dist/commands/agent/contribute-cli.js +16 -3
- package/dist/commands/feedback-cli.js +15 -6
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +422 -80
- package/dist/commands/health.js +381 -9
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate.js +634 -111
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +145 -69
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +115 -1
- package/dist/commands/improve/extract-prompt.js +33 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +244 -35
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/homeostatic.js +467 -0
- package/dist/commands/improve/improve-auto-accept.js +113 -6
- package/dist/commands/improve/improve-profiles.js +12 -0
- package/dist/commands/improve/improve.js +1974 -614
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/proactive-maintenance.js +87 -0
- package/dist/commands/improve/procedural.js +409 -0
- package/dist/commands/improve/recombine.js +593 -0
- package/dist/commands/improve/reflect.js +26 -1
- package/dist/commands/improve/related-sessions.js +120 -0
- package/dist/commands/improve/salience.js +386 -0
- package/dist/commands/improve/triage.js +95 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +34 -1
- package/dist/commands/lint/fact-linter.js +39 -0
- package/dist/commands/lint/index.js +31 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +7 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/proposal/proposal.js +5 -0
- package/dist/commands/proposal/validators/proposals.js +71 -54
- package/dist/commands/read/curate.js +344 -80
- package/dist/commands/read/search-cli.js +7 -0
- package/dist/commands/read/search.js +1 -0
- package/dist/commands/read/show.js +67 -2
- package/dist/commands/sources/installed-stashes.js +5 -1
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/core/asset/asset-registry.js +2 -0
- package/dist/core/asset/asset-spec.js +14 -0
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/config/config-schema.js +259 -2
- package/dist/core/config/config.js +2 -2
- package/dist/core/logs-db.js +4 -3
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +649 -30
- package/dist/indexer/db/db.js +364 -38
- package/dist/indexer/db/graph-db.js +129 -86
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/graph/graph-extraction.js +203 -3
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +10 -3
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +15 -4
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/prompts.js +1 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/opencode/session-log.js +9 -0
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/client.js +23 -4
- package/dist/llm/embedder.js +27 -3
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/graph-extract.js +2 -1
- package/dist/llm/memory-infer.js +4 -8
- package/dist/llm/metadata-enhance.js +9 -1
- package/dist/output/renderers.js +73 -1
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/text/helpers.js +9 -0
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1242 -594
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +473 -270
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +1 -0
- package/package.json +9 -6
package/dist/core/state-db.js
CHANGED
|
@@ -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
|
|
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,310 @@ 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
|
+
},
|
|
491
794
|
];
|
|
492
795
|
/**
|
|
493
796
|
* Apply every pending migration in a single transaction per migration.
|
|
@@ -562,6 +865,9 @@ export function proposalRowToProposal(row) {
|
|
|
562
865
|
...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
|
|
563
866
|
...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
|
|
564
867
|
...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
|
|
868
|
+
...(typeof meta.eligibilitySource === "string"
|
|
869
|
+
? { eligibilitySource: meta.eligibilitySource }
|
|
870
|
+
: {}),
|
|
565
871
|
};
|
|
566
872
|
}
|
|
567
873
|
/**
|
|
@@ -581,6 +887,8 @@ export function proposalToRowValues(proposal, stashDir) {
|
|
|
581
887
|
metaObj.gateDecision = proposal.gateDecision;
|
|
582
888
|
if (proposal.backupContent !== undefined)
|
|
583
889
|
metaObj.backupContent = proposal.backupContent;
|
|
890
|
+
if (proposal.eligibilitySource !== undefined)
|
|
891
|
+
metaObj.eligibilitySource = proposal.eligibilitySource;
|
|
584
892
|
return {
|
|
585
893
|
id: proposal.id,
|
|
586
894
|
stash_dir: stashDir,
|
|
@@ -723,6 +1031,57 @@ export function listStateProposals(db, options = {}) {
|
|
|
723
1031
|
.all(...params);
|
|
724
1032
|
return rows.map(proposalRowToProposal);
|
|
725
1033
|
}
|
|
1034
|
+
/**
|
|
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.
|
|
1045
|
+
*/
|
|
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
|
+
}
|
|
726
1085
|
/**
|
|
727
1086
|
* Look up a single proposal by id, optionally scoped to one stash root.
|
|
728
1087
|
* Returns undefined when not found.
|
|
@@ -785,6 +1144,32 @@ export function insertProposalIfAbsent(db, proposal, stashDir) {
|
|
|
785
1144
|
const changes = result.changes ?? 0;
|
|
786
1145
|
return Number(changes) > 0;
|
|
787
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
|
+
}
|
|
788
1173
|
// ── task_history table helpers ───────────────────────────────────────────────
|
|
789
1174
|
/**
|
|
790
1175
|
* Upsert a task history row.
|
|
@@ -1082,9 +1467,11 @@ export function purgeOldImproveRuns(db, retentionDays = 90) {
|
|
|
1082
1467
|
}
|
|
1083
1468
|
/**
|
|
1084
1469
|
* Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
|
|
1085
|
-
* row reflects the most recent run
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
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.
|
|
1088
1475
|
*/
|
|
1089
1476
|
export function upsertExtractedSession(db, input) {
|
|
1090
1477
|
const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
|
|
@@ -1093,9 +1480,10 @@ export function upsertExtractedSession(db, input) {
|
|
|
1093
1480
|
db.prepare(`
|
|
1094
1481
|
INSERT OR REPLACE INTO extract_sessions_seen
|
|
1095
1482
|
(harness, session_id, processed_at, session_ended_at, outcome,
|
|
1096
|
-
candidate_count, proposal_count, rationale, source_run, metadata_json
|
|
1097
|
-
|
|
1098
|
-
|
|
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);
|
|
1099
1487
|
}
|
|
1100
1488
|
/**
|
|
1101
1489
|
* Fetch a single session's last extract record, or `undefined` when the
|
|
@@ -1133,31 +1521,262 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
|
|
|
1133
1521
|
return out;
|
|
1134
1522
|
}
|
|
1135
1523
|
/**
|
|
1136
|
-
* Decide whether a session should be skipped because the extractor has
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
* the
|
|
1140
|
-
*
|
|
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.
|
|
1141
1529
|
*
|
|
1142
|
-
*
|
|
1143
|
-
* -
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
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).
|
|
1147
1536
|
*/
|
|
1148
|
-
export function shouldSkipAlreadyExtractedSession(prior,
|
|
1537
|
+
export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
|
|
1149
1538
|
if (!prior)
|
|
1150
1539
|
return false;
|
|
1151
|
-
|
|
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))
|
|
1540
|
+
if (prior.content_hash == null)
|
|
1158
1541
|
return false;
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
+
* #633 — find an existing pending (non-promoted) hypothesis row whose cluster
|
|
1604
|
+
* is the SAME generalization as a newly-induced one, matched by SIGNATURE plus
|
|
1605
|
+
* a Jaccard membership-overlap test, rather than an exact member-set hash.
|
|
1606
|
+
*
|
|
1607
|
+
* In a growing stash any added/removed memory changes the exact member set, so
|
|
1608
|
+
* the ref hash (and member_key) shift every run → a fresh row at count=1 → the
|
|
1609
|
+
* streak never reaches `confirmThreshold` and nothing ever promotes. Matching
|
|
1610
|
+
* on overlap lets a drifting-but-stable cluster keep accumulating under one row.
|
|
1611
|
+
*
|
|
1612
|
+
* Returns the matched row with the HIGHEST overlap (ties broken by most-recent
|
|
1613
|
+
* `last_seen_at`), or `undefined` when none clears `minOverlap`. Already-promoted
|
|
1614
|
+
* rows are ignored so a confirmed lesson is not reopened by a later induction.
|
|
1615
|
+
*
|
|
1616
|
+
* @param memberKey the candidate cluster's membership fingerprint
|
|
1617
|
+
* (sorted member entryKeys joined by `|`).
|
|
1618
|
+
* @param minOverlap Jaccard threshold in [0,1]; a candidate matches when
|
|
1619
|
+
* |A∩B| / |A∪B| >= minOverlap.
|
|
1620
|
+
*/
|
|
1621
|
+
export function findMatchingRecombineHypothesis(db, input) {
|
|
1622
|
+
const candidateMembers = new Set(input.memberKey.split("|").filter((m) => m.length > 0));
|
|
1623
|
+
if (candidateMembers.size === 0)
|
|
1624
|
+
return undefined;
|
|
1625
|
+
const rows = db
|
|
1626
|
+
.prepare("SELECT * FROM recombine_hypotheses WHERE signature = ? AND promoted_at IS NULL ORDER BY last_seen_at DESC")
|
|
1627
|
+
.all(input.signature);
|
|
1628
|
+
let best;
|
|
1629
|
+
let bestOverlap = -1;
|
|
1630
|
+
for (const row of rows) {
|
|
1631
|
+
const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
|
|
1632
|
+
if (rowMembers.length === 0)
|
|
1633
|
+
continue;
|
|
1634
|
+
let intersection = 0;
|
|
1635
|
+
for (const m of rowMembers) {
|
|
1636
|
+
if (candidateMembers.has(m))
|
|
1637
|
+
intersection += 1;
|
|
1638
|
+
}
|
|
1639
|
+
const union = candidateMembers.size + rowMembers.length - intersection;
|
|
1640
|
+
const overlap = union === 0 ? 0 : intersection / union;
|
|
1641
|
+
// rows are ordered last_seen_at DESC, so a strict `>` keeps the most-recent
|
|
1642
|
+
// row on ties.
|
|
1643
|
+
if (overlap >= input.minOverlap && overlap > bestOverlap) {
|
|
1644
|
+
best = row;
|
|
1645
|
+
bestOverlap = overlap;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return best;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Fetch a single recombine hypothesis row, or `undefined` when the ref has
|
|
1652
|
+
* never been induced. Normalizes bun:sqlite null → undefined like
|
|
1653
|
+
* {@link getExtractedSession}.
|
|
1654
|
+
*/
|
|
1655
|
+
export function getRecombineHypothesis(db, hypothesisRef) {
|
|
1656
|
+
const row = db
|
|
1657
|
+
.prepare("SELECT * FROM recombine_hypotheses WHERE hypothesis_ref = ?")
|
|
1658
|
+
.get(hypothesisRef);
|
|
1659
|
+
return row ?? undefined;
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Mark a hypothesis promoted: stamp `promoted_at` and reset the consecutive
|
|
1663
|
+
* count to 0, so it must re-accumulate a full confirmation streak before it can
|
|
1664
|
+
* promote again. The `promoted_at` non-null state is the double-promotion guard.
|
|
1665
|
+
*/
|
|
1666
|
+
export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
|
|
1667
|
+
db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Decay-to-zero every NON-promoted hypothesis NOT re-induced in the current run.
|
|
1671
|
+
*
|
|
1672
|
+
* A generalization that stops being supported by the corpus has lost its
|
|
1673
|
+
* confirmation streak, so we hard-reset `consecutive_count` to 0 (the
|
|
1674
|
+
* alternative — `count - 1` floored at 0 — tolerates a single noisy run but
|
|
1675
|
+
* blurs the "consecutive" semantics; hard-reset is the conservative choice).
|
|
1676
|
+
*
|
|
1677
|
+
* Only rows whose `hypothesis_ref` is NOT in `seenRefs` AND whose `last_run` is
|
|
1678
|
+
* NOT the current run are decayed. Already-promoted rows are left alone.
|
|
1679
|
+
* Returns the number of rows reset.
|
|
1680
|
+
*/
|
|
1681
|
+
export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
|
|
1682
|
+
// Reset every eligible row, then exclude the seen refs in chunks to respect
|
|
1683
|
+
// the ~999 SQLite param ceiling. With no seen refs we reset all non-promoted
|
|
1684
|
+
// rows from prior runs in a single statement.
|
|
1685
|
+
if (seenRefs.length === 0) {
|
|
1686
|
+
const res = db
|
|
1687
|
+
.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")
|
|
1688
|
+
.run(currentRun);
|
|
1689
|
+
return Number(res.changes);
|
|
1690
|
+
}
|
|
1691
|
+
// A single NOT IN keeps the exclusion atomic (a chunked NOT IN would let a ref
|
|
1692
|
+
// excluded by one chunk still be reset by another chunk's statement). The
|
|
1693
|
+
// recombine pass caps re-induced clusters at `maxClustersPerRun` (a handful),
|
|
1694
|
+
// so `seenRefs` is far under SQLite's ~999 param ceiling in practice; we cap
|
|
1695
|
+
// defensively and fall back to resetting all when somehow exceeded.
|
|
1696
|
+
if (seenRefs.length > 900) {
|
|
1697
|
+
const res = db
|
|
1698
|
+
.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")
|
|
1699
|
+
.run(currentRun);
|
|
1700
|
+
return Number(res.changes);
|
|
1701
|
+
}
|
|
1702
|
+
const placeholders = seenRefs.map(() => "?").join(",");
|
|
1703
|
+
const res = db
|
|
1704
|
+
.prepare(`UPDATE recombine_hypotheses SET consecutive_count = 0
|
|
1705
|
+
WHERE promoted_at IS NULL
|
|
1706
|
+
AND (last_run IS NULL OR last_run != ?)
|
|
1707
|
+
AND consecutive_count != 0
|
|
1708
|
+
AND hypothesis_ref NOT IN (${placeholders})`)
|
|
1709
|
+
.run(currentRun, ...seenRefs);
|
|
1710
|
+
return Number(res.changes);
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Convert a `number[]` embedding vector to the `Float32Array` byte
|
|
1714
|
+
* representation stored in the `body_embeddings.embedding` BLOB column.
|
|
1715
|
+
*/
|
|
1716
|
+
export function embeddingToBlob(vec) {
|
|
1717
|
+
const f32 = new Float32Array(vec);
|
|
1718
|
+
return new Uint8Array(f32.buffer);
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Convert the raw `Uint8Array` bytes from the `body_embeddings.embedding`
|
|
1722
|
+
* BLOB column back to a `number[]` embedding vector.
|
|
1723
|
+
*/
|
|
1724
|
+
export function blobToEmbedding(blob) {
|
|
1725
|
+
// SQLite BLOB columns are returned as Uint8Array; re-interpret as Float32.
|
|
1726
|
+
const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
|
|
1727
|
+
return Array.from(f32);
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Bulk-fetch cached body embeddings for a set of content hashes.
|
|
1731
|
+
* Returns a Map keyed by `content_hash` (embedding decoded to `number[]`).
|
|
1732
|
+
* Empty input → empty map (no query issued).
|
|
1733
|
+
*
|
|
1734
|
+
* If the stored `model_id` does not match `expectedModelId` the entire table
|
|
1735
|
+
* is cleared (drop-all on model mismatch) and an empty map is returned so
|
|
1736
|
+
* callers re-embed everything on this run.
|
|
1737
|
+
*/
|
|
1738
|
+
export function getBodyEmbeddings(db, contentHashes, expectedModelId) {
|
|
1739
|
+
const out = new Map();
|
|
1740
|
+
if (contentHashes.length === 0)
|
|
1741
|
+
return out;
|
|
1742
|
+
// Model-id mismatch: vectors are in the wrong metric space — drop all rows.
|
|
1743
|
+
const firstRow = db.prepare("SELECT model_id FROM body_embeddings LIMIT 1").get();
|
|
1744
|
+
if (firstRow && firstRow.model_id !== expectedModelId) {
|
|
1745
|
+
db.exec("DELETE FROM body_embeddings");
|
|
1746
|
+
return out;
|
|
1747
|
+
}
|
|
1748
|
+
// SQLite has a ~999 param ceiling; chunk if needed.
|
|
1749
|
+
const CHUNK = 500;
|
|
1750
|
+
for (let i = 0; i < contentHashes.length; i += CHUNK) {
|
|
1751
|
+
const chunk = contentHashes.slice(i, i + CHUNK);
|
|
1752
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
1753
|
+
const rows = db
|
|
1754
|
+
.prepare(`SELECT content_hash, embedding FROM body_embeddings WHERE content_hash IN (${placeholders})`)
|
|
1755
|
+
.all(...chunk);
|
|
1756
|
+
for (const row of rows) {
|
|
1757
|
+
out.set(row.content_hash, blobToEmbedding(row.embedding));
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return out;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Upsert body-embedding rows in a single transaction.
|
|
1764
|
+
* Each entry maps a `cacheHash` → `number[]` vector. `model_id` is stored
|
|
1765
|
+
* so a future model change can trigger a drop-all purge.
|
|
1766
|
+
*/
|
|
1767
|
+
export function upsertBodyEmbeddings(db, entries) {
|
|
1768
|
+
if (entries.length === 0)
|
|
1769
|
+
return;
|
|
1770
|
+
const now = Date.now();
|
|
1771
|
+
const stmt = db.prepare(`
|
|
1772
|
+
INSERT OR REPLACE INTO body_embeddings (content_hash, embedding, model_id, created_at)
|
|
1773
|
+
VALUES (?, ?, ?, ?)
|
|
1774
|
+
`);
|
|
1775
|
+
db.transaction(() => {
|
|
1776
|
+
for (const { contentHash, embedding, modelId } of entries) {
|
|
1777
|
+
stmt.run(contentHash, embeddingToBlob(embedding), modelId, now);
|
|
1778
|
+
}
|
|
1779
|
+
})();
|
|
1161
1780
|
}
|
|
1162
1781
|
// ── registry_index_cache (goes in index.db, not state.db) ───────────────────
|
|
1163
1782
|
/**
|