akm-cli 0.9.0-beta.5 → 0.9.0-beta.51
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 +711 -0
- package/README.md +12 -4
- package/dist/akm +38 -0
- package/dist/akm-migrate-storage +38 -0
- package/dist/assets/profiles/default.json +9 -4
- package/dist/assets/profiles/frequent.json +1 -1
- package/dist/assets/profiles/memory-focus.json +1 -1
- package/dist/assets/profiles/quick.json +1 -1
- package/dist/assets/profiles/synthesize.json +15 -0
- package/dist/assets/profiles/thorough.json +1 -1
- 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 +6 -2
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
- 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/stash-skeleton/facts/conventions/assets/agent.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/assets/wiki/ingest-workflow-template.md +38 -10
- package/dist/cli/parse-args.js +46 -1
- package/dist/cli/shared.js +28 -0
- package/dist/cli.js +27 -11
- package/dist/commands/agent/agent-dispatch.js +2 -2
- package/dist/commands/agent/agent-support.js +0 -7
- package/dist/commands/agent/contribute-cli.js +17 -4
- package/dist/commands/config-cli.js +18 -2
- package/dist/commands/env/child-env.js +47 -0
- package/dist/commands/env/env-cli.js +33 -26
- package/dist/commands/env/secret-cli.js +36 -22
- package/dist/commands/feedback-cli.js +15 -6
- package/dist/commands/graph/graph-cli.js +5 -13
- package/dist/commands/graph/graph.js +76 -72
- package/dist/commands/health/checks.js +49 -1
- package/dist/commands/health/html-report.js +422 -80
- package/dist/commands/health.js +386 -9
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate/chunking.js +141 -0
- package/dist/commands/improve/consolidate/eligibility.js +81 -0
- package/dist/commands/improve/consolidate/merge.js +145 -0
- package/dist/commands/improve/consolidate/sanitize.js +231 -0
- package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
- package/dist/commands/improve/consolidate.js +635 -660
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +159 -69
- package/dist/commands/improve/eligibility.js +434 -0
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +124 -2
- package/dist/commands/improve/extract-prompt.js +39 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +389 -40
- 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 +138 -7
- package/dist/commands/improve/improve-cli.js +36 -61
- package/dist/commands/improve/improve-profiles.js +14 -0
- package/dist/commands/improve/improve-result-file.js +14 -25
- package/dist/commands/improve/improve-session.js +58 -0
- package/dist/commands/improve/improve.js +485 -2498
- package/dist/commands/improve/locks.js +154 -0
- package/dist/commands/improve/loop-stages.js +1083 -0
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/preparation.js +1966 -0
- package/dist/commands/improve/proactive-maintenance.js +115 -0
- package/dist/commands/improve/procedural.js +418 -0
- package/dist/commands/improve/recombine.js +850 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +183 -40
- package/dist/commands/improve/salience.js +438 -0
- package/dist/commands/improve/triage.js +93 -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 +38 -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/drain-policies.js +5 -0
- package/dist/commands/proposal/drain.js +43 -50
- package/dist/commands/proposal/proposal-cli.js +21 -31
- package/dist/commands/proposal/proposal.js +5 -0
- package/dist/commands/proposal/propose.js +7 -2
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +189 -63
- package/dist/commands/read/curate.js +414 -94
- package/dist/commands/read/knowledge.js +6 -3
- package/dist/commands/read/search-cli.js +9 -4
- package/dist/commands/read/search.js +10 -6
- package/dist/commands/read/show.js +86 -7
- package/dist/commands/sources/init.js +49 -17
- package/dist/commands/sources/installed-stashes.js +11 -3
- package/dist/commands/sources/schema-repair.js +43 -45
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/stash-cli.js +28 -40
- package/dist/commands/sources/stash-skeleton.js +23 -8
- package/dist/commands/tasks/tasks-cli.js +19 -27
- package/dist/commands/tasks/tasks.js +39 -11
- package/dist/commands/wiki-cli.js +21 -35
- package/dist/core/asset/asset-registry.js +3 -1
- package/dist/core/asset/asset-spec.js +18 -2
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +92 -0
- package/dist/core/common.js +0 -5
- package/dist/core/config/config-migration.js +12 -11
- package/dist/core/config/config-schema.js +340 -56
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config.js +28 -7
- package/dist/core/events.js +3 -7
- package/dist/core/improve-types.js +11 -8
- package/dist/core/logs-db.js +10 -66
- package/dist/core/parse.js +36 -16
- package/dist/core/paths.js +3 -0
- package/dist/core/standards/resolve-standards-context.js +87 -0
- package/dist/core/standards/resolve-stash-standards.js +99 -0
- package/dist/core/standards/resolve-type-conventions.js +66 -0
- package/dist/core/state/migrations.js +714 -0
- package/dist/core/state-db.js +525 -474
- package/dist/indexer/db/db.js +439 -247
- 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 +218 -4
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +123 -221
- package/dist/indexer/passes/dir-staleness.js +114 -0
- package/dist/indexer/passes/memory-inference.js +13 -5
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +19 -6
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/indexer/search/search-source.js +17 -18
- package/dist/indexer/search/semantic-status.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/config.js +6 -53
- package/dist/integrations/agent/index.js +2 -18
- package/dist/integrations/agent/prompts.js +75 -9
- package/dist/integrations/agent/runner-dispatch.js +59 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/index.js +2 -3
- package/dist/integrations/harnesses/opencode/session-log.js +173 -3
- package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/client.js +45 -15
- package/dist/llm/embedder.js +42 -3
- package/dist/llm/embedders/deterministic.js +66 -0
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/feature-gate.js +8 -4
- package/dist/llm/graph-extract.js +67 -44
- package/dist/llm/memory-infer-impl.js +138 -0
- package/dist/llm/memory-infer.js +1 -127
- package/dist/llm/metadata-enhance.js +44 -31
- package/dist/llm/structured-call.js +49 -0
- package/dist/migrate-storage-node.mjs +8 -0
- package/dist/output/context.js +5 -5
- package/dist/output/renderers.js +74 -2
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/shapes/passthrough.js +0 -1
- package/dist/output/text/helpers.js +16 -1
- package/dist/registry/providers/skills-sh.js +21 -147
- package/dist/registry/providers/static-index.js +15 -157
- package/dist/registry/resolve.js +22 -9
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +2617 -1961
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
- package/dist/setup/setup.js +29 -8
- package/dist/sources/include.js +6 -2
- package/dist/sources/providers/filesystem.js +0 -1
- package/dist/sources/providers/git-install.js +210 -0
- package/dist/sources/providers/git-provider.js +234 -0
- package/dist/sources/providers/git-stash.js +248 -0
- package/dist/sources/providers/git.js +10 -661
- package/dist/sources/providers/npm.js +2 -6
- package/dist/sources/providers/provider-utils.js +13 -7
- package/dist/sources/providers/sync-from-ref.js +9 -1
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/sources/providers/website.js +9 -5
- package/dist/sources/website-ingest.js +187 -29
- package/dist/sources/wiki-fetchers/registry.js +53 -0
- package/dist/sources/wiki-fetchers/youtube.js +239 -0
- package/dist/storage/database.js +45 -10
- package/dist/storage/managed-db.js +82 -0
- package/dist/storage/repositories/registry-cache.js +92 -0
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/tasks/backends/cron.js +1 -1
- package/dist/tasks/backends/launchd.js +1 -1
- package/dist/tasks/backends/schtasks.js +1 -1
- package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
- package/dist/tasks/runner.js +5 -13
- package/dist/text-import-hook.mjs +0 -0
- package/dist/wiki/wiki.js +37 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/runtime/runs.js +1 -117
- package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +3 -2
- package/docs/migration/release-notes/0.9.0.md +39 -0
- package/package.json +13 -11
- package/dist/commands/db-cli.js +0 -23
- package/dist/indexer/db/db-backup.js +0 -376
package/dist/core/state-db.js
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ## Why a separate database from index.db
|
|
14
14
|
*
|
|
15
|
-
* index.db
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* task history are NON-REGENERABLE — losing them is data loss. They
|
|
19
|
-
* a database whose schema evolves via incremental, additive migrations
|
|
20
|
-
* drop rows.
|
|
15
|
+
* index.db is a derived cache built by an idempotent baseline schema; it is fully
|
|
16
|
+
* regenerable from the stash on disk, so a corrupt index is recovered by deleting
|
|
17
|
+
* it and re-running `akm index` (no destructive version-bump rebuild). Events,
|
|
18
|
+
* proposals, and task history are NON-REGENERABLE — losing them is data loss. They
|
|
19
|
+
* must live in a database whose schema evolves via incremental, additive migrations
|
|
20
|
+
* that never drop rows.
|
|
21
21
|
*
|
|
22
22
|
* ## Migration-safety contract
|
|
23
23
|
*
|
|
@@ -49,10 +49,8 @@
|
|
|
49
49
|
*
|
|
50
50
|
* @module state-db
|
|
51
51
|
*/
|
|
52
|
-
import fs from "node:fs";
|
|
53
52
|
import path from "node:path";
|
|
54
|
-
import {
|
|
55
|
-
import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
|
|
53
|
+
import { openManagedDatabase, withManagedDb, withManagedDbAsync } from "../storage/managed-db.js";
|
|
56
54
|
import { classifyImproveAction } from "./improve-types.js";
|
|
57
55
|
import { getDataDir } from "./paths.js";
|
|
58
56
|
import { error } from "./warn.js";
|
|
@@ -94,412 +92,33 @@ export function getStateDbPath() {
|
|
|
94
92
|
* narrow when a post-inference reindex overlapped a parallel event write.
|
|
95
93
|
*/
|
|
96
94
|
export function openStateDatabase(dbPath) {
|
|
97
|
-
|
|
98
|
-
const dir = path.dirname(resolvedPath);
|
|
99
|
-
if (!fs.existsSync(dir)) {
|
|
100
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
-
}
|
|
102
|
-
const db = openDatabase(resolvedPath);
|
|
103
|
-
// 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");
|
|
107
|
-
runMigrations(db);
|
|
108
|
-
return db;
|
|
95
|
+
return openManagedDatabase({ path: dbPath ?? getStateDbPath(), init: runMigrations });
|
|
109
96
|
}
|
|
110
|
-
// ── Migration engine ─────────────────────────────────────────────────────────
|
|
111
|
-
//
|
|
112
|
-
// The runner itself (ensureMigrationsTable + runMigrations) lives in the shared
|
|
113
|
-
// engine at src/storage/engines/sqlite-migrations.ts. This module owns only its
|
|
114
|
-
// own MIGRATIONS array and delegates application to that shared runner. The
|
|
115
|
-
// {@link Migration} interface is imported from there.
|
|
116
97
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
98
|
+
* Run `fn` against state.db, owning the handle unless one is borrowed. The loan
|
|
99
|
+
* helper for state.db, mirroring `withIndexDb` / `withWorkflowRunsRepo`. Pass
|
|
100
|
+
* `{ borrowed: ctx?.db }` to reuse an already-open run-scoped handle rather than
|
|
101
|
+
* opening + closing a fresh one — this replaces the hand-rolled
|
|
102
|
+
* `ctx?.db ?? open()` + `ownsDb` flag + `finally`/close idiom at call sites.
|
|
121
103
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
id: "001-initial-schema",
|
|
126
|
-
up: `
|
|
127
|
-
-- ── events ──────────────────────────────────────────────────────────────
|
|
128
|
-
--
|
|
129
|
-
-- Replaces events.jsonl. Indexed (query) columns:
|
|
130
|
-
-- id INTEGER PK — monotonic rowid; replaces byte-offset cursor.
|
|
131
|
-
-- Callers store this as "sinceId" for resume.
|
|
132
|
-
-- event_type TEXT — indexed; replaces the type filter in readEvents().
|
|
133
|
-
-- ts TEXT — ISO-8601 UTC ms; indexed for range queries.
|
|
134
|
-
-- ref TEXT — nullable asset ref; indexed for ref-scoped queries.
|
|
135
|
-
--
|
|
136
|
-
-- Extensible (metadata_json) columns:
|
|
137
|
-
-- metadata_json TEXT — JSON object storing all non-indexed payload
|
|
138
|
-
-- fields (tags, any future structured fields).
|
|
139
|
-
-- Maps directly to EventEnvelope.metadata.
|
|
140
|
-
--
|
|
141
|
-
-- schema_version mirrors EventEnvelope.schemaVersion — always 1 for v1
|
|
142
|
-
-- rows. Stored as a column (not in the JSON blob) so future schema
|
|
143
|
-
-- changes can be detected and migrated row-by-row if ever needed.
|
|
144
|
-
--
|
|
145
|
-
-- TTL: rows where ts < NOW() - 90 days can be deleted by a maintenance job.
|
|
146
|
-
-- No automatic deletion occurs here — callers call purgeOldEvents().
|
|
147
|
-
--
|
|
148
|
-
-- ADD COLUMN extension points (future migrations):
|
|
149
|
-
-- ALTER TABLE events ADD COLUMN stash_dir TEXT DEFAULT NULL;
|
|
150
|
-
-- ALTER TABLE events ADD COLUMN correlation_id TEXT DEFAULT NULL;
|
|
151
|
-
-- ALTER TABLE events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;
|
|
152
|
-
--
|
|
153
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
154
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
|
-
event_type TEXT NOT NULL,
|
|
156
|
-
ts TEXT NOT NULL,
|
|
157
|
-
ref TEXT,
|
|
158
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
-- Query patterns supported by these indexes:
|
|
162
|
-
-- SELECT … WHERE event_type = ? → idx_events_type
|
|
163
|
-
-- SELECT … WHERE ref = ? → idx_events_ref
|
|
164
|
-
-- SELECT … WHERE ts >= ? AND ts <= ? → idx_events_ts
|
|
165
|
-
-- SELECT … WHERE event_type = ? AND ref = ? → idx_events_type (prefix scan) + filter
|
|
166
|
-
-- SELECT … WHERE id > ? → PK (rowid) — no extra index needed
|
|
167
|
-
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
|
168
|
-
CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
|
|
169
|
-
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
170
|
-
|
|
171
|
-
-- ── proposals ────────────────────────────────────────────────────────────
|
|
172
|
-
--
|
|
173
|
-
-- Replaces per-uuid JSON directories under <stashDir>/.akm/proposals/.
|
|
174
|
-
--
|
|
175
|
-
-- Indexed (query) columns:
|
|
176
|
-
-- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
|
|
177
|
-
-- stash_dir TEXT — absolute stash root; multi-stash installs need
|
|
178
|
-
-- this to partition proposal lists per stash.
|
|
179
|
-
-- ref TEXT — target asset ref (e.g. "lesson:alpha");
|
|
180
|
-
-- indexed for ref-scoped queue views.
|
|
181
|
-
-- status TEXT — "pending" | "accepted" | "rejected"; indexed
|
|
182
|
-
-- so pending-queue queries are fast.
|
|
183
|
-
-- source TEXT — human-readable origin tag (e.g. "reflect").
|
|
184
|
-
-- created_at TEXT — ISO-8601; used for ORDER BY created_at ASC.
|
|
185
|
-
-- updated_at TEXT — ISO-8601; updated on accept/reject.
|
|
186
|
-
--
|
|
187
|
-
-- Large payload columns (NOT indexed):
|
|
188
|
-
-- content TEXT — full markdown text; the proposal payload body.
|
|
189
|
-
-- frontmatter_json TEXT — JSON of parsed frontmatter (may be NULL when
|
|
190
|
-
-- the content has no frontmatter block).
|
|
191
|
-
--
|
|
192
|
-
-- Extensible (metadata_json) columns:
|
|
193
|
-
-- metadata_json TEXT — JSON object for future proposal fields.
|
|
194
|
-
-- Current fields stored here: sourceRun,
|
|
195
|
-
-- review, confidence, gateDecision (#577),
|
|
196
|
-
-- backupContent.
|
|
197
|
-
--
|
|
198
|
-
-- ADD COLUMN extension points (future migrations):
|
|
199
|
-
-- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
|
|
200
|
-
-- ALTER TABLE proposals ADD COLUMN review_outcome TEXT DEFAULT NULL;
|
|
201
|
-
-- ALTER TABLE proposals ADD COLUMN review_reason TEXT DEFAULT NULL;
|
|
202
|
-
-- ALTER TABLE proposals ADD COLUMN review_decided_at TEXT DEFAULT NULL;
|
|
203
|
-
-- ALTER TABLE proposals ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
204
|
-
--
|
|
205
|
-
CREATE TABLE IF NOT EXISTS proposals (
|
|
206
|
-
id TEXT PRIMARY KEY,
|
|
207
|
-
stash_dir TEXT NOT NULL,
|
|
208
|
-
ref TEXT NOT NULL,
|
|
209
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
210
|
-
source TEXT NOT NULL,
|
|
211
|
-
created_at TEXT NOT NULL,
|
|
212
|
-
updated_at TEXT NOT NULL,
|
|
213
|
-
content TEXT NOT NULL DEFAULT '',
|
|
214
|
-
frontmatter_json TEXT,
|
|
215
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
-- Query patterns:
|
|
219
|
-
-- SELECT … WHERE stash_dir = ? AND status = ? → idx_proposals_stash_status
|
|
220
|
-
-- SELECT … WHERE ref = ? AND status = ? → idx_proposals_ref_status
|
|
221
|
-
-- SELECT … WHERE id = ? → PK
|
|
222
|
-
CREATE INDEX IF NOT EXISTS idx_proposals_stash_status
|
|
223
|
-
ON proposals(stash_dir, status);
|
|
224
|
-
CREATE INDEX IF NOT EXISTS idx_proposals_ref_status
|
|
225
|
-
ON proposals(ref, status);
|
|
226
|
-
|
|
227
|
-
-- ── task_history ─────────────────────────────────────────────────────────
|
|
228
|
-
--
|
|
229
|
-
-- Replaces per-task JSONL files under <cacheDir>/tasks/history/.
|
|
230
|
-
--
|
|
231
|
-
-- Indexed (query) columns:
|
|
232
|
-
-- task_id TEXT PK — stable task identifier string.
|
|
233
|
-
-- status TEXT — terminal status (e.g. "completed", "failed",
|
|
234
|
-
-- "cancelled"); indexed for status-scoped queries.
|
|
235
|
-
-- started_at TEXT — ISO-8601; indexed for time-range queries.
|
|
236
|
-
-- target_kind TEXT — kind of the target entity (e.g. "issue",
|
|
237
|
-
-- "workflow", "agent"); indexed for kind-scoped queries.
|
|
238
|
-
-- target_ref TEXT — stable ref of the target entity; indexed for
|
|
239
|
-
-- per-target history lookups.
|
|
240
|
-
--
|
|
241
|
-
-- Non-indexed time columns:
|
|
242
|
-
-- completed_at TEXT — ISO-8601 or NULL if still running.
|
|
243
|
-
-- failed_at TEXT — ISO-8601 or NULL.
|
|
244
|
-
--
|
|
245
|
-
-- Non-indexed diagnostic columns:
|
|
246
|
-
-- log_path TEXT — absolute path to the task log file, if any.
|
|
247
|
-
--
|
|
248
|
-
-- Extensible (metadata_json) columns:
|
|
249
|
-
-- metadata_json TEXT — JSON object for future task fields (exit_code,
|
|
250
|
-
-- runner, priority, parent_task_id, …).
|
|
251
|
-
--
|
|
252
|
-
-- ADD COLUMN extension points (future migrations):
|
|
253
|
-
-- ALTER TABLE task_history ADD COLUMN exit_code INTEGER DEFAULT NULL;
|
|
254
|
-
-- ALTER TABLE task_history ADD COLUMN runner TEXT DEFAULT NULL;
|
|
255
|
-
-- ALTER TABLE task_history ADD COLUMN parent_task_id TEXT DEFAULT NULL;
|
|
256
|
-
-- ALTER TABLE task_history ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
|
|
257
|
-
--
|
|
258
|
-
CREATE TABLE IF NOT EXISTS task_history (
|
|
259
|
-
task_id TEXT PRIMARY KEY,
|
|
260
|
-
status TEXT NOT NULL,
|
|
261
|
-
started_at TEXT NOT NULL,
|
|
262
|
-
completed_at TEXT,
|
|
263
|
-
failed_at TEXT,
|
|
264
|
-
log_path TEXT,
|
|
265
|
-
target_kind TEXT,
|
|
266
|
-
target_ref TEXT,
|
|
267
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
-- Query patterns:
|
|
271
|
-
-- SELECT … WHERE task_id = ? → PK
|
|
272
|
-
-- SELECT … WHERE started_at >= ? AND started_at <= ? → idx_task_history_started
|
|
273
|
-
-- SELECT … WHERE target_kind = ? AND target_ref = ? → idx_task_history_target
|
|
274
|
-
-- SELECT … WHERE status = ? → idx_task_history_status
|
|
275
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_started
|
|
276
|
-
ON task_history(started_at);
|
|
277
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_target
|
|
278
|
-
ON task_history(target_kind, target_ref);
|
|
279
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_status
|
|
280
|
-
ON task_history(status);
|
|
281
|
-
`,
|
|
282
|
-
},
|
|
283
|
-
// Migration 002 — fix task_history to be a true per-run log.
|
|
284
|
-
//
|
|
285
|
-
// Migration 001 used task_id as PRIMARY KEY, meaning each task had exactly
|
|
286
|
-
// one row and every new run overwrote the previous one. This silently
|
|
287
|
-
// discarded all historical runs — the opposite of a history table.
|
|
288
|
-
//
|
|
289
|
-
// This migration recreates the table with an AUTOINCREMENT id so each run
|
|
290
|
-
// appends a new row. The old single-row table is renamed to _old, the new
|
|
291
|
-
// table is created, data is copied, and the old table is dropped.
|
|
292
|
-
{
|
|
293
|
-
id: "002-task-history-per-run",
|
|
294
|
-
up: `
|
|
295
|
-
ALTER TABLE task_history RENAME TO task_history_v1;
|
|
296
|
-
|
|
297
|
-
CREATE TABLE task_history (
|
|
298
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
299
|
-
task_id TEXT NOT NULL,
|
|
300
|
-
status TEXT NOT NULL,
|
|
301
|
-
started_at TEXT NOT NULL,
|
|
302
|
-
completed_at TEXT,
|
|
303
|
-
failed_at TEXT,
|
|
304
|
-
log_path TEXT,
|
|
305
|
-
target_kind TEXT,
|
|
306
|
-
target_ref TEXT,
|
|
307
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
INSERT INTO task_history
|
|
311
|
-
(task_id, status, started_at, completed_at, failed_at,
|
|
312
|
-
log_path, target_kind, target_ref, metadata_json)
|
|
313
|
-
SELECT task_id, status, started_at, completed_at, failed_at,
|
|
314
|
-
log_path, target_kind, target_ref, metadata_json
|
|
315
|
-
FROM task_history_v1;
|
|
316
|
-
|
|
317
|
-
DROP TABLE task_history_v1;
|
|
318
|
-
|
|
319
|
-
-- Unique constraint: same task cannot have two runs with the same start time.
|
|
320
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_history_run
|
|
321
|
-
ON task_history(task_id, started_at);
|
|
322
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_task_id
|
|
323
|
-
ON task_history(task_id);
|
|
324
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_started
|
|
325
|
-
ON task_history(started_at);
|
|
326
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_target
|
|
327
|
-
ON task_history(target_kind, target_ref);
|
|
328
|
-
CREATE INDEX IF NOT EXISTS idx_task_history_status
|
|
329
|
-
ON task_history(status);
|
|
330
|
-
`,
|
|
331
|
-
},
|
|
332
|
-
// ── Migration 003 — improve_runs ────────────────────────────────────────────
|
|
333
|
-
//
|
|
334
|
-
// Records every `akm improve` invocation as a durable row, replacing the
|
|
335
|
-
// legacy `<stash>/.akm/runs/<runId>/improve-result.json` artifact files.
|
|
336
|
-
//
|
|
337
|
-
// The `dry_run` column is FIRST-CLASS and indexed so productivity audits can
|
|
338
|
-
// cleanly filter dry-run probes out of real-run analyses without parsing
|
|
339
|
-
// `result_json`. The dry-run/real-run artifact-trap (recorded in
|
|
340
|
-
// feedback_akm_dryrun_artifact_trap) was the specific motivating bug.
|
|
341
|
-
//
|
|
342
|
-
// Indexed (query) columns:
|
|
343
|
-
// id TEXT PK — runId (`buildImproveRunId()` output).
|
|
344
|
-
// started_at TEXT — ISO-8601; indexed for time-range queries.
|
|
345
|
-
// stash_dir TEXT — absolute stash root; multi-stash scoping.
|
|
346
|
-
// dry_run INTEGER — 0/1; indexed for productivity audits.
|
|
347
|
-
// scope_mode TEXT — "all" | "type" | "ref"; indexed via composite
|
|
348
|
-
// with stash_dir for stash-scoped scope queries.
|
|
349
|
-
//
|
|
350
|
-
// Non-indexed payload:
|
|
351
|
-
// completed_at TEXT — ISO-8601 or NULL if interrupted.
|
|
352
|
-
// profile TEXT — improve profile name (nullable).
|
|
353
|
-
// scope_value TEXT — type name or asset ref (nullable).
|
|
354
|
-
// guidance TEXT — user-provided guidance text, if any.
|
|
355
|
-
// ok INTEGER — 0/1; whether the run produced ok=true.
|
|
356
|
-
// result_json TEXT — full AkmImproveResult JSON.
|
|
357
|
-
// metrics_json TEXT — aggregate counts extracted from result, cheap
|
|
358
|
-
// to query without parsing result_json.
|
|
359
|
-
//
|
|
360
|
-
// Extensible (metadata_json) columns:
|
|
361
|
-
// metadata_json TEXT — JSON object for future improve-run fields.
|
|
362
|
-
//
|
|
363
|
-
// ADD COLUMN extension points (future migrations):
|
|
364
|
-
// ALTER TABLE improve_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL;
|
|
365
|
-
// ALTER TABLE improve_runs ADD COLUMN host TEXT DEFAULT NULL;
|
|
366
|
-
//
|
|
367
|
-
// TTL: rows where started_at < NOW() - 90 days can be deleted by
|
|
368
|
-
// `purgeOldImproveRuns()`. No automatic deletion occurs here.
|
|
369
|
-
{
|
|
370
|
-
id: "003-improve-runs",
|
|
371
|
-
up: `
|
|
372
|
-
CREATE TABLE IF NOT EXISTS improve_runs (
|
|
373
|
-
id TEXT PRIMARY KEY,
|
|
374
|
-
started_at TEXT NOT NULL,
|
|
375
|
-
completed_at TEXT,
|
|
376
|
-
stash_dir TEXT NOT NULL,
|
|
377
|
-
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
378
|
-
profile TEXT,
|
|
379
|
-
scope_mode TEXT NOT NULL,
|
|
380
|
-
scope_value TEXT,
|
|
381
|
-
guidance TEXT,
|
|
382
|
-
ok INTEGER NOT NULL,
|
|
383
|
-
result_json TEXT NOT NULL,
|
|
384
|
-
metrics_json TEXT,
|
|
385
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
386
|
-
);
|
|
387
|
-
|
|
388
|
-
-- Query patterns supported:
|
|
389
|
-
-- SELECT … WHERE started_at >= ? AND started_at <= ?
|
|
390
|
-
-- → idx_improve_runs_started
|
|
391
|
-
-- SELECT … WHERE dry_run = 0
|
|
392
|
-
-- → idx_improve_runs_dry_run (productivity audits filter trap)
|
|
393
|
-
-- SELECT … WHERE stash_dir = ? AND scope_mode = ?
|
|
394
|
-
-- → idx_improve_runs_stash_scope
|
|
395
|
-
CREATE INDEX IF NOT EXISTS idx_improve_runs_started
|
|
396
|
-
ON improve_runs(started_at);
|
|
397
|
-
CREATE INDEX IF NOT EXISTS idx_improve_runs_dry_run
|
|
398
|
-
ON improve_runs(dry_run);
|
|
399
|
-
CREATE INDEX IF NOT EXISTS idx_improve_runs_stash_scope
|
|
400
|
-
ON improve_runs(stash_dir, scope_mode);
|
|
401
|
-
`,
|
|
402
|
-
},
|
|
403
|
-
// ── Migration 004 — extract_sessions_seen ───────────────────────────────────
|
|
404
|
-
//
|
|
405
|
-
// Tracks which platform sessions the extractor has processed, so the discovery
|
|
406
|
-
// pass in `akm extract --since <window>` skips sessions whose content hasn't
|
|
407
|
-
// changed since the last successful run. Replaces the akm-plugin
|
|
408
|
-
// session-checkpoint hook's implicit "write-once" memory of what's been
|
|
409
|
-
// captured — but persistent and queryable.
|
|
410
|
-
//
|
|
411
|
-
// Indexed (query) columns:
|
|
412
|
-
// harness TEXT — harness name (claude-code, opencode, ...).
|
|
413
|
-
// session_id TEXT — platform-native session identifier.
|
|
414
|
-
// processed_at TEXT — ISO-8601 UTC; when extract last ran on this session.
|
|
415
|
-
// session_ended_at TEXT — session.endedAt at processing time. When a
|
|
416
|
-
// later listSessions reports a *newer* endedAt
|
|
417
|
-
// for the same session_id, the extractor
|
|
418
|
-
// re-processes the appended events.
|
|
419
|
-
// outcome TEXT — "candidates_queued" | "no_candidates" |
|
|
420
|
-
// "skipped" | "failed".
|
|
421
|
-
//
|
|
422
|
-
// Non-indexed columns:
|
|
423
|
-
// candidate_count INTEGER — number of candidates the LLM produced.
|
|
424
|
-
// proposal_count INTEGER — number of proposals actually queued
|
|
425
|
-
// (candidates may fail downstream validation).
|
|
426
|
-
// rationale TEXT — for "no_candidates", the LLM's explanation.
|
|
427
|
-
// source_run TEXT — sourceRun id for PROV-DM traceability.
|
|
428
|
-
// metadata_json TEXT — future-proofing (pre-filter stats, LLM
|
|
429
|
-
// model+version, prompt token count, etc.).
|
|
430
|
-
//
|
|
431
|
-
// PK: (harness, session_id) — one row per session per harness. A re-extract
|
|
432
|
-
// updates the row in place via INSERT OR REPLACE.
|
|
433
|
-
//
|
|
434
|
-
// TTL: no automatic deletion. Sessions stay tracked as long as the source
|
|
435
|
-
// session files exist on disk. Operator can `DELETE FROM extract_sessions_seen
|
|
436
|
-
// WHERE processed_at < ?` for cleanup if desired.
|
|
437
|
-
{
|
|
438
|
-
id: "004-extract-sessions-seen",
|
|
439
|
-
up: `
|
|
440
|
-
CREATE TABLE IF NOT EXISTS extract_sessions_seen (
|
|
441
|
-
harness TEXT NOT NULL,
|
|
442
|
-
session_id TEXT NOT NULL,
|
|
443
|
-
processed_at TEXT NOT NULL,
|
|
444
|
-
session_ended_at TEXT,
|
|
445
|
-
outcome TEXT NOT NULL,
|
|
446
|
-
candidate_count INTEGER NOT NULL DEFAULT 0,
|
|
447
|
-
proposal_count INTEGER NOT NULL DEFAULT 0,
|
|
448
|
-
rationale TEXT,
|
|
449
|
-
source_run TEXT,
|
|
450
|
-
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
451
|
-
PRIMARY KEY (harness, session_id)
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
-- Query patterns:
|
|
455
|
-
-- SELECT … WHERE harness = ? → idx_extract_sessions_harness
|
|
456
|
-
-- SELECT … WHERE processed_at >= ? → idx_extract_sessions_processed
|
|
457
|
-
-- SELECT … WHERE harness = ? AND session_id = ? → PK
|
|
458
|
-
CREATE INDEX IF NOT EXISTS idx_extract_sessions_harness
|
|
459
|
-
ON extract_sessions_seen(harness);
|
|
460
|
-
CREATE INDEX IF NOT EXISTS idx_extract_sessions_processed
|
|
461
|
-
ON extract_sessions_seen(processed_at);
|
|
462
|
-
`,
|
|
463
|
-
},
|
|
464
|
-
// ── Migration 005 — proposal_fs_imports ─────────────────────────────────────
|
|
465
|
-
//
|
|
466
|
-
// One-shot ledger for the legacy filesystem→SQLite proposal import (#578).
|
|
467
|
-
//
|
|
468
|
-
// Before 0.9.0 the proposal queue lived as per-uuid JSON directories under
|
|
469
|
-
// `<stashDir>/.akm/proposals/` and the `proposals` table (created in 001) was
|
|
470
|
-
// dead weight. 0.9.0 makes the table canonical; the first proposal operation
|
|
471
|
-
// against a stash imports any legacy `proposal.json` files it finds (INSERT
|
|
472
|
-
// OR IGNORE, so re-runs never duplicate) and records the stash here so later
|
|
473
|
-
// invocations skip the directory walk entirely.
|
|
474
|
-
//
|
|
475
|
-
// Indexed (query) columns:
|
|
476
|
-
// stash_dir TEXT PK — absolute stash root the import ran against.
|
|
477
|
-
//
|
|
478
|
-
// Non-indexed columns:
|
|
479
|
-
// imported_at TEXT — ISO-8601 UTC; when the import completed.
|
|
480
|
-
// imported_count INTEGER — rows actually inserted by the import.
|
|
481
|
-
{
|
|
482
|
-
id: "005-proposal-fs-imports",
|
|
483
|
-
up: `
|
|
484
|
-
CREATE TABLE IF NOT EXISTS proposal_fs_imports (
|
|
485
|
-
stash_dir TEXT PRIMARY KEY,
|
|
486
|
-
imported_at TEXT NOT NULL,
|
|
487
|
-
imported_count INTEGER NOT NULL DEFAULT 0
|
|
488
|
-
);
|
|
489
|
-
`,
|
|
490
|
-
},
|
|
491
|
-
];
|
|
104
|
+
export function withStateDb(fn, opts) {
|
|
105
|
+
return withManagedDb(() => openStateDatabase(opts?.path), fn, opts);
|
|
106
|
+
}
|
|
492
107
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* pre-versioning bootstrap step, so no `bootstrap` hook is passed.
|
|
497
|
-
*
|
|
498
|
-
* Called automatically by `openStateDatabase()`.
|
|
108
|
+
* Async sibling of {@link withStateDb} — for `fn`s that hold the handle across
|
|
109
|
+
* an `await` (the sync version would close it too early). Same borrow/own +
|
|
110
|
+
* optional `path` override semantics.
|
|
499
111
|
*/
|
|
500
|
-
export function
|
|
501
|
-
|
|
112
|
+
export function withStateDbAsync(fn, opts) {
|
|
113
|
+
return withManagedDbAsync(() => openStateDatabase(opts?.path), fn, opts);
|
|
502
114
|
}
|
|
115
|
+
// ── Migration engine ─────────────────────────────────────────────────────────
|
|
116
|
+
//
|
|
117
|
+
// The MIGRATIONS registry + runMigrations live in ./state/migrations (the single
|
|
118
|
+
// append-only ordered source of truth). Imported for internal use by
|
|
119
|
+
// openStateDatabase + re-exported so existing importers keep resolving.
|
|
120
|
+
import { runMigrations } from "./state/migrations.js";
|
|
121
|
+
export { runMigrations } from "./state/migrations.js";
|
|
503
122
|
/**
|
|
504
123
|
* Convert a raw `EventRow` from the database to the public `EventEnvelope`
|
|
505
124
|
* interface used throughout the events module.
|
|
@@ -562,6 +181,9 @@ export function proposalRowToProposal(row) {
|
|
|
562
181
|
...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
|
|
563
182
|
...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
|
|
564
183
|
...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
|
|
184
|
+
...(typeof meta.eligibilitySource === "string"
|
|
185
|
+
? { eligibilitySource: meta.eligibilitySource }
|
|
186
|
+
: {}),
|
|
565
187
|
};
|
|
566
188
|
}
|
|
567
189
|
/**
|
|
@@ -581,6 +203,8 @@ export function proposalToRowValues(proposal, stashDir) {
|
|
|
581
203
|
metaObj.gateDecision = proposal.gateDecision;
|
|
582
204
|
if (proposal.backupContent !== undefined)
|
|
583
205
|
metaObj.backupContent = proposal.backupContent;
|
|
206
|
+
if (proposal.eligibilitySource !== undefined)
|
|
207
|
+
metaObj.eligibilitySource = proposal.eligibilitySource;
|
|
584
208
|
return {
|
|
585
209
|
id: proposal.id,
|
|
586
210
|
stash_dir: stashDir,
|
|
@@ -723,6 +347,57 @@ export function listStateProposals(db, options = {}) {
|
|
|
723
347
|
.all(...params);
|
|
724
348
|
return rows.map(proposalRowToProposal);
|
|
725
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Read every proposal's `gateDecision` record across all stashes (#612).
|
|
352
|
+
*
|
|
353
|
+
* Calibration reads the auto-accept gate's per-proposal decisions regardless of
|
|
354
|
+
* the proposal's current lifecycle status — a proposal that was auto-accepted
|
|
355
|
+
* is now `accepted`, an auto-rejected one stays `pending`, so filtering by
|
|
356
|
+
* status would drop half the join. Rows without a `gateDecision` (created
|
|
357
|
+
* before #577, or never gated) are skipped. The result is ordered by
|
|
358
|
+
* `decidedAt ASC` for deterministic downstream aggregation, falling back to
|
|
359
|
+
* `created_at` ordering from the SQL layer for rows with equal/missing
|
|
360
|
+
* timestamps.
|
|
361
|
+
*/
|
|
362
|
+
export function listProposalGateDecisions(db) {
|
|
363
|
+
const rows = db.prepare("SELECT metadata_json FROM proposals ORDER BY created_at ASC, rowid ASC").all();
|
|
364
|
+
const decisions = [];
|
|
365
|
+
for (const row of rows) {
|
|
366
|
+
let meta;
|
|
367
|
+
try {
|
|
368
|
+
meta = JSON.parse(row.metadata_json);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const decision = meta.gateDecision;
|
|
374
|
+
if (decision && typeof decision === "object" && typeof decision.outcome === "string") {
|
|
375
|
+
decisions.push(decision);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
decisions.sort((a, b) => new Date(a.decidedAt).getTime() - new Date(b.decidedAt).getTime());
|
|
379
|
+
return decisions;
|
|
380
|
+
}
|
|
381
|
+
// ── WS-4: Per-phase gate threshold store (Migration 012) ─────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* Read the persisted auto-tuned threshold for a gate phase.
|
|
384
|
+
*
|
|
385
|
+
* Returns `undefined` when no row exists yet (first run, or the phase has
|
|
386
|
+
* never been tuned). The caller falls back to the global `options.autoAccept`
|
|
387
|
+
* in that case.
|
|
388
|
+
*/
|
|
389
|
+
export function getPhaseThreshold(db, phase) {
|
|
390
|
+
const row = db.prepare("SELECT threshold FROM improve_gate_thresholds WHERE phase = ?").get(phase);
|
|
391
|
+
return row?.threshold;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Persist the auto-tuned threshold for a gate phase.
|
|
395
|
+
* Uses INSERT OR REPLACE so the call is idempotent (upsert semantics).
|
|
396
|
+
*/
|
|
397
|
+
export function persistPhaseThreshold(db, phase, threshold) {
|
|
398
|
+
db.prepare(`INSERT OR REPLACE INTO improve_gate_thresholds (phase, threshold, updated_at)
|
|
399
|
+
VALUES (?, ?, ?)`).run(phase, Math.round(threshold), Date.now());
|
|
400
|
+
}
|
|
726
401
|
/**
|
|
727
402
|
* Look up a single proposal by id, optionally scoped to one stash root.
|
|
728
403
|
* Returns undefined when not found.
|
|
@@ -785,6 +460,92 @@ export function insertProposalIfAbsent(db, proposal, stashDir) {
|
|
|
785
460
|
const changes = result.changes ?? 0;
|
|
786
461
|
return Number(changes) > 0;
|
|
787
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Run `fn` inside a `BEGIN IMMEDIATE` transaction.
|
|
465
|
+
*
|
|
466
|
+
* `db.transaction()` is DEFERRED by default on both Bun and better-sqlite3,
|
|
467
|
+
* which means two writers can both perform stale preflight reads and only race
|
|
468
|
+
* when they finally attempt the write. Proposal creation and queue mutation
|
|
469
|
+
* need the write lock BEFORE those reads so concurrent processes serialize on
|
|
470
|
+
* the live queue state rather than clobbering each other.
|
|
471
|
+
*/
|
|
472
|
+
/**
|
|
473
|
+
* Errors `BEGIN IMMEDIATE` can throw under concurrent-writer contention that are
|
|
474
|
+
* transient (the statement did NOT start a usable transaction) and safe to
|
|
475
|
+
* retry after clearing any phantom transaction state:
|
|
476
|
+
* - "database is locked" / SQLITE_BUSY — another writer holds the lock.
|
|
477
|
+
* - "cannot start a transaction within a transaction" — bun:sqlite can leave
|
|
478
|
+
* the connection reporting an open transaction after a contended busy-wait
|
|
479
|
+
* on BEGIN IMMEDIATE (observed only under heavy parallel load, e.g. the
|
|
480
|
+
* proposal-queue worker race). A ROLLBACK clears that phantom state.
|
|
481
|
+
* These are start-of-transaction failures only; an error thrown by `fn` is a
|
|
482
|
+
* real failure and is NEVER retried.
|
|
483
|
+
*/
|
|
484
|
+
function isRetryableBeginError(err) {
|
|
485
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
486
|
+
return (msg.includes("within a transaction") ||
|
|
487
|
+
msg.includes("database is locked") ||
|
|
488
|
+
msg.includes("database table is locked") ||
|
|
489
|
+
// Phantom BEGIN (see below) — synthesized when BEGIN IMMEDIATE returns
|
|
490
|
+
// without opening a transaction. Safe to retry: fn() has not run.
|
|
491
|
+
msg.includes("did not open a transaction"));
|
|
492
|
+
}
|
|
493
|
+
const WITH_IMMEDIATE_TX_MAX_ATTEMPTS = 5;
|
|
494
|
+
/** Portable synchronous sleep (works under both Bun and Node). */
|
|
495
|
+
function sleepSyncMs(ms) {
|
|
496
|
+
if (ms <= 0)
|
|
497
|
+
return;
|
|
498
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
499
|
+
}
|
|
500
|
+
export function withImmediateTransaction(db, fn) {
|
|
501
|
+
let lastBeginErr;
|
|
502
|
+
for (let attempt = 1; attempt <= WITH_IMMEDIATE_TX_MAX_ATTEMPTS; attempt++) {
|
|
503
|
+
try {
|
|
504
|
+
db.exec("BEGIN IMMEDIATE");
|
|
505
|
+
// bun:sqlite can return from BEGIN IMMEDIATE under writer contention WITHOUT
|
|
506
|
+
// actually opening a transaction (no throw). That phantom state otherwise
|
|
507
|
+
// surfaces as "cannot commit - no transaction is active" at COMMIT — AFTER
|
|
508
|
+
// fn() has already run in autocommit, so its writes escaped the intended
|
|
509
|
+
// serialization (the concurrent proposal-queue race). Detect it here, before
|
|
510
|
+
// fn(), and route it through the same retry path as a contended BEGIN.
|
|
511
|
+
if (!db.inTransaction) {
|
|
512
|
+
throw new Error("BEGIN IMMEDIATE did not open a transaction (phantom contention state)");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
lastBeginErr = err;
|
|
517
|
+
if (isRetryableBeginError(err) && attempt < WITH_IMMEDIATE_TX_MAX_ATTEMPTS) {
|
|
518
|
+
// Clear any phantom/stale transaction left by the contended BEGIN, then
|
|
519
|
+
// retry with a small backoff so concurrent writers serialize cleanly.
|
|
520
|
+
try {
|
|
521
|
+
db.exec("ROLLBACK");
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// No active transaction to roll back — fine.
|
|
525
|
+
}
|
|
526
|
+
sleepSyncMs(2 ** (attempt - 1));
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const result = fn();
|
|
533
|
+
db.exec("COMMIT");
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
try {
|
|
538
|
+
db.exec("ROLLBACK");
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
// Ignore rollback failures so the original error is preserved.
|
|
542
|
+
}
|
|
543
|
+
throw err; // a real error inside the transaction body — never retried.
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Exhausted retries on transient begin failures.
|
|
547
|
+
throw lastBeginErr;
|
|
548
|
+
}
|
|
788
549
|
// ── task_history table helpers ───────────────────────────────────────────────
|
|
789
550
|
/**
|
|
790
551
|
* Upsert a task history row.
|
|
@@ -991,15 +752,16 @@ export function computeImproveRunMetrics(result) {
|
|
|
991
752
|
const actionsCount = actions.length;
|
|
992
753
|
let acceptedCount = 0;
|
|
993
754
|
let rejectedCount = 0;
|
|
755
|
+
let skippedCount = 0;
|
|
994
756
|
let autoAcceptedCount = 0;
|
|
995
757
|
let errorCount = 0;
|
|
996
758
|
for (const action of actions) {
|
|
997
759
|
// Bucketing delegated to the shared classifyImproveAction so this aggregate
|
|
998
760
|
// and the improve_completed event in improve.ts can never disagree, and so a
|
|
999
|
-
// new union variant is a compile error rather than a silent drop.
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
// intentionally counted in none of the
|
|
761
|
+
// new union variant is a compile error rather than a silent drop. Gated skips
|
|
762
|
+
// (cooldown / signal-delta / distill pool-delta) bucket to "skipped", NOT
|
|
763
|
+
// "rejected" — only a guard-rejected produced change is a true rejection.
|
|
764
|
+
// "noop" (memory-prune) is intentionally counted in none of the buckets.
|
|
1003
765
|
switch (classifyImproveAction(action.mode)) {
|
|
1004
766
|
case "accepted":
|
|
1005
767
|
acceptedCount++;
|
|
@@ -1007,6 +769,9 @@ export function computeImproveRunMetrics(result) {
|
|
|
1007
769
|
case "rejected":
|
|
1008
770
|
rejectedCount++;
|
|
1009
771
|
break;
|
|
772
|
+
case "skipped":
|
|
773
|
+
skippedCount++;
|
|
774
|
+
break;
|
|
1010
775
|
case "error":
|
|
1011
776
|
errorCount++;
|
|
1012
777
|
break;
|
|
@@ -1020,7 +785,7 @@ export function computeImproveRunMetrics(result) {
|
|
|
1020
785
|
}
|
|
1021
786
|
// Add gate-promoted count from the unified PostPhaseAutoAcceptGate (all phases).
|
|
1022
787
|
autoAcceptedCount += result.gateAutoAcceptedCount ?? 0;
|
|
1023
|
-
return { plannedCount, actionsCount, acceptedCount, rejectedCount, autoAcceptedCount, errorCount };
|
|
788
|
+
return { plannedCount, actionsCount, acceptedCount, rejectedCount, skippedCount, autoAcceptedCount, errorCount };
|
|
1024
789
|
}
|
|
1025
790
|
/**
|
|
1026
791
|
* Insert a single improve-run row into `improve_runs`. Uses parameterised SQL.
|
|
@@ -1082,9 +847,11 @@ export function purgeOldImproveRuns(db, retentionDays = 90) {
|
|
|
1082
847
|
}
|
|
1083
848
|
/**
|
|
1084
849
|
* Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
|
|
1085
|
-
* row reflects the most recent run
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
850
|
+
* row reflects the most recent run. The `content_hash` persisted here is what
|
|
851
|
+
* the NEXT run compares against (#602): a byte-identical session is skipped, a
|
|
852
|
+
* changed session is re-processed, and a NULL-backfill row becomes hash-stable
|
|
853
|
+
* after its one reprocess. `session_ended_at` is still written for
|
|
854
|
+
* telemetry/forensics but is no longer the skip authority.
|
|
1088
855
|
*/
|
|
1089
856
|
export function upsertExtractedSession(db, input) {
|
|
1090
857
|
const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
|
|
@@ -1093,9 +860,10 @@ export function upsertExtractedSession(db, input) {
|
|
|
1093
860
|
db.prepare(`
|
|
1094
861
|
INSERT OR REPLACE INTO extract_sessions_seen
|
|
1095
862
|
(harness, session_id, processed_at, session_ended_at, outcome,
|
|
1096
|
-
candidate_count, proposal_count, rationale, source_run, metadata_json
|
|
1097
|
-
|
|
1098
|
-
|
|
863
|
+
candidate_count, proposal_count, rationale, source_run, metadata_json,
|
|
864
|
+
content_hash)
|
|
865
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
866
|
+
`).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
867
|
}
|
|
1100
868
|
/**
|
|
1101
869
|
* Fetch a single session's last extract record, or `undefined` when the
|
|
@@ -1133,71 +901,354 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
|
|
|
1133
901
|
return out;
|
|
1134
902
|
}
|
|
1135
903
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
904
|
+
* The most recent extract-run time for a harness — `MAX(processed_at)` across
|
|
905
|
+
* its ledger rows, as ms epoch — or `null` when the harness has never been
|
|
906
|
+
* extracted. Used to default the discovery window to "since the last run" so an
|
|
907
|
+
* intermittently-online host that was off for days still rediscovers sessions
|
|
908
|
+
* that ended during the gap (the content-hash ledger keeps the widened window
|
|
909
|
+
* free of redundant LLM cost).
|
|
910
|
+
*/
|
|
911
|
+
export function getLastExtractRunAt(db, harness) {
|
|
912
|
+
const row = db
|
|
913
|
+
.prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?")
|
|
914
|
+
.get(harness);
|
|
915
|
+
if (!row?.last)
|
|
916
|
+
return null;
|
|
917
|
+
const ms = Date.parse(row.last);
|
|
918
|
+
return Number.isFinite(ms) ? ms : null;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Decide whether a session should be skipped because the extractor has already
|
|
922
|
+
* processed BYTE-IDENTICAL content (#602). The skip authority is the content
|
|
923
|
+
* hash, NOT `session_ended_at` — this is clock-independent, so it is immune to
|
|
924
|
+
* the clock-skew / out-of-order-endedAt problems that caused the Jun 11-12
|
|
925
|
+
* double-extract + over-throttle incident.
|
|
1141
926
|
*
|
|
1142
|
-
*
|
|
1143
|
-
* -
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
927
|
+
* Rules:
|
|
928
|
+
* - no prior row → `false` (never seen → process; AC3).
|
|
929
|
+
* - prior.content_hash == null → `false` (legacy / hash-less row → process
|
|
930
|
+
* exactly once to backfill the hash, then it becomes hash-stable; AC4).
|
|
931
|
+
* - hashes equal → `true` (unchanged content → skip; AC1).
|
|
932
|
+
* - hashes differ → `false` (changed content → re-process; AC2).
|
|
1147
933
|
*/
|
|
1148
|
-
export function shouldSkipAlreadyExtractedSession(prior,
|
|
934
|
+
export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
|
|
1149
935
|
if (!prior)
|
|
1150
936
|
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))
|
|
937
|
+
if (prior.content_hash == null)
|
|
1158
938
|
return false;
|
|
1159
|
-
|
|
1160
|
-
|
|
939
|
+
return prior.content_hash === currentContentHash;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Bulk-fetch the judged-state cache for a set of entry keys in one query.
|
|
943
|
+
* Returns a Map keyed by entry_key so the consolidate pool-selection loop can
|
|
944
|
+
* do O(1) "has this memory been judged at this content hash?" lookups.
|
|
945
|
+
* Empty input → empty map (no query issued).
|
|
946
|
+
*/
|
|
947
|
+
export function getConsolidationJudgedMap(db, entryKeys) {
|
|
948
|
+
const out = new Map();
|
|
949
|
+
if (entryKeys.length === 0)
|
|
950
|
+
return out;
|
|
951
|
+
// SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that.
|
|
952
|
+
const CHUNK = 500;
|
|
953
|
+
for (let i = 0; i < entryKeys.length; i += CHUNK) {
|
|
954
|
+
const chunk = entryKeys.slice(i, i + CHUNK);
|
|
955
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
956
|
+
const rows = db
|
|
957
|
+
.prepare(`SELECT * FROM consolidation_judged WHERE entry_key IN (${placeholders})`)
|
|
958
|
+
.all(...chunk);
|
|
959
|
+
for (const row of rows)
|
|
960
|
+
out.set(row.entry_key, row);
|
|
961
|
+
}
|
|
962
|
+
return out;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Record (or update) the judged state for one memory. INSERT-OR-REPLACE so the
|
|
966
|
+
* row always reflects the most recent judge of that entry_key. Called once per
|
|
967
|
+
* memory the consolidate LLM saw in a successfully-judged chunk.
|
|
968
|
+
*/
|
|
969
|
+
export function upsertConsolidationJudged(db, input) {
|
|
970
|
+
db.prepare(`
|
|
971
|
+
INSERT OR REPLACE INTO consolidation_judged
|
|
972
|
+
(entry_key, content_hash, judged_at, outcome)
|
|
973
|
+
VALUES (?, ?, ?, ?)
|
|
974
|
+
`).run(input.entryKey, input.contentHash, input.judgedAt, input.outcome);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Record an induction of a recombine hypothesis and return the new consecutive
|
|
978
|
+
* count. INSERT … ON CONFLICT increments the streak, but the `last_run` guard
|
|
979
|
+
* makes a repeated call within the SAME run idempotent (no double-increment if
|
|
980
|
+
* the same ref appears twice in one run). On insert the streak starts at 1.
|
|
981
|
+
*/
|
|
982
|
+
export function recordRecombineInduction(db, input) {
|
|
983
|
+
const row = db
|
|
984
|
+
.prepare(`
|
|
985
|
+
INSERT INTO recombine_hypotheses
|
|
986
|
+
(hypothesis_ref, signature, member_key, consecutive_count, first_seen_at, last_seen_at, last_run)
|
|
987
|
+
VALUES (?, ?, ?, 1, ?, ?, ?)
|
|
988
|
+
ON CONFLICT(hypothesis_ref) DO UPDATE SET
|
|
989
|
+
consecutive_count = consecutive_count + (CASE WHEN last_run IS excluded.last_run THEN 0 ELSE 1 END),
|
|
990
|
+
last_seen_at = excluded.last_seen_at,
|
|
991
|
+
last_run = excluded.last_run,
|
|
992
|
+
signature = excluded.signature,
|
|
993
|
+
member_key = excluded.member_key
|
|
994
|
+
RETURNING consecutive_count
|
|
995
|
+
`)
|
|
996
|
+
.get(input.hypothesisRef, input.signature, input.memberKey, input.seenAt, input.seenAt, input.run);
|
|
997
|
+
return row?.consecutive_count ?? 0;
|
|
1161
998
|
}
|
|
1162
|
-
// ── registry_index_cache (goes in index.db, not state.db) ───────────────────
|
|
1163
999
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1000
|
+
* #633 — find an existing pending (non-promoted) hypothesis row whose cluster
|
|
1001
|
+
* is the SAME generalization as a newly-induced one, matched by SIGNATURE plus
|
|
1002
|
+
* a Jaccard membership-overlap test, rather than an exact member-set hash.
|
|
1166
1003
|
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1004
|
+
* In a growing stash any added/removed memory changes the exact member set, so
|
|
1005
|
+
* the ref hash (and member_key) shift every run → a fresh row at count=1 → the
|
|
1006
|
+
* streak never reaches `confirmThreshold` and nothing ever promotes. Matching
|
|
1007
|
+
* on overlap lets a drifting-but-stable cluster keep accumulating under one row.
|
|
1170
1008
|
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1009
|
+
* Returns the matched row with the HIGHEST overlap (ties broken by most-recent
|
|
1010
|
+
* `last_seen_at`), or `undefined` when none clears `minOverlap`. Already-promoted
|
|
1011
|
+
* rows are ignored so a confirmed lesson is not reopened by a later induction.
|
|
1173
1012
|
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
|
|
1013
|
+
* @param memberKey the candidate cluster's membership fingerprint
|
|
1014
|
+
* (sorted member entryKeys joined by `|`).
|
|
1015
|
+
* @param minOverlap Jaccard threshold in [0,1]; a candidate matches when
|
|
1016
|
+
* |A∩B| / |A∪B| >= minOverlap.
|
|
1017
|
+
*/
|
|
1018
|
+
export function findMatchingRecombineHypothesis(db, input) {
|
|
1019
|
+
const candidateMembers = new Set(input.memberKey.split("|").filter((m) => m.length > 0));
|
|
1020
|
+
if (candidateMembers.size === 0)
|
|
1021
|
+
return undefined;
|
|
1022
|
+
const rows = db
|
|
1023
|
+
.prepare("SELECT * FROM recombine_hypotheses WHERE signature = ? AND promoted_at IS NULL ORDER BY last_seen_at DESC")
|
|
1024
|
+
.all(input.signature);
|
|
1025
|
+
let best;
|
|
1026
|
+
let bestOverlap = -1;
|
|
1027
|
+
for (const row of rows) {
|
|
1028
|
+
const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
|
|
1029
|
+
if (rowMembers.length === 0)
|
|
1030
|
+
continue;
|
|
1031
|
+
let intersection = 0;
|
|
1032
|
+
for (const m of rowMembers) {
|
|
1033
|
+
if (candidateMembers.has(m))
|
|
1034
|
+
intersection += 1;
|
|
1035
|
+
}
|
|
1036
|
+
const union = candidateMembers.size + rowMembers.length - intersection;
|
|
1037
|
+
const overlap = union === 0 ? 0 : intersection / union;
|
|
1038
|
+
// rows are ordered last_seen_at DESC, so a strict `>` keeps the most-recent
|
|
1039
|
+
// row on ties.
|
|
1040
|
+
if (overlap >= input.minOverlap && overlap > bestOverlap) {
|
|
1041
|
+
best = row;
|
|
1042
|
+
bestOverlap = overlap;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return best;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Fetch a single recombine hypothesis row, or `undefined` when the ref has
|
|
1049
|
+
* never been induced. Normalizes bun:sqlite null → undefined like
|
|
1050
|
+
* {@link getExtractedSession}.
|
|
1051
|
+
*/
|
|
1052
|
+
export function getRecombineHypothesis(db, hypothesisRef) {
|
|
1053
|
+
const row = db
|
|
1054
|
+
.prepare("SELECT * FROM recombine_hypotheses WHERE hypothesis_ref = ?")
|
|
1055
|
+
.get(hypothesisRef);
|
|
1056
|
+
return row ?? undefined;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Mark a hypothesis promoted: stamp `promoted_at` and reset the consecutive
|
|
1060
|
+
* count to 0, so it must re-accumulate a full confirmation streak before it can
|
|
1061
|
+
* promote again. The `promoted_at` non-null state is the double-promotion guard.
|
|
1062
|
+
*/
|
|
1063
|
+
export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
|
|
1064
|
+
db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* #658 — does any current-run cluster match this hypothesis row under the SAME
|
|
1068
|
+
* signature + Jaccard-overlap rule used for re-induction? A match means the
|
|
1069
|
+
* cluster genuinely re-formed this run (it was merely cap-displaced out of the
|
|
1070
|
+
* processed top-`maxClustersPerRun` slice), so its streak must NOT be reset.
|
|
1071
|
+
*/
|
|
1072
|
+
function hypothesisMatchesAnyPresentCluster(row, presentClusters, minOverlap) {
|
|
1073
|
+
const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
|
|
1074
|
+
if (rowMembers.length === 0)
|
|
1075
|
+
return false;
|
|
1076
|
+
const rowSet = new Set(rowMembers);
|
|
1077
|
+
for (const cluster of presentClusters) {
|
|
1078
|
+
if (cluster.signature !== row.signature)
|
|
1079
|
+
continue;
|
|
1080
|
+
const clusterMembers = cluster.memberKey.split("|").filter((m) => m.length > 0);
|
|
1081
|
+
if (clusterMembers.length === 0)
|
|
1082
|
+
continue;
|
|
1083
|
+
let intersection = 0;
|
|
1084
|
+
for (const m of clusterMembers) {
|
|
1085
|
+
if (rowSet.has(m))
|
|
1086
|
+
intersection += 1;
|
|
1087
|
+
}
|
|
1088
|
+
const union = rowSet.size + clusterMembers.length - intersection;
|
|
1089
|
+
const overlap = union === 0 ? 0 : intersection / union;
|
|
1090
|
+
if (overlap >= minOverlap)
|
|
1091
|
+
return true;
|
|
1092
|
+
}
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Decay-to-zero every NON-promoted hypothesis NOT re-induced in the current run.
|
|
1097
|
+
*
|
|
1098
|
+
* A generalization that stops being supported by the corpus has lost its
|
|
1099
|
+
* confirmation streak, so we hard-reset `consecutive_count` to 0 (the
|
|
1100
|
+
* alternative — `count - 1` floored at 0 — tolerates a single noisy run but
|
|
1101
|
+
* blurs the "consecutive" semantics; hard-reset is the conservative choice).
|
|
1102
|
+
*
|
|
1103
|
+
* Only rows whose `hypothesis_ref` is NOT in `seenRefs` AND whose `last_run` is
|
|
1104
|
+
* NOT the current run are decayed. Already-promoted rows are left alone.
|
|
1179
1105
|
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1106
|
+
* #658 — CAP-AWARE decay. The recombine pass only re-inducts (and thus marks
|
|
1107
|
+
* `seen`) the top-`maxClustersPerRun` clusters, but a cluster genuinely
|
|
1108
|
+
* re-forms every run even when it is displaced below that cap. Resetting such a
|
|
1109
|
+
* row treats a SCHEDULING miss as a SUBSTANCE miss and traps the hypothesis
|
|
1110
|
+
* below `confirmThreshold` forever. When `opts.presentClusters` is supplied, a
|
|
1111
|
+
* row is SPARED from decay if it Jaccard-matches any present cluster (same
|
|
1112
|
+
* signature, overlap >= `opts.minOverlap`) — i.e. its cluster re-formed this run
|
|
1113
|
+
* but was cap-displaced. This does NOT advance the streak (only re-induction in
|
|
1114
|
+
* the processed slice does that, via {@link recordRecombineInduction}), so the
|
|
1115
|
+
* recurrence bar for promotion is unchanged; it only stops the cap from
|
|
1116
|
+
* manufacturing artificial misses. Omitting `presentClusters` preserves the
|
|
1117
|
+
* pre-#658 hard-reset-after-one-miss behaviour exactly.
|
|
1182
1118
|
*
|
|
1183
|
-
*
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1119
|
+
* Returns the number of rows reset.
|
|
1120
|
+
*/
|
|
1121
|
+
export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs, opts) {
|
|
1122
|
+
// #658 — when cap-aware sparing is requested, fold the cap-displaced rows into
|
|
1123
|
+
// the "seen" exclusion set: the underlying reset SQL already protects every
|
|
1124
|
+
// ref it is given, so sparing == treating a spared row exactly like a seen
|
|
1125
|
+
// row for this sweep (its count is left untouched, never advanced).
|
|
1126
|
+
let effectiveSeen = seenRefs;
|
|
1127
|
+
if (opts && opts.presentClusters.length > 0) {
|
|
1128
|
+
const candidates = db
|
|
1129
|
+
.prepare("SELECT hypothesis_ref, signature, member_key FROM recombine_hypotheses WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0")
|
|
1130
|
+
.all(currentRun);
|
|
1131
|
+
const seenSet = new Set(seenRefs);
|
|
1132
|
+
for (const row of candidates) {
|
|
1133
|
+
if (seenSet.has(row.hypothesis_ref))
|
|
1134
|
+
continue;
|
|
1135
|
+
if (hypothesisMatchesAnyPresentCluster(row, opts.presentClusters, opts.minOverlap)) {
|
|
1136
|
+
seenSet.add(row.hypothesis_ref);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
effectiveSeen = [...seenSet];
|
|
1140
|
+
}
|
|
1141
|
+
return decayUnseenRecombineHypothesesInner(db, currentRun, effectiveSeen);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The raw reset sweep shared by the cap-aware wrapper above. Resets every
|
|
1145
|
+
* non-promoted row from a prior run whose ref is NOT in `seenRefs`. Kept private
|
|
1146
|
+
* so the param-ceiling chunking logic lives in one place.
|
|
1147
|
+
*/
|
|
1148
|
+
function decayUnseenRecombineHypothesesInner(db, currentRun, seenRefs) {
|
|
1149
|
+
// Reset every eligible row, then exclude the seen refs in chunks to respect
|
|
1150
|
+
// the ~999 SQLite param ceiling. With no seen refs we reset all non-promoted
|
|
1151
|
+
// rows from prior runs in a single statement.
|
|
1152
|
+
if (seenRefs.length === 0) {
|
|
1153
|
+
const res = db
|
|
1154
|
+
.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")
|
|
1155
|
+
.run(currentRun);
|
|
1156
|
+
return Number(res.changes);
|
|
1157
|
+
}
|
|
1158
|
+
// A single NOT IN keeps the exclusion atomic (a chunked NOT IN would let a ref
|
|
1159
|
+
// excluded by one chunk still be reset by another chunk's statement). The
|
|
1160
|
+
// recombine pass caps RE-INDUCED clusters at `maxClustersPerRun` (a handful) —
|
|
1161
|
+
// but with #658 cap-aware sparing the caller folds every cap-displaced
|
|
1162
|
+
// (present-but-unprocessed) hypothesis into `effectiveSeen` too, so on a large
|
|
1163
|
+
// stash `seenRefs` here can carry MANY spared refs, not just the handful that
|
|
1164
|
+
// were processed. We cap defensively at ~900 (under SQLite's ~999 param
|
|
1165
|
+
// ceiling): if `effectiveSeen` somehow exceeds it we fall back to resetting all
|
|
1166
|
+
// eligible rows — which re-introduces the cap-displacement trap for THAT run
|
|
1167
|
+
// (spared rows get decayed because the NOT IN protection is dropped). That is a
|
|
1168
|
+
// rare, bounded degradation; a stash with >900 simultaneously-spared
|
|
1169
|
+
// hypotheses is far beyond current scale.
|
|
1170
|
+
if (seenRefs.length > 900) {
|
|
1171
|
+
const res = db
|
|
1172
|
+
.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")
|
|
1173
|
+
.run(currentRun);
|
|
1174
|
+
return Number(res.changes);
|
|
1175
|
+
}
|
|
1176
|
+
const placeholders = seenRefs.map(() => "?").join(",");
|
|
1177
|
+
const res = db
|
|
1178
|
+
.prepare(`UPDATE recombine_hypotheses SET consecutive_count = 0
|
|
1179
|
+
WHERE promoted_at IS NULL
|
|
1180
|
+
AND (last_run IS NULL OR last_run != ?)
|
|
1181
|
+
AND consecutive_count != 0
|
|
1182
|
+
AND hypothesis_ref NOT IN (${placeholders})`)
|
|
1183
|
+
.run(currentRun, ...seenRefs);
|
|
1184
|
+
return Number(res.changes);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Convert a `number[]` embedding vector to the `Float32Array` byte
|
|
1188
|
+
* representation stored in the `body_embeddings.embedding` BLOB column.
|
|
1189
|
+
*/
|
|
1190
|
+
export function embeddingToBlob(vec) {
|
|
1191
|
+
const f32 = new Float32Array(vec);
|
|
1192
|
+
return new Uint8Array(f32.buffer);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Convert the raw `Uint8Array` bytes from the `body_embeddings.embedding`
|
|
1196
|
+
* BLOB column back to a `number[]` embedding vector.
|
|
1197
|
+
*/
|
|
1198
|
+
export function blobToEmbedding(blob) {
|
|
1199
|
+
// SQLite BLOB columns are returned as Uint8Array; re-interpret as Float32.
|
|
1200
|
+
const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
|
|
1201
|
+
return Array.from(f32);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Bulk-fetch cached body embeddings for a set of content hashes.
|
|
1205
|
+
* Returns a Map keyed by `content_hash` (embedding decoded to `number[]`).
|
|
1206
|
+
* Empty input → empty map (no query issued).
|
|
1187
1207
|
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1208
|
+
* If the stored `model_id` does not match `expectedModelId` the entire table
|
|
1209
|
+
* is cleared (drop-all on model mismatch) and an empty map is returned so
|
|
1210
|
+
* callers re-embed everything on this run.
|
|
1191
1211
|
*/
|
|
1192
|
-
export
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1212
|
+
export function getBodyEmbeddings(db, contentHashes, expectedModelId) {
|
|
1213
|
+
const out = new Map();
|
|
1214
|
+
if (contentHashes.length === 0)
|
|
1215
|
+
return out;
|
|
1216
|
+
// Model-id mismatch: vectors are in the wrong metric space — drop all rows.
|
|
1217
|
+
const firstRow = db.prepare("SELECT model_id FROM body_embeddings LIMIT 1").get();
|
|
1218
|
+
if (firstRow && firstRow.model_id !== expectedModelId) {
|
|
1219
|
+
db.exec("DELETE FROM body_embeddings");
|
|
1220
|
+
return out;
|
|
1221
|
+
}
|
|
1222
|
+
// SQLite has a ~999 param ceiling; chunk if needed.
|
|
1223
|
+
const CHUNK = 500;
|
|
1224
|
+
for (let i = 0; i < contentHashes.length; i += CHUNK) {
|
|
1225
|
+
const chunk = contentHashes.slice(i, i + CHUNK);
|
|
1226
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
1227
|
+
const rows = db
|
|
1228
|
+
.prepare(`SELECT content_hash, embedding FROM body_embeddings WHERE content_hash IN (${placeholders})`)
|
|
1229
|
+
.all(...chunk);
|
|
1230
|
+
for (const row of rows) {
|
|
1231
|
+
out.set(row.content_hash, blobToEmbedding(row.embedding));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return out;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Upsert body-embedding rows in a single transaction.
|
|
1238
|
+
* Each entry maps a `cacheHash` → `number[]` vector. `model_id` is stored
|
|
1239
|
+
* so a future model change can trigger a drop-all purge.
|
|
1240
|
+
*/
|
|
1241
|
+
export function upsertBodyEmbeddings(db, entries) {
|
|
1242
|
+
if (entries.length === 0)
|
|
1243
|
+
return;
|
|
1244
|
+
const now = Date.now();
|
|
1245
|
+
const stmt = db.prepare(`
|
|
1246
|
+
INSERT OR REPLACE INTO body_embeddings (content_hash, embedding, model_id, created_at)
|
|
1247
|
+
VALUES (?, ?, ?, ?)
|
|
1248
|
+
`);
|
|
1249
|
+
db.transaction(() => {
|
|
1250
|
+
for (const { contentHash, embedding, modelId } of entries) {
|
|
1251
|
+
stmt.run(contentHash, embeddingToBlob(embedding), modelId, now);
|
|
1252
|
+
}
|
|
1253
|
+
})();
|
|
1254
|
+
}
|