akm-cli 0.9.15 → 0.9.16-alpha.1

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 (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -23,8 +23,9 @@ const SearchGraphBoostSchema = z
23
23
  })
24
24
  .passthrough();
25
25
  /**
26
- * `search.curateRerank` (#951) an optional cross-encoder rerank pass over
27
- * `akm curate`'s already-selected candidates.
26
+ * `search.rerank` (#951, moved from `search.curateRerank` in 0.9.16 the
27
+ * pass was always meant for `akm search`, not `akm curate`) an optional
28
+ * cross-encoder rerank pass over search's already-ranked LOCAL stash hits.
28
29
  *
29
30
  * Deliberately its own small config arm rather than a third member of the
30
31
  * `engines` map (`EngineConfigSchema` in ./engines.ts): that union's "llm" /
@@ -37,26 +38,27 @@ const SearchGraphBoostSchema = z
37
38
  * connection shape as an LLM engine without inheriting that machinery.
38
39
  *
39
40
  * `curate_rerank` was removed as a dead `llm.features.*` key in 0.8.0 (no
40
- * implementation ever sent a request); this is a new, real implementation,
41
- * disabled by default.
41
+ * implementation ever sent a request); 0.9.15 shipped a real implementation
42
+ * wired to curate under `search.curateRerank`, disabled by default; 0.9.16
43
+ * moves it to search and renames the key (no compatibility alias — the old
44
+ * key shipped hours earlier, default-off, so nobody has it meaningfully set).
42
45
  */
43
- export const CurateRerankConfigSchema = z
46
+ export const SearchRerankConfigSchema = z
44
47
  .object({
45
48
  enabled: z.boolean().optional(),
46
49
  /** Full URL of the reranker's rerank endpoint, e.g. `http://host:port/rerank`. */
47
50
  endpoint: httpUrl.optional(),
48
51
  model: nonEmptyString.optional(),
49
- apiKey: symbolicOrWarnApiKey("search.curateRerank.apiKey").optional(),
52
+ apiKey: symbolicOrWarnApiKey("search.rerank.apiKey").optional(),
50
53
  timeoutMs: positiveInt.optional(),
51
- /** How many of curate's already-ranked candidates to send to the reranker. Default 8. */
54
+ /** How many of search's already-ranked LOCAL hits to send to the reranker. Default 8. */
52
55
  topN: positiveInt.max(50).optional(),
53
56
  })
54
57
  .passthrough();
55
58
  export const SearchConfigSchema = z
56
59
  .object({
57
- minScore: nonNegativeNumber.optional(),
58
60
  defaultExcludeTypes: z.array(nonEmptyString).optional(),
59
61
  graphBoost: SearchGraphBoostSchema.optional(),
60
- curateRerank: CurateRerankConfigSchema.optional(),
62
+ rerank: SearchRerankConfigSchema.optional(),
61
63
  })
62
64
  .passthrough();
@@ -18,6 +18,7 @@ const CONFIG_HINTS = {
18
18
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
19
19
  EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
20
20
  SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the name after `secret://` matches, or run `akm secret set <name> <value>` to store it.",
21
+ EMBEDDING_VEC_UNAVAILABLE: "Install sqlite-vec for unit-level semantic search, or rely on lexical search until it is available.",
21
22
  };
22
23
  // Code-review finding: COMPOSITION_INVALID covers several unrelated causes
23
24
  // (a rejected with:, a multi-job source, a composition cycle/depth/size
@@ -82,8 +83,10 @@ const USAGE_HINTS = {
82
83
  const TRANSIENT_HINTS = {
83
84
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
84
85
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
85
- INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
86
- MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
86
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db right now. Wait a few seconds and retry index runs take no rebuild " +
87
+ "lock, so this clears quickly; a scheduled run left alone will simply run again next time.",
88
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on " +
89
+ "scheduled improve runs to skip gracefully instead — workflow run does not treat this code as skippable.",
87
90
  IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs.",
88
91
  };
89
92
  /** Default hint for each NotFoundError code. */
@@ -0,0 +1,18 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Content addressing for text handed to an embedding provider.
6
+ *
7
+ * `hashEmbeddableText` is the ONE hash function every writer of and lookup
8
+ * against provider-bound text must agree on — a stable, deterministic sha256
9
+ * over the exact bytes sent, so a single-byte content change is never
10
+ * confused with unchanged text. Originally lived in
11
+ * `embedding-salvage-repository.ts` (#955); moved here so a pure
12
+ * text-processing module (`src/indexer/units/unit.ts`, #index-units) is not
13
+ * coupled to a storage repository just to reuse it.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ export function hashEmbeddableText(text) {
17
+ return createHash("sha256").update(text, "utf8").digest("hex");
18
+ }
@@ -27,16 +27,18 @@ const heldBarrierContext = new AsyncLocalStorage();
27
27
  const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
28
28
  /**
29
29
  * The barrier normally holds for one lock-file write — sub-millisecond on
30
- * any real filesystem. Two akm processes racing to register a lock in the
31
- * very same instant (e.g. two `akm index` runs a scheduler launched back to
32
- * back) can still collide on it; retrying briefly resolves that ordinary
33
- * case instead of failing a legitimate concurrent invocation outright
30
+ * any real filesystem. Two akm processes racing to register a lock/lease/
31
+ * activity in the very same instant (e.g. two `akm index` runs a scheduler
32
+ * launched back to back, both opening canonical state.db) can still collide
33
+ * on it; retrying briefly resolves that ordinary case instead of failing a
34
+ * legitimate concurrent invocation outright
34
35
  * (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
35
36
  * still surfaces the busy error promptly rather than making a losing
36
37
  * process hang — comfortably above the barrier's normal hold time, well
37
38
  * below a length that would make this feel like the blocking lock #872
38
- * removed. Never applies to the rebuild lock itself, which stays
39
- * non-blocking (#872).
39
+ * removed. Never applies to whatever lock/lease/activity is registered
40
+ * after the barrier releases — that thing's own held/skip/throw/wait
41
+ * policy belongs to its caller, not to the barrier (#872).
40
42
  */
41
43
  const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
42
44
  let busyRetryBoundMsForTests;
@@ -234,17 +234,6 @@ export function getDbPath(env = process.env) {
234
234
  export function getIndexWriterLockPath() {
235
235
  return path.join(getDataDir(), "index.db.write.lock");
236
236
  }
237
- /**
238
- * Path to the opt-in, PID-liveness-only rebuild lock an explicit `akm index`
239
- * run acquires (#956). Distinct from {@link getIndexWriterLockPath}'s
240
- * `index.db.write.lock`, which is the asset-mutation lease and unrelated to
241
- * indexing since #872 — this lock never blocks and is never required, it
242
- * only lets a scheduled/opportunistic `akm index --skip-if-locked` step
243
- * aside instead of contending with a run already in progress.
244
- */
245
- export function getIndexRebuildLockPath() {
246
- return path.join(getDataDir(), "index.rebuild.lock");
247
- }
248
237
  export function getMaintenanceBarrierPath() {
249
238
  return path.join(getDataDir(), "maintenance.barrier.lock");
250
239
  }
@@ -3,8 +3,11 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * PID-liveness-only run lock — the shared mechanics behind `akm improve`'s
6
- * whole-run lock (`commands/improve/locks.ts`) and the index rebuild lock
7
- * (`indexer/index-rebuild-lock.ts`, #956).
6
+ * whole-run lock (`commands/improve/locks.ts`). Originally also backed the
7
+ * index rebuild lock (`indexer/index-rebuild-lock.ts`, #956); index-redesign
8
+ * deleted that consumer (every index write is now a short, idempotent,
9
+ * content-addressed transaction, so there is no rebuild to serialize), and
10
+ * `improve`'s lock is this module's only caller today.
8
11
  *
9
12
  * No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. This is
10
13
  * the #872 lesson encoded at the mechanics layer so every future caller gets
@@ -1200,12 +1200,33 @@ export function getStateMigrationSafety(migrationId) {
1200
1200
  * Delegates to the shared SQLite migration engine; state.db has no
1201
1201
  * pre-versioning bootstrap step, so no `bootstrap` hook is passed.
1202
1202
  *
1203
+ * `freshDatabase` is the one exception to "single transaction per migration":
1204
+ * the entire registry is locked through the final migration and applied as
1205
+ * ONE transaction (mirroring the existing-unversioned prefix lock below).
1206
+ * Two akm processes can race to create the same brand-new state.db
1207
+ * (`openStateDatabase`'s file-reservation only decides who creates the file,
1208
+ * not who finishes initializing it first); with per-migration commits, the
1209
+ * loser could open its own connection between two of the winner's commits,
1210
+ * see a real-but-incomplete ledger, and — correctly believing this is an
1211
+ * ordinary existing database needing to catch up on pending migrations —
1212
+ * refuse historical-destructive migration 018 with the same message a
1213
+ * genuine legacy database gets. Applying every migration for a fresh
1214
+ * database atomically removes that partially-migrated state from view
1215
+ * entirely: a concurrent opener now only ever observes "nothing committed
1216
+ * yet" (treated as fresh) or "fully current" (nothing left to apply), never
1217
+ * the in-between. Safe to lose on a crash mid-bootstrap: a fresh database
1218
+ * has no prior data to preserve, so rolling back to nothing and letting the
1219
+ * next open retry from scratch is strictly fine.
1220
+ *
1203
1221
  * Called automatically by `openStateDatabase()`.
1204
1222
  */
1205
1223
  export function runMigrations(db, options) {
1206
1224
  const initialMigration = STATE_MIGRATIONS[0];
1207
1225
  if (!initialMigration)
1208
1226
  throw new Error("State migration registry has no initial migration.");
1227
+ const finalMigration = STATE_MIGRATIONS[STATE_MIGRATIONS.length - 1];
1228
+ if (!finalMigration)
1229
+ throw new Error("State migration registry has no final migration.");
1209
1230
  let existingUnversionedSnapshotPrepared = false;
1210
1231
  const prepareExistingUnversionedState = (lockedDb) => {
1211
1232
  if (existingUnversionedSnapshotPrepared)
@@ -1228,7 +1249,11 @@ export function runMigrations(db, options) {
1228
1249
  existingUnversionedSnapshotPrepared = true;
1229
1250
  };
1230
1251
  runSqliteMigrations(db, STATE_MIGRATIONS, {
1231
- lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase ? "002-task-history-per-run" : undefined,
1252
+ lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase
1253
+ ? "002-task-history-per-run"
1254
+ : options?.freshDatabase
1255
+ ? finalMigration.id
1256
+ : undefined,
1232
1257
  beforeLedgerInitializationLocked(lockedDb) {
1233
1258
  if (options?.freshDatabase)
1234
1259
  return;
@@ -442,10 +442,24 @@ export function openStateDatabase(dbPath, options) {
442
442
  });
443
443
  try {
444
444
  preflight.exec("PRAGMA busy_timeout = 30000");
445
- const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
445
+ // Both reads below must observe ONE WAL snapshot. Each is its own
446
+ // statement, and without an explicit transaction SQLite auto-commits
447
+ // each separately, so they can see two different snapshots. A sibling
448
+ // process's fresh bootstrap is now one all-or-nothing transaction, so
449
+ // a commit landing between the two reads showed an empty ledger to the
450
+ // first and the sibling's freshly created tables to the second — the
451
+ // signature of a genuine legacy unversioned database, which this
452
+ // preflight then refused. Pinned to one snapshot, either nothing the
453
+ // sibling did is visible (empty ledger AND no tables, correctly fresh)
454
+ // or all of it is (a current ledger, so the refusal is never reached).
455
+ // A real legacy database, racing no one, reads exactly as before.
456
+ const { ledger, hasNoOtherTables } = preflight.transaction(() => ({
457
+ ledger: assertMigrationLedger(preflight, STATE_MIGRATIONS),
458
+ hasNoOtherTables: unversionedDatabaseHasNoTables(preflight),
459
+ }))();
446
460
  warnNewerStateLedger(ledger);
447
461
  existingUnversionedDatabase = ledger.migrationIds.length === 0;
448
- if (existingUnversionedDatabase && unversionedDatabaseHasNoTables(preflight)) {
462
+ if (existingUnversionedDatabase && hasNoOtherTables) {
449
463
  existingUnversionedDatabase = false;
450
464
  treatUnversionedAsFresh = true;
451
465
  }
@@ -539,7 +553,21 @@ export function openStateDatabase(dbPath, options) {
539
553
  if (freshReservation)
540
554
  closeFileIdentity(freshReservation);
541
555
  releaseActivity?.();
542
- throw error;
556
+ // The migration engine's own writer-lock retry (sqlite-migrations.ts's
557
+ // `withImmediateWriteLock`, used by every migration this open can run,
558
+ // including a from-empty first open) throws the raw driver error after
559
+ // its own retry budget, not a `TransientError` — only
560
+ // `beginImmediateTransaction` below does that reclassification, and nothing
561
+ // upstream of it re-wraps a raw SQLITE_BUSY/LOCKED that surfaces from
562
+ // deeper in the open/migrate sequence (field follow-up: a from-empty
563
+ // first open racing a concurrent opener can still hit this under load).
564
+ // Reclassify uniformly here so the contract this function promises --
565
+ // contention is reported as `STATE_DB_CONTENDED` (exit 75), never a raw
566
+ // driver message (exit 70) -- holds regardless of which internal step
567
+ // the contention was observed at. Anything not contention-shaped
568
+ // (a genuine legacy-database refusal, corruption, a real schema error)
569
+ // rethrows exactly as raised.
570
+ throwBeginFailure(error, "state");
543
571
  }
544
572
  }
545
573
  /**
@@ -690,35 +718,43 @@ function sleepSyncMs(ms) {
690
718
  return;
691
719
  sleepSync(ms);
692
720
  }
693
- /**
694
- * Open, but deliberately do not finish, an immediate transaction.
695
- *
696
- * This is the split-phase counterpart to {@link withImmediateTransaction} for
697
- * the source-update coordinator: index finalization must mutate state.db in a
698
- * transaction that remains pending until content, lockfile, and index
699
- * publication have all succeeded. The caller that asked for this split phase
700
- * owns the matching COMMIT/ROLLBACK.
701
- */
702
721
  /**
703
722
  * Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
704
- * (#948) into a `TransientError("STATE_DB_CONTENDED")`, mirroring the
705
- * RUN_LEASE_HELD precedent (`WorkflowRunsRepository.acquireEngineLease`): the
706
- * driver text is accurate but unhelpful (`{"ok":false,"error":"database is
707
- * locked"}`, exit 70/INTERNAL) — this instead reads as a retryable-shortly
708
- * signal (exit 75, sysexits EX_TEMPFAIL — #948 addendum) with the original
709
- * error preserved as `cause` for `--verbose`/debugging. A genuinely unrelated
710
- * error (not contention-shaped) is rethrown exactly as raised, never
711
- * reclassified.
723
+ * (#948) into a `TransientError`, mirroring the RUN_LEASE_HELD precedent
724
+ * (`WorkflowRunsRepository.acquireEngineLease`): the driver text is accurate
725
+ * but unhelpful (`{"ok":false,"error":"database is locked"}`, exit
726
+ * 70/INTERNAL) — this instead reads as a retryable-shortly signal (exit 75,
727
+ * sysexits EX_TEMPFAIL — #948 addendum) with the original error preserved as
728
+ * `cause` for `--verbose`/debugging. A genuinely unrelated error (not
729
+ * contention-shaped) is rethrown exactly as raised, never reclassified.
730
+ *
731
+ * `dbKind` (field follow-up to #956) picks the reported identity: `"state"`
732
+ * (default, unchanged text) yields `STATE_DB_CONTENDED`; `"index"` yields
733
+ * `INDEX_DB_CONTENDED` with index.db's own message, mirroring
734
+ * `reclassifyIndexDbContention`'s text (`src/indexer/indexer.ts`) so a caller
735
+ * that reaches this helper directly and one that only reclassifies a raw
736
+ * driver error report the same thing.
712
737
  */
713
- function throwBeginFailure(err) {
738
+ function throwBeginFailure(err, dbKind) {
714
739
  if (isSqliteContentionError(err)) {
715
- const contended = new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
740
+ const contended = dbKind === "index"
741
+ ? new TransientError("akm's index database is busy (another akm process is writing it); retry shortly.", "INDEX_DB_CONTENDED")
742
+ : new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
716
743
  contended.cause = err;
717
744
  throw contended;
718
745
  }
719
746
  throw err;
720
747
  }
721
- export function beginImmediateTransaction(db) {
748
+ /**
749
+ * Open, but deliberately do not finish, an immediate transaction.
750
+ *
751
+ * This is the split-phase counterpart to {@link withImmediateTransaction} for
752
+ * the source-update coordinator: index finalization must mutate state.db in a
753
+ * transaction that remains pending until content, lockfile, and index
754
+ * publication have all succeeded. The caller that asked for this split phase
755
+ * owns the matching COMMIT/ROLLBACK.
756
+ */
757
+ export function beginImmediateTransaction(db, dbKind = "state") {
722
758
  if (db.inTransaction) {
723
759
  throw new Error("beginImmediateTransaction requires a connection with no active transaction");
724
760
  }
@@ -745,12 +781,12 @@ export function beginImmediateTransaction(db) {
745
781
  sleepSyncMs(2 ** (attempt - 1));
746
782
  continue;
747
783
  }
748
- throwBeginFailure(err);
784
+ throwBeginFailure(err, dbKind);
749
785
  }
750
786
  }
751
- throwBeginFailure(lastBeginErr);
787
+ throwBeginFailure(lastBeginErr, dbKind);
752
788
  }
753
- export function withImmediateTransaction(db, fn) {
789
+ export function withImmediateTransaction(db, fn, dbKind = "state") {
754
790
  // Re-entrancy guard (issue #686): if a transaction is already open on this
755
791
  // connection (e.g. a nested withImmediateTransaction call inside an outer
756
792
  // frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
@@ -761,7 +797,7 @@ export function withImmediateTransaction(db, fn) {
761
797
  if (db.inTransaction) {
762
798
  return fn();
763
799
  }
764
- beginImmediateTransaction(db);
800
+ beginImmediateTransaction(db, dbKind);
765
801
  try {
766
802
  const result = fn();
767
803
  if (!db.inTransaction) {
@@ -0,0 +1,306 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { getConfigPath } from "../core/paths.js";
5
+ import { isVerbose } from "../core/warn.js";
6
+ import { embedBatch } from "../llm/embedder.js";
7
+ import { probeProviderLimits } from "../llm/embedders/provider-limits.js";
8
+ import { describeEmbeddingCredential, hasRemoteEndpoint, normalizeEmbeddingEndpoint, } from "../llm/embedders/remote.js";
9
+ import { getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
10
+ import { SQLITE_CHUNK_SIZE } from "../storage/repositories/index-sql.js";
11
+ import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
12
+ import { dropOtherIdentities, listMissingHashes, upsertUnitVectors } from "../storage/repositories/units-repository.js";
13
+ import { deriveObservedEmbeddingIdentity } from "./embedding-identity.js";
14
+ /**
15
+ * Failure threshold, within the recent window below, that stops dispatching
16
+ * further provider batches. Mirrors materialize-embeddings.ts's own
17
+ * (unexported) `CIRCUIT_BREAKER_THRESHOLD`, #954 — reimplemented here at the
18
+ * same value rather than imported, since that file is private and slated for
19
+ * deletion by B5; the underlying stop-dispatch MECHANISM (`onSkip` returning
20
+ * `false`) is still the real `RemoteEmbedder`'s, reused unmodified. Two
21
+ * independent streaks share it: single-document failures (a multi-document
22
+ * timeout is not yet evidence of a dead endpoint — `RemoteEmbedder` retries
23
+ * and splits it smaller before ever reporting it this small), or network
24
+ * errors at ANY size (never retried, trusted immediately). Storage-write
25
+ * failures (E5b — `upsertUnitVectors`'s own per-row result) feed the SAME two
26
+ * streaks: a sustained STORAGE failure (contention, permissions, a full
27
+ * disk) must stop paying for provider requests just as surely as a
28
+ * sustained PROVIDER failure, even while the provider itself keeps
29
+ * succeeding.
30
+ */
31
+ const CIRCUIT_BREAKER_THRESHOLD = 3;
32
+ /**
33
+ * Recent-history window (in settled batch-starts) the two streaks above are
34
+ * evaluated over, in place of a plain "reset to zero on any success" counter
35
+ * (round-2 field finding): with concurrent dispatch (default 2, up to 16)
36
+ * outcomes settle out of dispatch order, so a degraded endpoint failing MOST
37
+ * requests never tripped the breaker as long as occasional successes
38
+ * interleaved — reproduced with a 67% failure rate dispatching the entire
39
+ * pending set. `CIRCUIT_BREAKER_THRESHOLD` failures within the last
40
+ * `CIRCUIT_BREAKER_WINDOW` settled batch-starts of a streak's own kind (see
41
+ * {@link pushBreakerOutcome}) trips it: a genuinely dead endpoint (no
42
+ * successes at all) still trips in exactly `CIRCUIT_BREAKER_THRESHOLD`
43
+ * batches, same as before; a single success now only AGES a failure out of
44
+ * the window over time rather than erasing the whole run's evidence at once.
45
+ */
46
+ const CIRCUIT_BREAKER_WINDOW = CIRCUIT_BREAKER_THRESHOLD * 2;
47
+ /** Record one settled batch-start's outcome into a breaker streak's window, capped at {@link CIRCUIT_BREAKER_WINDOW}. */
48
+ function pushBreakerOutcome(window, isFailure) {
49
+ window.push(isFailure);
50
+ if (window.length > CIRCUIT_BREAKER_WINDOW)
51
+ window.shift();
52
+ }
53
+ /** Failures currently recorded in a breaker streak's window. */
54
+ function breakerFailureCount(window) {
55
+ return window.reduce((n, isFailure) => n + (isFailure ? 1 : 0), 0);
56
+ }
57
+ /**
58
+ * Prefix of the per-committed-batch progress line (`"${DRAIN_BATCH_PROGRESS_PREFIX}N: …"`,
59
+ * emitted once per provider batch this call commits). Exported so a caller
60
+ * juggling several `onProgress` sources (`stash-cli.ts`'s `akm index`) can
61
+ * recognize — and, outside `--verbose`, suppress — this specific
62
+ * high-frequency line by prefix rather than re-deriving its own copy of the
63
+ * pattern (#954).
64
+ */
65
+ export const DRAIN_BATCH_PROGRESS_PREFIX = "[drain] batch ";
66
+ function throwIfAborted(signal) {
67
+ if (signal?.aborted) {
68
+ throw signal.reason instanceof Error ? signal.reason : new Error("drain interrupted");
69
+ }
70
+ }
71
+ /** Every distinct unit hash the index currently knows about, regardless of identity. */
72
+ function selectAllUnitHashes(db) {
73
+ return db.prepare("SELECT unit_hash FROM unit_texts ORDER BY unit_hash").all().map((row) => row.unit_hash);
74
+ }
75
+ /** `unit_hash -> text` for exactly `hashes`, chunked to respect SQLite's bound-parameter limit. */
76
+ function fetchUnitTexts(db, hashes) {
77
+ const texts = new Map();
78
+ for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
79
+ const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
80
+ const placeholders = chunk.map(() => "?").join(",");
81
+ const rows = db
82
+ .prepare(`SELECT unit_hash, text FROM unit_texts WHERE unit_hash IN (${placeholders})`)
83
+ .all(...chunk);
84
+ for (const row of rows)
85
+ texts.set(row.unit_hash, row.text);
86
+ }
87
+ return texts;
88
+ }
89
+ /**
90
+ * Effective embedding config and request packing for this drain (index
91
+ * redesign, B5 — replaces the retired `embedding.maxTokens`/`batchSize`/
92
+ * `contextLength` config keys): `concurrency` (in-flight requests) defaults
93
+ * to the provider's OWN observed slot count when `embedding.concurrency`
94
+ * itself leaves it unset (`probeProviderLimits` already applies that same
95
+ * override to `slots`). The request TOKEN WINDOW, chars-per-token ratio, and
96
+ * Ollama `num_ctx` are no longer config fields at all — they are threaded
97
+ * into `RemoteEmbedder.embedBatch` as `packing`, sourced straight from the
98
+ * same probe: `windowTokens` for the per-request budget, `charsPerToken` for
99
+ * the calibrated per-text token estimate, and `windowTokens` again for
100
+ * Ollama's `num_ctx` when `source === "ollama"`. `windowIsKnown`
101
+ * (`source !== "default"`) gates `RemoteEmbedder`'s same-run adaptive
102
+ * shrink: a provider that reports nothing about its own context size still
103
+ * gets that corrective, but a probed, authoritative window does not need it
104
+ * second-guessed.
105
+ */
106
+ async function resolveEmbeddingPacking(config, signal) {
107
+ const base = config.embedding ?? {};
108
+ const limits = await probeProviderLimits(base, { signal });
109
+ return {
110
+ embeddingConfig: { ...base, concurrency: base.concurrency ?? limits.slots },
111
+ packing: {
112
+ tokenBudget: limits.windowTokens,
113
+ charsPerToken: limits.charsPerToken,
114
+ windowIsKnown: limits.source !== "default",
115
+ ollamaNumCtx: limits.source === "ollama" ? limits.windowTokens : undefined,
116
+ },
117
+ };
118
+ }
119
+ /**
120
+ * #953 field gap, ported from the deleted `materialize-embeddings.ts`
121
+ * (`git show fc711fd6^:src/indexer/materialize-embeddings.ts`): a keyless
122
+ * request against a remote embedding endpoint could not be reproduced in the
123
+ * lab — every `RemoteEmbedder` path already resolves `secret://` through one
124
+ * boundary, so a keyless request can only mean `embedding.apiKey` was absent
125
+ * from the config THIS run loaded. The actionable outcome is a
126
+ * self-diagnosing run, not a fix: one default-level line, emitted once
127
+ * before the first provider request this call makes, naming the endpoint,
128
+ * model, and credential SOURCE (never the value) so a field run can compare
129
+ * it against what the gateway actually saw. A local (non-remote) endpoint,
130
+ * or a call with nothing pending, has nothing to diagnose and stays silent.
131
+ */
132
+ function emitCredentialDiagnostic(config, onProgress) {
133
+ if (!onProgress || !hasRemoteEndpoint(config.embedding ?? {}))
134
+ return;
135
+ const endpoint = normalizeEmbeddingEndpoint(config.embedding?.endpoint ?? "");
136
+ const credential = describeEmbeddingCredential(config.embedding?.apiKey);
137
+ const configFileSuffix = isVerbose() ? `; config: ${getConfigPath()}` : "";
138
+ onProgress(`[embed] endpoint ${endpoint}, model ${config.embedding?.model ?? "unknown"}; credential: ${credential}${configFileSuffix}`);
139
+ }
140
+ function formatDoneLine(counts) {
141
+ return (`[drain] done: ${counts.pending} pending, ${counts.embedded} embedded, ${counts.failed} failed, ` +
142
+ `${counts.skipped} skipped (identity: ${counts.identity ?? "unknown"})`);
143
+ }
144
+ export async function drainEmbeddingQueue(db, config, opts = {}) {
145
+ throwIfAborted(opts.signal);
146
+ let identity = getMeta(db, "embeddingIdentity") ?? null;
147
+ if (config.semanticSearchMode === "off") {
148
+ return { pending: 0, embedded: 0, failed: 0, skipped: 0, identity };
149
+ }
150
+ const candidateHashes = opts.onlyHashes ? [...new Set(opts.onlyHashes)] : selectAllUnitHashes(db);
151
+ const missingHashes = identity ? listMissingHashes(db, candidateHashes, identity) : candidateHashes;
152
+ const pending = missingHashes.length;
153
+ const emitDone = (counts) => {
154
+ opts.onProgress?.(formatDoneLine(counts));
155
+ return counts;
156
+ };
157
+ // upsertUnitVectors is a no-op without sqlite-vec (units-repository.ts), so
158
+ // embedding the pending set here would just throw every vector away and
159
+ // leave it "missing" again for the next call — pure wasted provider
160
+ // traffic. `akmIndex`'s verification reports the missing extension as
161
+ // blocked; this stays silent. `pending` is computed above so the done line and `akm
162
+ // index status` stay truthful even though nothing was attempted.
163
+ if (!isVecAvailable(db)) {
164
+ return emitDone({ pending, embedded: 0, failed: 0, skipped: pending, identity });
165
+ }
166
+ if (pending === 0) {
167
+ return emitDone({ pending: 0, embedded: 0, failed: 0, skipped: 0, identity });
168
+ }
169
+ const boundedHashes = opts.limit !== undefined ? missingHashes.slice(0, opts.limit) : missingHashes;
170
+ const textByHash = fetchUnitTexts(db, boundedHashes);
171
+ // A hash in `unit_texts` should always resolve to a row (it was just read
172
+ // from that same table above), but a missing row is dropped rather than
173
+ // sent to the provider as `undefined` text.
174
+ const orderedHashes = boundedHashes.filter((hash) => textByHash.has(hash));
175
+ const texts = orderedHashes.map((hash) => textByHash.get(hash));
176
+ if (texts.length === 0) {
177
+ return emitDone({ pending, embedded: 0, failed: 0, skipped: 0, identity });
178
+ }
179
+ emitCredentialDiagnostic(config, opts.onProgress);
180
+ const { embeddingConfig, packing } = await resolveEmbeddingPacking(config, opts.signal);
181
+ let embedded = 0;
182
+ let failed = 0;
183
+ let batchNumber = 0;
184
+ // Two independent circuit-breaker streaks (single-document failures,
185
+ // network errors at any size) — see CIRCUIT_BREAKER_WINDOW above.
186
+ const singleDocFailureWindow = [];
187
+ const networkErrorFailureWindow = [];
188
+ // Whether this CALL has already decided the identity its first committed
189
+ // row observed (E1) — adoption happens at most once per call; see the
190
+ // module doc comment and the identity block in `onBatch` below.
191
+ let identityDecidedThisCall = false;
192
+ const onSkip = (skip) => {
193
+ failed++;
194
+ if (!skip.batchStart)
195
+ return undefined;
196
+ if (skip.reason === "context-window-exceeded") {
197
+ // Proves the provider IS reachable; not evidence of a dead endpoint.
198
+ singleDocFailureWindow.length = 0;
199
+ networkErrorFailureWindow.length = 0;
200
+ return undefined;
201
+ }
202
+ pushBreakerOutcome(singleDocFailureWindow, skip.batchSize === 1);
203
+ pushBreakerOutcome(networkErrorFailureWindow, skip.failureKind === "network-error");
204
+ if (breakerFailureCount(singleDocFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD ||
205
+ breakerFailureCount(networkErrorFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD) {
206
+ return false;
207
+ }
208
+ return undefined;
209
+ };
210
+ const onBatch = (indices, embeddings, model, outcome) => {
211
+ // "retrying"/"budget-lowered" are in-flight notices for a batch that has
212
+ // not settled yet (see EmbeddingBatchOutcome) — nothing to commit or
213
+ // count, and not a distinct "batch" for the one-line-per-batch contract.
214
+ if (outcome?.outcome === "retrying" || outcome?.outcome === "budget-lowered")
215
+ return;
216
+ const rows = [];
217
+ for (let k = 0; k < indices.length; k++) {
218
+ const embedding = embeddings[k];
219
+ if (!embedding)
220
+ continue;
221
+ const learned = deriveObservedEmbeddingIdentity(config.embedding, model, embedding.length);
222
+ if (!identityDecidedThisCall) {
223
+ // This call's FIRST committed row decides the identity it adopts
224
+ // (E1) — learned once, not re-derived per batch: a provider whose
225
+ // responses alternate between models WITHIN one call (a
226
+ // load-balanced gateway, a blue/green rollout behind one endpoint)
227
+ // must not thrash the store between them (embed, delete, re-embed,
228
+ // never converging). A genuine model change is still caught, just
229
+ // not until the NEXT call's own first batch observes it and purges
230
+ // whatever this call left behind.
231
+ identityDecidedThisCall = true;
232
+ if (learned && learned !== identity) {
233
+ identity = learned;
234
+ setMeta(db, "embeddingIdentity", identity);
235
+ dropOtherIdentities(db, identity, embedding.length);
236
+ }
237
+ }
238
+ const currentIdentity = identity;
239
+ if (currentIdentity === null || learned !== currentIdentity) {
240
+ // Either nothing has ever been learned, or a LATER batch this same
241
+ // call reported an identity different from the one already adopted
242
+ // — left missing rather than switched to; it becomes "missing"
243
+ // again under whatever identity this call is using, and a later
244
+ // call, whose own first batch observes it, picks it up. Counted in
245
+ // `skipped` below (attempted minus embedded minus failed), not
246
+ // `embedded`.
247
+ continue;
248
+ }
249
+ const hash = orderedHashes[indices[k]];
250
+ if (hash)
251
+ rows.push({ hash, identity: currentIdentity, vector: embedding });
252
+ }
253
+ let storageBreakerTripped = false;
254
+ if (rows.length > 0) {
255
+ // upsertUnitVectors commits each row in its own transaction — this IS
256
+ // "each provider batch commits durably" (a wrapping db.transaction()
257
+ // here would only nest as an unobservable SAVEPOINT inside it, per the
258
+ // ambient-transaction hazard materialize-embeddings.ts's own drift
259
+ // guard documents), now made even finer-grained so one malformed
260
+ // vector in a batch (e.g. a width mismatch) can't roll back the rest
261
+ // of an otherwise-good response.
262
+ const result = upsertUnitVectors(db, rows);
263
+ embedded += result.inserted;
264
+ failed += result.failed;
265
+ if (result.failed > 0) {
266
+ // E5b: a write failure is just as much evidence of a broken run as
267
+ // a provider failure — a sustained STORAGE failure (contention,
268
+ // permissions, a full disk) must not keep dispatching every
269
+ // remaining batch to a perfectly healthy provider at full cost
270
+ // while every write silently fails. One event per committed batch
271
+ // (the "count batch starts, not documents" rule onSkip already
272
+ // applies to provider failures), fed into the SAME two streaks.
273
+ pushBreakerOutcome(singleDocFailureWindow, true);
274
+ pushBreakerOutcome(networkErrorFailureWindow, true);
275
+ if (breakerFailureCount(singleDocFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD ||
276
+ breakerFailureCount(networkErrorFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD) {
277
+ storageBreakerTripped = true;
278
+ }
279
+ }
280
+ }
281
+ if (embeddings.some((embedding) => embedding !== undefined)) {
282
+ pushBreakerOutcome(singleDocFailureWindow, false);
283
+ pushBreakerOutcome(networkErrorFailureWindow, false);
284
+ }
285
+ batchNumber++;
286
+ if (opts.onProgress) {
287
+ const docCount = outcome?.docCount ?? indices.length;
288
+ const label = outcome && outcome.outcome !== "stored" ? `failed: ${outcome.reason ?? "unknown"}` : `${rows.length} stored`;
289
+ opts.onProgress(`${DRAIN_BATCH_PROGRESS_PREFIX}${batchNumber}: ${docCount} docs → ${label}`);
290
+ }
291
+ if (storageBreakerTripped) {
292
+ // onBatch has no `false`-return stop-dispatch contract the way onSkip
293
+ // does (a storage failure can trip this even when the provider itself
294
+ // keeps succeeding, so onSkip is never called at all) — this reuses
295
+ // RemoteEmbedder's own documented mechanism instead: a throw from
296
+ // onBatch stops the pool from dispatching any further provider
297
+ // request, and is rethrown once every in-flight batch has settled.
298
+ throw new Error(`Circuit breaker: ${CIRCUIT_BREAKER_THRESHOLD} storage write failures while embedding; stopping further provider requests this call.`);
299
+ }
300
+ };
301
+ await embedBatch(texts, embeddingConfig, opts.signal, onSkip, onBatch, packing);
302
+ throwIfAborted(opts.signal);
303
+ const attempted = texts.length;
304
+ const skipped = Math.max(0, attempted - embedded - failed);
305
+ return emitDone({ pending, embedded, failed, skipped, identity });
306
+ }