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.
Files changed (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -12,12 +12,12 @@
12
12
  *
13
13
  * ## Why a separate database from index.db
14
14
  *
15
- * index.db uses a single DB_VERSION integer: when the version changes it drops
16
- * ALL tables and recreates them. That is acceptable for the search index because
17
- * every entry is fully regenerable from the stash on disk. Events, proposals, and
18
- * task history are NON-REGENERABLE — losing them is data loss. They must live in
19
- * a database whose schema evolves via incremental, additive migrations that never
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 { openDatabase } from "../storage/database.js";
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
- const resolvedPath = dbPath ?? getStateDbPath();
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
- * All migrations in application order. New migrations are APPENDED to this
118
- * array never inserted in the middle or reordered.
119
- *
120
- * @see Migration
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
- const MIGRATIONS = [
123
- // ── Migration 001 initial schema ──────────────────────────────────────────
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
- * Apply every pending migration in a single transaction per migration.
494
- *
495
- * Delegates to the shared SQLite migration engine; state.db has no
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 runMigrations(db) {
501
- runSqliteMigrations(db, MIGRATIONS);
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. Note:
1000
- // `reflect-guard-rejected` now counts as "rejected" (previously this switch
1001
- // omitted it entirely a data-integrity miscount). "noop" (memory-prune) is
1002
- // intentionally counted in none of the three numeric buckets.
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; downstream skip-logic compares
1086
- * `session_ended_at` against the live session metadata to decide if anything
1087
- * new arrived since `processed_at`.
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1098
- `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}));
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
- * Decide whether a session should be skipped because the extractor has
1137
- * already processed it AND nothing has changed since. The "anything new since
1138
- * last extract?" rule is: the live `sessionEndedAtMs` is strictly later than
1139
- * the recorded `session_ended_at`. Same-or-earlier endedAt means we'd be
1140
- * re-processing the exact same content for no gain.
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
- * Returns:
1143
- * - `false` — no prior row, or session has new content since last extract.
1144
- * The caller should process it.
1145
- * - `true` — the session was already processed and hasn't been updated.
1146
- * The caller should skip.
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, liveSessionEndedAtMs) {
934
+ export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
1149
935
  if (!prior)
1150
936
  return false;
1151
- // No live timestamp → can't tell if anything's new. Be conservative and
1152
- // skip — the operator can pass --force later if we add it.
1153
- if (typeof liveSessionEndedAtMs !== "number" || !Number.isFinite(liveSessionEndedAtMs)) {
1154
- return true;
1155
- }
1156
- const priorMs = prior.session_ended_at ? Date.parse(prior.session_ended_at) : Number.NaN;
1157
- if (!Number.isFinite(priorMs))
937
+ if (prior.content_hash == null)
1158
938
  return false;
1159
- // Re-process when there's new content; skip when the session is unchanged.
1160
- return liveSessionEndedAtMs <= priorMs;
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
- * DDL for the `registry_index_cache` table that lives in the EXISTING index.db
1165
- * (managed by src/indexer/db/db.ts).
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
- * Design: uses the same migration-safe ADD COLUMN approach. The table is
1168
- * created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
1169
- * ensureSchema() or as a standalone migration.
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
- * Purpose: caches the result of resolving and fetching remote registry stash
1172
- * indexes so `akm search` does not hit the network on every invocation.
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
- * Indexed (query) columns:
1175
- * registry_url TEXT PK — canonical URL of the registry; cache key.
1176
- * fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
1177
- * etag TEXT — HTTP ETag for conditional GET (If-None-Match).
1178
- * last_modified TEXT — HTTP Last-Modified for conditional GET.
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
- * Non-indexed payload:
1181
- * index_json TEXT — JSON blob of the fetched registry index document.
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
- * ADD COLUMN extension points (future migrations in db.ts):
1184
- * ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
1185
- * ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
1186
- * ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
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
- * To add this table to index.db, call ensureRegistryIndexCacheSchema(db) from
1189
- * within ensureSchema() in src/indexer/db/db.ts, or add it as a new CREATE TABLE
1190
- * IF NOT EXISTS block inside the existing ensureSchema() call.
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 const REGISTRY_INDEX_CACHE_DDL = `
1193
- CREATE TABLE IF NOT EXISTS registry_index_cache (
1194
- registry_url TEXT PRIMARY KEY,
1195
- fetched_at TEXT NOT NULL,
1196
- etag TEXT,
1197
- last_modified TEXT,
1198
- index_json TEXT NOT NULL DEFAULT '{}'
1199
- );
1200
-
1201
- CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
1202
- ON registry_index_cache(fetched_at);
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
+ }