omgbase 0.26.3 → 0.29.0

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "e7c11d3",
3
- "built": "2026-10-23T02:30:46.363Z"
2
+ "commit": "4966991a",
3
+ "built": "2026-10-26T20:22:57.580Z"
4
4
  }
@@ -1,4 +1,4 @@
1
- import { EmbeddingWorker, EngineError, type EmbeddingProvider } from "@omgbase/core";
1
+ import { EmbeddingWorker, EngineError, type EmbeddingProvider, type DrainMode } from "@omgbase/core";
2
2
  import type { Cli } from "../context.js";
3
3
  export interface LoadedEmbedding {
4
4
  worker: EmbeddingWorker;
@@ -19,7 +19,9 @@ export interface LoadedEmbedding {
19
19
  * embedder is never mistaken for an unconfigured one. Callers MUST call close()
20
20
  * when done (e.g. in a finally) to release a spawned process.
21
21
  */
22
- export declare function loadEmbedding(ws: ReturnType<Cli["workspace"]>, repoId: string): Promise<LoadedEmbedding | null>;
22
+ export declare function loadEmbedding(ws: ReturnType<Cli["workspace"]>, repoId: string, opts?: {
23
+ switchedOff?: "null" | "throw";
24
+ }): Promise<LoadedEmbedding | null>;
23
25
  /** Build the shared `embedder_failed` error: a configured embedder that can't be
24
26
  * reached is a hard, clearly-diagnosed fault (not `semantic_unavailable`, which
25
27
  * means "no provider configured"). */
@@ -48,7 +50,10 @@ export declare function drainEmbeddings(cli: Cli, ws: ReturnType<Cli["workspace"
48
50
  }): Promise<{
49
51
  embedded: number;
50
52
  cached: number;
51
- pruned?: {
53
+ mode: DrainMode;
54
+ processed_seq: number | null;
55
+ head: number | null;
56
+ pruned: {
52
57
  blocks: number;
53
58
  docs: number;
54
59
  };
@@ -1,4 +1,4 @@
1
- import { EmbeddingWorker, buildEmbedTasks, buildDocEmbedTasks, embeddingSettings, createExternalProvider, resolveSettings, EngineError } from "@omgbase/core";
1
+ import { EmbeddingWorker, buildEmbedTasks, embeddingSettings, createExternalProvider, resolveSettings, requireSemantic, semanticSwitchedOff, CapabilityUnavailable, EngineError } from "@omgbase/core";
2
2
  /**
3
3
  * Connect the configured embedding provider + build a worker for a repo, or
4
4
  * null when embedding.provider is UNSET (callers surface semantic_unavailable).
@@ -8,10 +8,25 @@ import { EmbeddingWorker, buildEmbedTasks, buildDocEmbedTasks, embeddingSettings
8
8
  * embedder is never mistaken for an unconfigured one. Callers MUST call close()
9
9
  * when done (e.g. in a finally) to release a spawned process.
10
10
  */
11
- export async function loadEmbedding(ws, repoId) {
11
+ export async function loadEmbedding(ws, repoId, opts = {}) {
12
12
  const settings = embeddingSettings(resolveSettings(ws.store, repoId));
13
13
  if (!settings.provider)
14
14
  return null;
15
+ // The row switched off by `omg embed disable` while the provider stays
16
+ // configured (spec/store §5.10, Phase 4b): an opportunistic caller (`find`,
17
+ // `omg mcp`'s host) gets the quiet null — FTS-only, nothing is spawned; an
18
+ // explicit one (`embed status|drain`) is refused with the fix `omg embed enable`.
19
+ if (semanticSwitchedOff(ws.store.db, repoId)) {
20
+ if (opts.switchedOff === "throw")
21
+ throw CapabilityUnavailable.semanticSwitchedOff();
22
+ return null;
23
+ }
24
+ // A configured provider on a store that runs WITHOUT the semantic module
25
+ // (`OMGBASE_CAPABILITIES=core`) cannot embed or rank: `capability_unavailable
26
+ // { semantic, disabled }` (modular-core Phase 2). An unconfigured one stays
27
+ // the quiet null above on every composition.
28
+ if (!ws.store.capabilities.semantic)
29
+ throw CapabilityUnavailable.semanticDisabled();
15
30
  let ext;
16
31
  try {
17
32
  ext = await createExternalProvider(settings);
@@ -51,8 +66,7 @@ export async function drainEmbeddings(cli, ws, repoId, opts = {}) {
51
66
  if (!loaded)
52
67
  return null;
53
68
  try {
54
- const tasks = buildEmbedTasks(ws.store, repoId);
55
- const pending = loaded.worker.staleBlocks(tasks);
69
+ const pending = loaded.worker.staleBlocks(buildEmbedTasks(ws.store, repoId));
56
70
  if (opts.confirm) {
57
71
  const ok = await opts.confirm({ pending: pending.length, remote: loaded.remote, providerName: loaded.providerName });
58
72
  if (!ok)
@@ -66,34 +80,36 @@ export async function drainEmbeddings(cli, ws, repoId, opts = {}) {
66
80
  cli.io.err(cli.style.dim(` embedding ${pending.length} block(s) via ${loaded.providerName} (local process)`));
67
81
  }
68
82
  const verboseHuman = Boolean(opts.verbose) && cli.flags.mode === "human";
69
- const result = await loaded.worker.process(tasks, {
70
- ...(verboseHuman
71
- ? { onProgress: ({ embedded, total }) => cli.io.err(cli.style.dim(` … ${embedded}/${total} embedded`)) }
72
- : {}),
73
- });
74
- // Doc-grain vectors: embed whole documents (or pool block vectors for
75
- // oversized docs) after blocks, so the pooled fallback can reuse the
76
- // vectors just cached. This is also the one-time backfill path for existing
77
- // repos — a fresh doc_embeddings table fills on the first drain.
78
- const docTasks = buildDocEmbedTasks(ws.store, repoId);
79
- const docResult = await loaded.worker.processDocs(docTasks, {
83
+ // The drain is the semantic capability's (modular-core Phase 2): blocks
84
+ // then documents — so the pooled fallback can reuse the vectors just cached
85
+ // — bootstrapping from a consistent snapshot when the repository has no
86
+ // watermark, catching up from it otherwise. `--prune` forces the full
87
+ // rescan with its cleanup: the current model's vectors are written before
88
+ // the dead ones go, and other models' rows go with them.
89
+ const semantic = requireSemantic(ws.store.db);
90
+ const result = await semantic.drain(loaded.worker, repoId, {
91
+ rescan: Boolean(opts.prune),
80
92
  ...(verboseHuman
81
- ? { onProgress: ({ embedded, total }) => cli.io.err(cli.style.dim(` … ${embedded}/${total} doc(s) embedded`)) }
93
+ ? { onProgress: ({ phase, embedded, total }) => cli.io.err(cli.style.dim(` … ${embedded}/${total} ${phase === "docs" ? "doc(s) " : ""}embedded`)) }
82
94
  : {}),
83
95
  });
84
- // Prune AFTER the drain, so the current model's vectors are in place before
85
- // we reclaim the old model's — a crash mid-way leaves the new vectors, not a
86
- // half-empty cache. Only touches rows for models other than the current one.
87
- let pruned;
96
+ // What the pass cleaned up: an identity change's invalidation and the S1
97
+ // sweep every pass does; with `--prune`, other models' leftovers as well.
98
+ const pruned = { ...result.pruned };
88
99
  if (opts.prune) {
89
- pruned = loaded.worker.pruneForeignVectors();
90
- if (cli.flags.mode === "human" && (pruned.blocks || pruned.docs)) {
91
- cli.io.err(cli.style.dim(` pruned ${pruned.blocks} block + ${pruned.docs} doc vector(s) from other models`));
92
- }
100
+ const foreign = semantic.pruneForeign(loaded.worker);
101
+ pruned.blocks += foreign.blocks;
102
+ pruned.docs += foreign.docs;
103
+ }
104
+ if (cli.flags.mode === "human" && (pruned.blocks || pruned.docs)) {
105
+ cli.io.err(cli.style.dim(` pruned ${pruned.blocks} block + ${pruned.docs} doc vector(s) (other models, vanished content)`));
93
106
  }
94
107
  // Fold doc work into the block counts so callers' "embedded/cached" report
95
108
  // covers both grains (pooled docs count as embedded work done).
96
- return { embedded: result.embedded + docResult.embedded + docResult.pooled, cached: result.cached + docResult.cached, ...(pruned ? { pruned } : {}) };
109
+ return {
110
+ embedded: result.embedded + result.docEmbedded + result.pooled, cached: result.cached + result.docCached,
111
+ mode: result.mode, processed_seq: result.processed_seq, head: result.head, pruned,
112
+ };
97
113
  }
98
114
  finally {
99
115
  await loaded.close();
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { rebuildIndex, runGc, reposStatus, buildEmbedTasks, buildDocEmbedTasks, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, ftsIndexedRowCount, liveLeafCount, sizeReport, vacuum, reclaimableBytes, humanBytes, formatShare, } from "@omgbase/core";
2
+ import { rebuildIndex, runGc, buildEmbedTasks, buildDocEmbedTasks, indexIdentity, requireSemantic, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, ftsIndexedRowCount, liveLeafCount, sizeReport, activeAlerts, refreshLegacyBytesAlert, readCapability, openCoverageFrom, captureFor, enableSemantic, disableSemantic, semanticProviderOf, SemanticProviderMissing, withWriterLock, vacuum, reclaimableBytes, humanBytes, formatShare, } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, EXIT_ERROR, emitMachine, renderHelp } from "../output.js";
4
4
  import { columns } from "../render.js";
5
5
  import { loadEmbedding, drainEmbeddings } from "./_embed.js";
@@ -40,7 +40,7 @@ function runGcCmd(cli, args) {
40
40
  if (values.help) {
41
41
  return renderHelp(cli, {
42
42
  name: "gc",
43
- summary: "Mark-and-sweep unreferenced blobs and tree nodes, and the `same` disposition rows a pre-14.2 store wrote (refuses unless gc.enabled is set in repo settings)",
43
+ summary: "Mark-and-sweep unreferenced blobs and tree nodes, the `same` disposition rows a pre-14.2 store wrote, and (store 17.0) the id claims older than identity.claims_retain_days that name no row and the change-feed rows past feed.retain_days / feed.retain_entries (refuses unless gc.enabled is set in repo settings)",
44
44
  usage: "gc [--dry-run] [--vacuum]",
45
45
  options: [
46
46
  ["--dry-run", "report what would be swept, sweep nothing (allowed even when gc is disabled)"],
@@ -70,7 +70,13 @@ function runGcCmd(cli, args) {
70
70
  const result = vac ? { ...swept, vacuum: vac } : swept;
71
71
  if (cli.flags.mode !== "human")
72
72
  return emitMachine(cli, result);
73
- cli.io.err(cli.style.dim(` swept ${swept.blobsSwept} blobs, ${swept.treeNodesSwept} tree nodes, ${swept.sameDispositionsSwept} same dispositions`));
73
+ // spec/store §7.3 (17.0): the claims prune's count is shown only when it
74
+ // released something — the line is pinned by shape without it, and the
75
+ // setting is off by default.
76
+ const released = swept.claimsReleased > 0 ? `, ${swept.claimsReleased} id claims released` : "";
77
+ // spec/store §7.4 (17.0): likewise the feed rows pruned under the floors, only when non-zero.
78
+ const pruned = swept.feedPruned > 0 ? `, ${swept.feedPruned} feed rows pruned` : "";
79
+ cli.io.err(cli.style.dim(` swept ${swept.blobsSwept} blobs, ${swept.treeNodesSwept} tree nodes, ${swept.sameDispositionsSwept} same dispositions${released}${pruned}`));
74
80
  if (vac)
75
81
  cli.io.err(cli.style.dim(vac.applied ? ` vacuumed, reclaimed ${humanBytes(vac.reclaimable)}` : ` would vacuum ${humanBytes(vac.reclaimable)}`));
76
82
  return EXIT_OK;
@@ -81,30 +87,67 @@ function runDoctor(cli, args) {
81
87
  if (values.help) {
82
88
  return renderHelp(cli, {
83
89
  name: "doctor",
84
- summary: "Invariant sweep: convergence, FTS rows vs live leaf blocks, dangling revisions, SQLite integrity — exit 1 on any failure (CI-able)",
90
+ summary: "Invariant sweep: FTS rows vs live leaf blocks, head present, bytes reproducible, capture complete, active alerts, SQLite integrity — exit 1 on any failure (CI-able)",
85
91
  usage: ["doctor [--json]", "doctor --size [--json]"],
86
92
  options: [
87
- ["--size", "instead of the checks: what the database is made of — bytes and rows per category (content, history, search, sync) and table, the file's pages, what VACUUM would reclaim, advice"],
88
- ["--json", "`{ ok, checks: [{ name, ok, detail }] }`; with --size `{ file, categories, same_dispositions, archived, advice }`"],
93
+ ["--size", "instead of the checks: what the database is made of — bytes and rows per category (content, identity, recovery, history, search, sync — the change feed under sync) and table, the file's pages, the feed's position, what VACUUM would reclaim, advice"],
94
+ ["--json", "`{ ok, checks: [{ name, ok, detail }] }`; with --size `{ file, categories, same_dispositions, id_claims, archived, feed, capabilities, advice }`"],
89
95
  ],
90
96
  });
91
97
  }
92
98
  const ws = cli.workspace();
93
- if (values.size)
94
- return runDoctorSize(cli, sizeReport(ws.store));
95
- const repo = cli.repo(ws);
99
+ // The size report is store-wide; its `feed` line is a repository's (spec/store
100
+ // §7.2, 17.0) — the selected one when the selection resolves, else the
101
+ // store's only one (the report decides), never an error.
102
+ if (values.size) {
103
+ const repoId = selectedRepoId(cli, ws);
104
+ return runDoctorSize(cli, sizeReport(ws.store, repoId === undefined ? {} : { repoId }));
105
+ }
106
+ const repo = cli.repo(ws); // the selected repo must resolve (`repo_not_found` otherwise); the checks are store-wide but the alerts are its
96
107
  const db = ws.store.db;
97
108
  const checks = [];
98
- const status = reposStatus(ws.store, repo.repoId);
99
- checks.push({ name: "convergence", ok: status.unconverged === 0, detail: `${status.unconverged} unconverged` });
109
+ // (The `convergence` check — `repos_status.unconverged === 0` — left with the
110
+ // count in store 17.0, D9: `file_hash` IS the head's hash by construction
111
+ // (I1), so it was identically true; `disk.changed` is the signal, `status`
112
+ // shows it.)
100
113
  // The index holds exactly the live LEAF blocks (spec/search §1.1): a container
101
114
  // has no row, a tombstone none. Counted from the index's shadow table — a plain
102
115
  // count(*) on an external-content FTS table answers from `blocks` instead.
103
116
  const ftsCount = ftsIndexedRowCount(db);
104
117
  const leaves = liveLeafCount(db);
105
118
  checks.push({ name: "fts rows == live leaf blocks", ok: ftsCount === leaves, detail: `fts=${ftsCount} leaves=${leaves}` });
106
- const dangling = db.prepare("SELECT count(*) c FROM docs d WHERE d.deleted_commit IS NULL AND d.current_rev IS NOT NULL AND NOT EXISTS (SELECT 1 FROM revisions r WHERE r.rev_id = d.current_rev)").get().c;
107
- checks.push({ name: "no dangling current_rev", ok: dangling === 0, detail: `${dangling} dangling` });
119
+ // Store 17.0 (D1): every live document has a head token, and while capture
120
+ // is on the recorder's `revisions` row agrees with it (spec/store §8 H1 —
121
+ // the check `doctor` used to call "no dangling current_rev"). Phase 4a: the
122
+ // check reads the repository's manifest — a head minted within the open
123
+ // coverage interval owes a revision; a repository whose capture is off (or a
124
+ // build without the module) owes none and the check says so in its detail.
125
+ const headless = db.prepare("SELECT count(*) c FROM docs d WHERE d.deleted_at IS NULL AND d.head IS NULL").get().c;
126
+ checks.push({ name: "head present", ok: headless === 0, detail: `${headless} without a head` });
127
+ // Store 17.4 (Phase 5a-ii; spec/store §5.11, §8 C9): every live document's
128
+ // rows reproduce its `file_hash` — the fidelity check the migration runs,
129
+ // re-derived here in full for the selected repository, and the `legacy_bytes`
130
+ // alert written to match (raised with the count and a sample of ids, or
131
+ // cleared), so the `alerts` check below sees the current condition. A
132
+ // document ingested before block trivia was stored fails it until `omg sync`
133
+ // repairs its rows from the file.
134
+ const legacy = refreshLegacyBytesAlert(db, repo.repoId, new Date().toISOString());
135
+ checks.push({ name: "bytes reproducible", ok: legacy.length === 0, detail: `${legacy.length} document(s) not reproducible from their rows` });
136
+ const manifest = readCapability(db, repo.repoId, "history");
137
+ const since = openCoverageFrom(manifest);
138
+ const recorder = captureFor(db, repo.repoId);
139
+ if (recorder && since !== null) {
140
+ const uncaptured = recorder.uncapturedHeads(repo.repoId, since);
141
+ checks.push({ name: "capture complete", ok: uncaptured === 0, detail: `${uncaptured} heads without a revision` });
142
+ }
143
+ else {
144
+ checks.push({ name: "capture complete", ok: true, detail: manifest?.state === "enabled" ? "history unavailable in this build" : `history disabled${manifest && manifest.coverage.length > 0 ? ` (captured ${manifest.coverage.map((i) => `${i.from}..${i.to ?? "now"}`).join(", ")})` : ""}` });
145
+ }
146
+ // Store 17.0 (3e-ii): the alert channel (spec/store §3.1 `alerts`) — the
147
+ // conditions the engine raised for a human on the selected repository; any
148
+ // active one fails the check and is named in its detail.
149
+ const alerts = activeAlerts(db, repo.repoId);
150
+ checks.push({ name: "alerts", ok: alerts.length === 0, detail: alerts.length === 0 ? "none active" : alerts.map((a) => `${a.code}[${a.severity}]: ${a.message}`).join("; ") });
108
151
  const integrity = db.pragma("integrity_check", { simple: true });
109
152
  checks.push({ name: "sqlite integrity", ok: integrity === "ok", detail: integrity });
110
153
  const allOk = checks.every((c) => c.ok);
@@ -119,6 +162,19 @@ function runDoctor(cli, args) {
119
162
  }
120
163
  return allOk ? EXIT_OK : EXIT_ERROR;
121
164
  }
165
+ /** The selected repository's id when the selection resolves; undefined when it does not (an explicit `--repo <slug>` that fails is still the error). */
166
+ function selectedRepoId(cli, ws) {
167
+ try {
168
+ return cli.repo(ws).repoId;
169
+ }
170
+ catch (err) {
171
+ if (cli.flags.repo)
172
+ throw err;
173
+ if (err instanceof EngineErrorLike && err.code === "repo_not_found")
174
+ return undefined;
175
+ throw err;
176
+ }
177
+ }
122
178
  // `doctor --size` (spec/cli §6, cli 4.3): the store's size report (spec/store
123
179
  // §7 "Size") as one aligned table — each category, then its three biggest
124
180
  // tables indented — the file's page accounting, and the advice lines. Exit 0:
@@ -160,6 +216,13 @@ function runDoctorSize(cli, report) {
160
216
  ["reclaimable", humanBytes(f.reclaimable)],
161
217
  // 15.0: the history that already moved to archive segments (spec/store §7 "Retention").
162
218
  ["archived", `${report.archived.revisions} revisions in ${report.archived.segments} segments, ${humanBytes(report.archived.bytes)} on disk`],
219
+ // 17.0: the identity claims (spec/store §2.1) — one row per id ever minted and still held; `identity.claims_retain_days` lets `gc` release old ones (§7.3).
220
+ ["id claims", `${report.id_claims} ids claimed`],
221
+ // 17.0 (3e): the change feed's position (spec/store §7.2 `feed`, §7.4) — the report's repository's; none when the store has several and none was selected.
222
+ ["feed", report.feed ? `epoch ${report.feed.epoch}, ${report.feed.entries} entries, seq ${report.feed.oldest}..${report.feed.head}` : "no repository selected"],
223
+ // 17.0 (3e-ii): the capability manifest of the same repository (spec/store §3.1 `capabilities`): `history enabled since <ts> | disabled`, `semantic enabled (<model>) | disabled`.
224
+ ["history", capabilityLine(report.capabilities, "history")],
225
+ ["semantic", capabilityLine(report.capabilities, "semantic")],
163
226
  ];
164
227
  for (const line of columns(file))
165
228
  io.out(` ${line}`);
@@ -167,6 +230,18 @@ function runDoctorSize(cli, report) {
167
230
  io.out(` ${style.dim("advice:")} ${a}`);
168
231
  return EXIT_OK;
169
232
  }
233
+ /** The manifest line of one capability: `enabled since <coverage_from>` for history, `enabled (<model>)` for semantic, `disabled`, or `no repository selected` like `feed`. */
234
+ function capabilityLine(capabilities, name) {
235
+ if (!capabilities)
236
+ return "no repository selected";
237
+ const c = capabilities.find((x) => x.capability === name);
238
+ if (!c || c.state === "disabled")
239
+ return "disabled";
240
+ if (name === "history")
241
+ return c.coverage_from ? `enabled since ${c.coverage_from}` : "enabled";
242
+ const model = c.config.model;
243
+ return typeof model === "string" ? `enabled (${model})` : "enabled";
244
+ }
170
245
  function resolveConfigScope(cli, ws) {
171
246
  if (cli.flags.repo === "")
172
247
  return { kind: "workspace" };
@@ -263,22 +338,29 @@ async function runEmbed(cli, args) {
263
338
  if (args.includes("--help") || args.includes("-h") || sub === "help") {
264
339
  return renderHelp(cli, {
265
340
  name: "embed",
266
- summary: "The embedding queue: report the provider + what is queued, or drain it to embed now",
267
- usage: ["embed [status]", "embed drain [--verbose] [--prune]"],
341
+ summary: "Semantic indexing: switch it on or off per repository, report the provider + what is queued, or drain the queue to embed now",
342
+ usage: ["embed [status]", "embed enable [--provider <command|url>]", "embed disable", "embed drain [--verbose] [--prune]"],
268
343
  options: [
269
344
  ["status", "(default) provider, model, and how many blocks/docs are embeddable but not yet embedded"],
345
+ ["enable", "switch semantic indexing on for the repository (needs a provider: --provider sets embedding.provider at the workspace layer); idempotent"],
346
+ ["disable", "switch it off: semantic() and the drain refuse until enable; the provider setting and the vectors stay; idempotent"],
347
+ ["--provider <p>", "(enable) the embedder command or http(s) URL to configure first"],
270
348
  ["drain", "embed the queued blocks now — status alone makes no progress"],
271
349
  ["-v, --verbose", "(drain) per-batch progress"],
272
350
  ["--prune", "(drain) afterwards delete vectors left by other models (e.g. after switching models)"],
273
351
  ],
274
- notes: [`needs embedding.provider (\`${cli.prog} config set embedding.provider omgbase-embedder --repo ""\`); without one, semantic search is simply off.`],
352
+ notes: [`needs embedding.provider (\`${cli.prog} config set embedding.provider omgbase-embedder --repo ""\`, or \`${cli.prog} embed enable --provider omgbase-embedder\`); without one, semantic search is simply off.`],
275
353
  });
276
354
  }
355
+ if (sub === "enable" || sub === "disable")
356
+ return runEmbedLifecycle(cli, sub, args.filter((a) => a !== sub));
277
357
  const verbose = args.includes("--verbose") || args.includes("-v");
278
358
  const prune = args.includes("--prune");
279
359
  const ws = cli.workspace();
280
360
  const repo = cli.repo(ws);
281
- const loaded = await loadEmbedding(ws, repo.repoId);
361
+ // `status`/`drain` are the explicit semantic verbs: a row switched off by
362
+ // `embed disable` refuses them (spec/store §5.10) rather than reporting nothing.
363
+ const loaded = await loadEmbedding(ws, repo.repoId, { switchedOff: "throw" });
282
364
  if (!loaded) {
283
365
  // No provider configured (05 §6). Honest report; not an error.
284
366
  const hint = 'set one with `omg config set embedding.provider <command|url>` (e.g. omgbase-embedder)';
@@ -305,14 +387,24 @@ async function runEmbed(cli, args) {
305
387
  const docPending = loaded.worker.staleDocs(docTasks);
306
388
  // Vectors left by a previous model (dead weight after a model switch).
307
389
  const foreign = loaded.worker.foreignVectorCount();
390
+ // The indexer's watermark against the feed head (modular-core Phase 2):
391
+ // how far the index is behind, and whether it was built under this provider.
392
+ const fresh = requireSemantic(ws.store.db).freshness(repo.repoId, indexIdentity(loaded.provider));
308
393
  // status
309
- const payload = { provider: loaded.providerName, model: loaded.provider.model, dim: loaded.provider.dim, embeddable: tasks.length, queued: pending.length, docs: docTasks.length, docsQueued: docPending.length, foreignBlocks: foreign.blocks, foreignDocs: foreign.docs };
394
+ const payload = {
395
+ provider: loaded.providerName, model: loaded.provider.model, dim: loaded.provider.dim, embeddable: tasks.length, queued: pending.length, docs: docTasks.length, docsQueued: docPending.length, foreignBlocks: foreign.blocks, foreignDocs: foreign.docs,
396
+ watermark: { processed_seq: fresh.processed_seq, head: fresh.head, fresh: fresh.fresh, identity: fresh.identity },
397
+ };
310
398
  if (cli.flags.mode !== "human")
311
399
  return emitMachine(cli, payload);
312
400
  else {
313
401
  cli.io.out(` provider ${cli.style.accent(loaded.providerName)} ${cli.style.dim(`(${loaded.provider.model}, ${loaded.provider.dim}d)`)}`);
314
402
  cli.io.out(` embeddable ${tasks.length} ${cli.style.dim("queued")} ${payload.queued}`);
315
403
  cli.io.out(` docs ${docTasks.length} ${cli.style.dim("queued")} ${payload.docsQueued}`);
404
+ const wm = fresh.processed_seq === null ? "none (the first drain bootstraps)" : `${fresh.processed_seq}${fresh.head !== null ? ` of ${fresh.head}` : ""}${fresh.fresh === true ? " (current)" : fresh.fresh === false ? " (behind)" : ""}`;
405
+ cli.io.out(` watermark ${wm}`);
406
+ if (fresh.identity && (fresh.identity.model !== loaded.provider.model || fresh.identity.dims !== loaded.provider.dim))
407
+ cli.io.out(cli.style.dim(` the index was built under ${fresh.identity.model} (${fresh.identity.dims}d) — the next drain rebuilds it`));
316
408
  // `status` reports but never embeds — point the reader at the verb that does.
317
409
  if (payload.queued > 0 || payload.docsQueued > 0)
318
410
  cli.io.out(cli.style.dim(` run \`omg embed drain\` to embed the ${payload.queued} queued block(s) + ${payload.docsQueued} doc(s)`));
@@ -327,6 +419,69 @@ async function runEmbed(cli, args) {
327
419
  await loaded.close();
328
420
  }
329
421
  }
422
+ /** A semantic coverage interval's `from..to` as the human lines print it. */
423
+ function semanticInterval(i) {
424
+ return `${i.from}..${i.to ?? "now"}`;
425
+ }
426
+ /**
427
+ * `embed enable [--provider <p>]` / `embed disable` (spec/store §5.10, Phase
428
+ * 4b): the semantic manifest row is the verbs'. `enable` with `--provider`
429
+ * first writes `embedding.provider` at the WORKSPACE layer (as `init
430
+ * --embedder` does — one vector space per workspace; that write re-derives
431
+ * every repository's row whose provider changed), then sets the selected
432
+ * repository's row `enabled` with an open coverage interval; without a
433
+ * configured provider it is a usage error. `disable` closes the interval and
434
+ * sets `disabled` — the provider setting and the vectors stay. Stderr `
435
+ * semantic enabled since <ts> (provider <p>)` / ` semantic already enabled
436
+ * since <ts> (provider <p>)` / ` semantic disabled (embedded <from>..<to>[,
437
+ * …])` / ` semantic already disabled`; `--json` `{ state, coverage,
438
+ * provider, changed }`. Under the writer lock (a manifest write).
439
+ */
440
+ function runEmbedLifecycle(cli, verb, args) {
441
+ const { values } = parseArgs({ args, allowPositionals: false, options: { provider: { type: "string" } } });
442
+ if (verb === "disable" && values.provider !== undefined)
443
+ throw new CliUsageError("--provider goes with embed enable");
444
+ const ws = cli.workspace();
445
+ const repo = cli.repo(ws);
446
+ // `--provider`'s settings write may itself flip the row (a provider change
447
+ // re-derives it): the verb reports `changed` when the state moved either way.
448
+ const stateBefore = readCapability(ws.store.db, repo.repoId, "semantic")?.state ?? "disabled";
449
+ const result = withWriterLock(ws.omgbaseDir, () => {
450
+ if (verb === "enable") {
451
+ if (values.provider !== undefined) {
452
+ if (values.provider.trim() === "")
453
+ throw new CliUsageError("--provider needs a <command|url>");
454
+ const current = workspaceSettings(ws.store);
455
+ const embedding = current.embedding && typeof current.embedding === "object" && !Array.isArray(current.embedding) ? current.embedding : {};
456
+ writeWorkspaceSettings(ws.store, { ...current, embedding: { ...embedding, provider: values.provider } });
457
+ }
458
+ try {
459
+ return enableSemantic(ws.store.db, repo.repoId);
460
+ }
461
+ catch (e) {
462
+ if (e instanceof SemanticProviderMissing)
463
+ throw new CliUsageError(e.message);
464
+ throw e;
465
+ }
466
+ }
467
+ return disableSemantic(ws.store.db, repo.repoId);
468
+ });
469
+ const row = result.row;
470
+ const changed = result.changed || row.state !== stateBefore;
471
+ const provider = semanticProviderOf(ws.store.db, repo.repoId);
472
+ if (cli.flags.mode !== "human")
473
+ return emitMachine(cli, { state: row.state, coverage: row.coverage, provider, changed });
474
+ const { io, style } = cli;
475
+ if (verb === "enable") {
476
+ const open = row.coverage.length > 0 ? row.coverage[row.coverage.length - 1] : null;
477
+ const since = open && open.to === null ? open.from : null;
478
+ io.err(style.dim(` semantic ${changed ? "enabled" : "already enabled"}${since ? ` since ${since}` : ""}${provider ? ` (provider ${provider})` : ""}`));
479
+ }
480
+ else {
481
+ io.err(style.dim(changed || row.coverage.length > 0 ? ` semantic ${changed ? "disabled" : "already disabled"} (embedded ${row.coverage.map(semanticInterval).join(", ")})` : " semantic already disabled"));
482
+ }
483
+ return EXIT_OK;
484
+ }
330
485
  // helpers ---------------------------------------------------------------------
331
486
  function getPath(obj, path) {
332
487
  return path.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { findDoc, docDiffUnified, diffBlocks } from "@omgbase/core";
2
+ import { findDoc, requireHistoryReader, historyReadRefusal, RevisionNotFound } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { remoteCall, docLocator } from "./_remote.js";
5
5
  // `omg diff <doc>` (11 §5.5) — `docs_diff_unified`: the unified diff, `--json` the
@@ -41,11 +41,20 @@ async function runDiff(cli, args) {
41
41
  const info = ref.startsWith("d_") ? findDoc(ws.store, { docId: ref }) : findDoc(ws.store, { repoId: repo.repoId, path: ref });
42
42
  if (!info)
43
43
  throw new EngineErrorLike("doc_missing", `no document ${ref}`);
44
- result = docDiffUnified(ws.store, info.docId, {
45
- ...(values.from ? { fromRev: values.from } : {}),
46
- ...(values.to ? { toRev: values.to } : {}),
47
- label: ref,
48
- });
44
+ // Through the recorder seam (Phase 4a): `capability_unavailable { history, unavailable | disabled }` when the build has no module or nothing was ever captured;
45
+ // a revision the recorder does not hold answers by the spec/surface §2.1 decision table (Phase 4b).
46
+ try {
47
+ result = requireHistoryReader(ws.store.db).docDiffUnified(info.docId, {
48
+ ...(values.from ? { fromRev: values.from } : {}),
49
+ ...(values.to ? { toRev: values.to } : {}),
50
+ label: ref,
51
+ });
52
+ }
53
+ catch (e) {
54
+ if (e instanceof RevisionNotFound)
55
+ historyReadRefusal(ws.store.db, repo.repoId, e.rev, { docId: info.docId });
56
+ throw e;
57
+ }
49
58
  }
50
59
  const { style, io } = cli;
51
60
  if (values.blocks) {
@@ -54,7 +63,7 @@ async function runDiff(cli, args) {
54
63
  if (cli.flags.server)
55
64
  entries = await remoteCall(cli, "docs_diff", { ...docLocator(ref), from_rev: result.from, to_rev: result.to });
56
65
  else
57
- entries = diffBlocks(cli.workspace().store, result._id, result.from, result.to);
66
+ entries = requireHistoryReader(cli.workspace().store.db).diffBlocks(result._id, result.from, result.to);
58
67
  if (cli.flags.mode !== "human")
59
68
  return emitMachine(cli, entries, { items: entries, ids: entries.map((e) => e._id) });
60
69
  if (entries.length === 0) {
@@ -13,7 +13,7 @@ import { runSugar } from "./_mutate.js";
13
13
  // The human structural-edit loop.
14
14
  function rawHashOf(ws, blockId) {
15
15
  const row = ws.store.db
16
- .prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL")
16
+ .prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_at IS NULL")
17
17
  .get(blockId);
18
18
  return row?.h ?? null;
19
19
  }
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { resolveRef, historyNode } from "@omgbase/core";
2
+ import { resolveRef, requireHistoryReader } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { remoteCall } from "./_remote.js";
5
5
  // `omg hist <node>` (11 §5.5) — blocks_history: a block's biography.
@@ -32,7 +32,8 @@ async function runHist(cli, args) {
32
32
  const resolved = resolveRef(ws.store, repo.repoId, ref);
33
33
  if (!resolved || resolved.kind !== "block")
34
34
  throw new EngineErrorLike("block_missing", `hist needs a block id; got ${ref}`);
35
- changes = historyNode(ws.store, resolved.blockId, values.n ? { limit: Number(values.n) } : {});
35
+ // Through the recorder seam (Phase 4a): `capability_unavailable { history, unavailable | disabled }` when the build has no module or nothing was ever captured.
36
+ changes = requireHistoryReader(ws.store.db).blocksHistory(resolved.blockId, values.n ? { limit: Number(values.n) } : {});
36
37
  }
37
38
  // A change carries no id of its own, so `--ids` is the `--json` document.
38
39
  if (cli.flags.mode !== "human")
@@ -1,21 +1,30 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
- import { evaluateRetention, retentionPolicy, exportHistory, attachArchive, detachArchive, listArchives, humanBytes, withWriterLock, RetentionPolicyError, NotASegment, StaleSegment, ARCHIVE_DIR, } from "@omgbase/core";
4
+ import { evaluateRetention, retentionPolicy, exportHistory, attachArchive, detachArchive, listArchives, humanBytes, withWriterLock, enableHistory, disableHistory, purgeHistory, openCoverageFrom, RetentionPolicyError, NotASegment, StaleSegment, ARCHIVE_DIR, } from "@omgbase/core";
5
5
  import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
6
6
  import { columns } from "../render.js";
7
- // `omg history <plan|export|attach|detach|list>` (spec/cli §6 `history`, cli
8
- // 4.6): the operator surface of history retention (spec/store §7 "Retention",
9
- // 15.0). `plan` evaluates the policy and prints what would move; `export`
10
- // writes one archive segment (and with `--prune` squashes the store); `attach`
11
- // / `detach` / `list` manage the segment registry. No MCP tool: the catalog's
12
- // only trace is the `revisions.archived` attribute and `revision_archived`.
13
- const SUBCOMMANDS = ["plan", "export", "attach", "detach", "list"];
7
+ // `omg history <enable|disable|purge|plan|export|attach|detach|list>` (spec/cli
8
+ // §6 `history`, cli 4.6; `enable|disable` cli 5.1 — store 17.1 Phase 4a;
9
+ // `purge` cli 5.2 — store 17.2 Phase 4b): the operator surface of history
10
+ // CAPTURE (spec/store §5.9: `enable` turns the repository's capture on with a
11
+ // baseline commit and opens a coverage interval, `disable` closes it — the rows
12
+ // stay, reads of what was captured keep working; §7.6: `purge` removes the rows)
13
+ // and of history RETENTION (spec/store §7 "Retention", 15.0: `plan` evaluates
14
+ // the policy and prints what would move; `export` writes one archive segment
15
+ // and with `--prune` squashes the store; `attach` / `detach` / `list` manage
16
+ // the segment registry). No MCP tool: the catalog's traces are
17
+ // `version.capabilities.history`, the `revisions.archived` attribute,
18
+ // `capability_unavailable` and `revision_archived`.
19
+ const SUBCOMMANDS = ["enable", "disable", "purge", "plan", "export", "attach", "detach", "list"];
14
20
  function renderCard(cli) {
15
21
  return renderHelp(cli, {
16
22
  name: "history",
17
- summary: "History retention: plan what the policy would archive, export it to a segment, attach/detach/list segments",
23
+ summary: "History capture (enable/disable/purge per repository) and retention: plan what the policy would archive, export it to a segment, attach/detach/list segments",
18
24
  usage: [
25
+ "history enable [--best-effort]",
26
+ "history disable",
27
+ "history purge [--before <ts>]",
19
28
  "history plan [--before <ts>] [--policy <json|@file>]",
20
29
  "history export [--prune] [--dest <dir>] [--before <ts>] [--policy <json|@file>] [--dry-run]",
21
30
  "history attach <file|dir>",
@@ -23,12 +32,16 @@ function renderCard(cli) {
23
32
  "history list",
24
33
  ],
25
34
  options: [
35
+ ["enable", "turn capture on for the repository: one baseline commit (origin import) records every live document's current head, a coverage interval opens, every later write is captured; idempotent"],
36
+ ["disable", "turn capture off: the coverage interval closes, later writes record no history (the feed still does); what was captured stays readable; idempotent"],
37
+ ["--best-effort", "(enable) do not require capture: a writer without the history module may still write (default: such a writer is refused — writer_requires)"],
38
+ ["purge", "delete the repository's captured history — every commit, or with --before those before an instant (a live head's revision stays while capture is on); the recorder's id holds are released; the history tables are dropped when no repository is enabled and nothing is left"],
26
39
  ["plan", "per document: revisions kept / to archive and the bytes only they reach; the policy in effect"],
27
40
  ["export", "write ONE new segment file (default dest .omgbase/archives/) holding the archived revisions' bodies; --prune then squashes the store and records the segment"],
28
41
  ["attach <path>", "record a segment file (or every segment of a directory that belongs to this repo) so reads at its revisions work again"],
29
42
  ["detach <ref>", "forget a segment by id or path (its revisions stay listed; reading one is revision_archived)"],
30
43
  ["list", "the repo's segments: commit range, revisions, bytes, state (attached, missing, pending, detached, stale)"],
31
- ["--before <ts>", "only revisions committed before this RFC 3339 instant are candidates"],
44
+ ["--before <ts>", "(plan/export/purge) only revisions committed before this RFC 3339 instant are candidates"],
32
45
  ["--policy <p>", "a JSON object (or @file) merged over the effective history.retention settings for this run"],
33
46
  ["--prune", "(export) after the segment is durable: NULL the archived bodies, drop their change rows, sweep, record"],
34
47
  ["--dest <dir>", "(export) where the segment file goes"],
@@ -213,6 +226,81 @@ function runList(cli, args) {
213
226
  io.out(` ${line}`);
214
227
  return EXIT_OK;
215
228
  }
229
+ /** A coverage interval's `from..to` as the human lines print it. */
230
+ function interval(i) {
231
+ return `${i.from}..${i.to ?? "now"}`;
232
+ }
233
+ /**
234
+ * The `enable`/`disable` rendering: `--json` the manifest row's capture state
235
+ * `{ state, coverage, writer_requires, changed, baseline? }` (`baseline` the
236
+ * commit and the revisions it wrote, on an `enable` that changed something);
237
+ * human one stderr line — ` history enabled since <ts>` / ` history already
238
+ * enabled since <ts>` / ` history disabled (captured <from>..<to>[, …])` /
239
+ * ` history already disabled`.
240
+ */
241
+ function renderLifecycle(cli, verb, r) {
242
+ const row = r.row;
243
+ if (cli.flags.mode !== "human") {
244
+ return emitMachine(cli, { state: row.state, coverage: row.coverage, writer_requires: row.writer_requires, changed: r.changed, ...(r.baseline ? { baseline: r.baseline } : {}) });
245
+ }
246
+ const { io, style } = cli;
247
+ if (verb === "enable") {
248
+ const since = openCoverageFrom(row);
249
+ io.err(style.dim(` history ${r.changed ? "enabled" : "already enabled"}${since ? ` since ${since}` : ""}${r.baseline ? ` (baseline ${r.baseline.commit}: ${plural(r.baseline.revisions, "revision")})` : ""}`));
250
+ }
251
+ else {
252
+ io.err(style.dim(r.changed || row.coverage.length > 0 ? ` history ${r.changed ? "disabled" : "already disabled"} (captured ${row.coverage.map(interval).join(", ")})` : " history already disabled"));
253
+ }
254
+ return EXIT_OK;
255
+ }
256
+ /**
257
+ * `history purge [--before <ts>]` (spec/store §7.6, Phase 4b): the receipt
258
+ * `{ purged: { <table>: n }, claims_released, coverage, tables_dropped }` under
259
+ * `--json`; human one stderr line — ` purged <c> commit(s), <r> revision(s),
260
+ * <d> disposition(s), <b> block change(s), <e> edge transition(s), <s>
261
+ * segment(s); swept <x> blob(s), <y> tree node(s); released <n> id claim(s)` —
262
+ * then ` history tables dropped` when the bundle left, or ` nothing to
263
+ * purge` when every count is zero. Coverage is untouched (it records when
264
+ * capture was on; a purged token reads `pruned`).
265
+ */
266
+ function renderPurge(cli, r) {
267
+ if (cli.flags.mode !== "human")
268
+ return emitMachine(cli, { purged: r.purged, claims_released: r.claims_released, coverage: r.coverage, tables_dropped: r.tables_dropped });
269
+ const { io, style } = cli;
270
+ const p = r.purged;
271
+ const total = Object.values(p).reduce((n, v) => n + v, 0) + r.claims_released;
272
+ if (total === 0 && !r.tables_dropped) {
273
+ io.err(style.dim(" nothing to purge"));
274
+ return EXIT_OK;
275
+ }
276
+ io.err(style.dim(` purged ${plural(p.commits, "commit")}, ${plural(p.revisions, "revision")}, ${plural(p.dispositions, "disposition")}, ${plural(p.block_changes, "block change")}, ${plural(p.edge_transitions, "edge transition")}, ${plural(p.archives, "segment")}; swept ${plural(p.blobs, "blob")}, ${plural(p.tree_nodes, "tree node")}; released ${plural(r.claims_released, "id claim")}`));
277
+ if (r.tables_dropped)
278
+ io.err(style.dim(" history tables dropped"));
279
+ return EXIT_OK;
280
+ }
281
+ function runPurge(cli, args) {
282
+ const { values } = parseArgs({ args, allowPositionals: false, options: { before: { type: "string" } } });
283
+ const ws = cli.workspace();
284
+ const repo = cli.repo(ws);
285
+ const before = parseBefore(values.before);
286
+ const result = withWriterLock(ws.omgbaseDir, () => purgeHistory(ws.store.db, repo.repoId, { ...(before !== undefined ? { before } : {}) }));
287
+ return renderPurge(cli, result);
288
+ }
289
+ function runEnable(cli, args) {
290
+ const { values } = parseArgs({ args, allowPositionals: false, options: { "best-effort": { type: "boolean" } } });
291
+ const ws = cli.workspace();
292
+ const repo = cli.repo(ws);
293
+ // A write (the baseline commit): under the workspace writer lock like every other.
294
+ const result = withWriterLock(ws.omgbaseDir, () => enableHistory(ws.store.db, repo.repoId, { writerRequires: !values["best-effort"] }));
295
+ return renderLifecycle(cli, "enable", result);
296
+ }
297
+ function runDisable(cli, args) {
298
+ parseArgs({ args, allowPositionals: false, options: {} });
299
+ const ws = cli.workspace();
300
+ const repo = cli.repo(ws);
301
+ const result = withWriterLock(ws.omgbaseDir, () => disableHistory(ws.store.db, repo.repoId));
302
+ return renderLifecycle(cli, "disable", result);
303
+ }
216
304
  function runHistory(cli, args) {
217
305
  const [sub, ...rest] = args;
218
306
  if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help" || rest.includes("--help") || rest.includes("-h"))
@@ -221,6 +309,9 @@ function runHistory(cli, args) {
221
309
  throw new CliUsageError(`unknown history subcommand '${sub}' (${SUBCOMMANDS.join("|")})`, `run '${cli.prog} history --help'`);
222
310
  }
223
311
  switch (sub) {
312
+ case "enable": return runEnable(cli, rest);
313
+ case "disable": return runDisable(cli, rest);
314
+ case "purge": return runPurge(cli, rest);
224
315
  case "plan": return runPlan(cli, rest);
225
316
  case "export": return runExport(cli, rest);
226
317
  case "attach": return runAttach(cli, rest);
@@ -228,5 +319,5 @@ function runHistory(cli, args) {
228
319
  case "list": return runList(cli, rest);
229
320
  }
230
321
  }
231
- export const cmdHistory = { name: "history", summary: "Retention: plan/export archive segments, attach/detach/list", run: (cli, a) => runHistory(cli, a) };
322
+ export const cmdHistory = { name: "history", summary: "Capture: enable/disable/purge per repository; retention: plan/export archive segments, attach/detach/list", run: (cli, a) => runHistory(cli, a) };
232
323
  //# sourceMappingURL=history.js.map
@@ -4,16 +4,24 @@ import { CliUsageError, truncationFooter, EXIT_OK, emitMachine, renderHelp } fro
4
4
  import { remoteCall } from "./_remote.js";
5
5
  // `omg log` (11 §5.5) — changes_since, one digest summary per line. Relative
6
6
  // --since (24h/7d) is resolved to a literal ISO timestamp client-side, then to
7
- // the commit seq at/just-before that time (the query language stays clock-free).
7
+ // the feed seq at/just-before that time (the query language stays clock-free).
8
+ // A cursor is `(epoch, seq)` since store 17.0 (D7): `--cursor <n>` continues
9
+ // from a seq of the current epoch unless `--epoch <ep>` names the one it came
10
+ // from; a cursor the feed no longer serves is `expired_cursor` (exit 1).
8
11
  function resolveSinceSeq(cli, repoId, since) {
9
12
  if (!since)
10
13
  return 0;
11
14
  const iso = relativeToIso(since);
12
15
  const ws = cli.workspace();
13
16
  const row = ws.store.db
14
- .prepare("SELECT MAX(seq) AS seq FROM commits WHERE repo_id = ? AND ts < ?")
15
- .get(repoId, iso);
16
- return row.seq ?? 0;
17
+ .prepare("SELECT MAX(seq) AS seq FROM feed WHERE repo_id = ? AND epoch = (SELECT epoch FROM repos WHERE repo_id = ?) AND ts < ?")
18
+ .get(repoId, repoId, iso);
19
+ // A `--since` before the oldest retained row means "everything the feed
20
+ // holds" — cursor `oldest − 1` (cursor 0 on a feed pruned to `oldest ≥ 2`
21
+ // is `expired_cursor` since store 17.0, spec/surface §3).
22
+ const oldest = ws.store.db.prepare("SELECT COALESCE(MIN(seq), 0) AS seq FROM feed WHERE repo_id = ? AND epoch = (SELECT epoch FROM repos WHERE repo_id = ?)").get(repoId, repoId).seq;
23
+ const seq = row.seq ?? 0;
24
+ return seq < oldest - 1 ? Math.max(oldest - 1, 0) : seq;
17
25
  }
18
26
  function relativeToIso(since) {
19
27
  const m = /^(\d+)([hdwm])$/.exec(since.trim());
@@ -37,6 +45,7 @@ async function runLog(cli, args) {
37
45
  options: {
38
46
  since: { type: "string" },
39
47
  cursor: { type: "string" },
48
+ epoch: { type: "string" },
40
49
  origin: { type: "string" },
41
50
  n: { type: "string", short: "n" },
42
51
  help: { type: "boolean" },
@@ -46,10 +55,11 @@ async function runLog(cli, args) {
46
55
  return renderHelp(cli, {
47
56
  name: "log",
48
57
  summary: "What changed: one digest per commit, newest first",
49
- usage: "log [--since <24h|7d|ISO>] [--cursor <n>] [--origin api|observed] [-n <N>]",
58
+ usage: "log [--since <24h|7d|ISO>] [--cursor <n> [--epoch <ep>]] [--origin api|observed] [-n <N>]",
50
59
  options: [
51
60
  ["--since <t>", "only commits after a relative age (24h, 7d) or an ISO timestamp"],
52
- ["--cursor <n>", "continue from a commit sequence number"],
61
+ ["--cursor <n>", "continue from a feed sequence number (the `cursor` of a previous page)"],
62
+ ["--epoch <ep>", "the epoch the cursor belongs to (a page's `epoch`; default: the repo's current one)"],
53
63
  ["--origin <o>", "api (agent/CLI writes) | observed (file edits picked up by sync)"],
54
64
  ["-n <N>", "max commits"],
55
65
  ],
@@ -64,6 +74,7 @@ async function runLog(cli, args) {
64
74
  throw new CliUsageError("--since is not supported with --server; use --cursor <seq>");
65
75
  result = await remoteCall(cli, "changes_since", {
66
76
  ...(values.cursor ? { cursor: Number(values.cursor) } : {}),
77
+ ...(values.epoch ? { epoch: values.epoch } : {}),
67
78
  ...(values.n ? { limit: Number(values.n) } : {}),
68
79
  ...(values.origin ? { origin: values.origin } : {}),
69
80
  });
@@ -73,6 +84,8 @@ async function runLog(cli, args) {
73
84
  const repo = cli.repo(ws);
74
85
  const cursor = values.cursor ? Number(values.cursor) : resolveSinceSeq(cli, repo.repoId, values.since);
75
86
  const opts = { cursor };
87
+ if (values.epoch)
88
+ opts.epoch = values.epoch;
76
89
  if (values.n)
77
90
  opts.limit = Number(values.n);
78
91
  if (values.origin)
@@ -113,7 +113,7 @@ async function runMcp(cli, args) {
113
113
  cli.io.err(cli.style.err("[mcp] ✖ EMBEDDER NONFUNCTIONAL — semantic search + auto-embed disabled"));
114
114
  cli.io.err(cli.style.err(` provider: ${d.provider ?? "?"}`));
115
115
  cli.io.err(cli.style.err(` reason: ${d.reason ?? embedderError.message}`));
116
- cli.io.err(cli.style.err(" semantic queries will fail with embedder_failed until this is fixed."));
116
+ cli.io.err(cli.style.err(" semantic queries will fail with capability_unavailable (state unavailable) until this is fixed."));
117
117
  }
118
118
  // Keep embeddings timely: a background drainer embeds blocks touched by a
119
119
  // mutation (or a watcher checkpoint) without blocking the tool's response.
@@ -147,7 +147,7 @@ async function runMcp(cli, args) {
147
147
  omgbaseDir: ws.omgbaseDir,
148
148
  onCheckpoint: (r) => {
149
149
  if (r.ingested.length > 0 || r.deleted.length > 0) {
150
- cli.io.err(cli.style.dim(`[watch] checkpoint: +${r.ingested.length} -${r.deleted.length}`));
150
+ cli.io.err(cli.style.dim(`[watch] batch: +${r.ingested.length} -${r.deleted.length}`));
151
151
  // A file change the watcher ingested may also need (re-)embedding.
152
152
  drainer?.schedule();
153
153
  }
@@ -180,19 +180,21 @@ async function runMcp(cli, args) {
180
180
  cli.io.err(cli.style.dim(`[mcp] auto-embed on mutation enabled`));
181
181
  if (tools !== "full")
182
182
  cli.io.err(cli.style.dim(`[mcp] tools profile: ${tools}`));
183
- // The query surface's `embedQuery`: a live vectorizer when the embedder loaded,
184
- // or — when it's configured-but-broken — a hook that throws `embedder_failed`
185
- // so semantic access fails loudly and specifically (not the misleading
186
- // `provider_unavailable`, which the server emits only when embedQuery is
187
- // absent, i.e. genuinely no provider configured). `const` captures satisfy
188
- // closure narrowing over the `let`-bound state above.
183
+ // The query surface's `embedQuery`: a live vectorizer when the embedder loaded.
184
+ // When it is configured-but-broken the server gets the host's REPORT instead
185
+ // (`semanticUnavailable`): every semantic path then fails loudly with
186
+ // `capability_unavailable { semantic, unavailable }` (retriable) naming the
187
+ // provider and the reason, and `version` reports the state — not the
188
+ // misleading `provider_unavailable`, which the server emits only when no
189
+ // provider is configured at all. `const` captures satisfy closure narrowing
190
+ // over the `let`-bound state above.
189
191
  const loaded = embedding;
190
192
  const failed = embedderError;
191
193
  const embedQuery = loaded
192
194
  ? async (text) => ({ model: loaded.provider.model, vec: await loaded.worker.embedQuery(text) })
193
- : failed
194
- ? (_text) => Promise.reject(failed)
195
- : undefined;
195
+ : undefined;
196
+ const failedReport = failed ? (failed.data ?? {}) : null;
197
+ const semanticUnavailable = failedReport ? { provider: failedReport.provider ?? "?", reason: failedReport.reason ?? failed.message } : undefined;
196
198
  // The workspace dir makes every write tool take the writer lock for its whole
197
199
  // call (spec/sync §7) — `omg` verbs, the Rust `omgbase mcp` and the watcher
198
200
  // all lock the same file, so the server must too.
@@ -204,6 +206,7 @@ async function runMcp(cli, args) {
204
206
  ...(drainer ? { onMutation: () => drainer.schedule() } : {}),
205
207
  ...(embedQuery ? { embedQuery } : {}),
206
208
  ...(loaded ? { embedProvider: loaded.providerName } : {}),
209
+ ...(semanticUnavailable ? { semanticUnavailable } : {}),
207
210
  // The `version` tool's view of this binary (spec/surface §4).
208
211
  host: hostInfo(),
209
212
  tools,
@@ -38,7 +38,7 @@ function parent(ws, repoId, ref) {
38
38
  // `edited` commit (its `_attrs.checked` only rewrites a task's marker).
39
39
  function taskBlock(ws, ref, blockId) {
40
40
  const row = ws.store.db
41
- .prepare("SELECT type FROM blocks WHERE block_id = ? AND deleted_commit IS NULL")
41
+ .prepare("SELECT type FROM blocks WHERE block_id = ? AND deleted_at IS NULL")
42
42
  .get(blockId);
43
43
  if (!row)
44
44
  throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(ref)}`, { data: { _id: ref } });
@@ -364,7 +364,7 @@ async function runAppend(cli, args) {
364
364
  * `block_not_container` (append means into, never a sibling insert). */
365
365
  function appendSugar(store, blockId, markdown) {
366
366
  const row = store.db
367
- .prepare("SELECT b.type AS type, d.format AS format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
367
+ .prepare("SELECT b.type AS type, d.format AS format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_at IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_at IS NULL")
368
368
  .get(blockId);
369
369
  if (!row)
370
370
  throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(blockId)}`, { data: { _id: blockId } });
@@ -129,7 +129,9 @@ async function runOqx(cli, args) {
129
129
  // provider loaded. loadEmbedding is only touched when a phrase is present.
130
130
  const phrases = collectSemanticPhrases(source);
131
131
  if (phrases.length > 0) {
132
- const loaded = await loadEmbedding(ws, repo.repoId);
132
+ // An explicit `semantic()` on a repository switched off by `omg embed
133
+ // disable` is the refusal with the fix, as the MCP `query` answers (Phase 4b).
134
+ const loaded = await loadEmbedding(ws, repo.repoId, { switchedOff: "throw" });
133
135
  if (!loaded) {
134
136
  throw new EngineErrorLike("provider_unavailable", "semantic(...) needs an embedding provider", {
135
137
  hint: "omg config set embedding.provider <command|url>",
@@ -64,7 +64,7 @@ async function runRun(cli, args) {
64
64
  const node = find(roots);
65
65
  if (!node || node.type !== "code_fence")
66
66
  throw new EngineErrorLike("opaque_block", `block ${ref} is not an omg fence`);
67
- fenceRaw = blockRaw(ws.store, node.rawHashHex);
67
+ fenceRaw = blockRaw(ws.store, node.blockId);
68
68
  }
69
69
  else {
70
70
  const info = findDoc(ws.store, { docId: resolved.docId });
@@ -72,7 +72,7 @@ async function runRun(cli, args) {
72
72
  const fence = firstOmgFence(loadDocBlocks(ws.store, resolved.docId));
73
73
  if (!fence)
74
74
  throw new EngineErrorLike("target_missing", `no \`\`\`omg fence in ${ref}`);
75
- fenceRaw = blockRaw(ws.store, fence.rawHashHex);
75
+ fenceRaw = blockRaw(ws.store, fence.blockId);
76
76
  }
77
77
  const source = fenceBody(fenceRaw).trim();
78
78
  if (!source)
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { resolveRef, nodesGet, docsRead, docLinks, historyNode, docPropertiesMerged } from "@omgbase/core";
2
+ import { resolveRef, nodesGet, docsRead, docLinks, historyReader, docPropertiesMerged } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { expandBlockArgs } from "./_mutate.js";
5
5
  function showOne(cli, ref, include) {
@@ -27,7 +27,8 @@ function showOne(cli, ref, include) {
27
27
  const node = nodesGet(ws.store, resolved.docId, resolved.blockId, { resolution: "full" });
28
28
  if (!node)
29
29
  throw new EngineErrorLike("block_missing", `no block ${ref}`);
30
- const history = include.includes("history") ? historyNode(ws.store, resolved.blockId, { limit: 5 }) : undefined;
30
+ // `--include history` on a store with nothing captured (Phase 4a): the card simply carries no history.
31
+ const history = include.includes("history") ? historyReader(ws.store.db)?.blocksHistory(resolved.blockId, { limit: 5 }) ?? [] : undefined;
31
32
  return { payload: { ...node, ...(history ? { history } : {}) }, render: () => renderBlockCard(cli, node, history) };
32
33
  }
33
34
  function runShow(cli, args) {
@@ -19,7 +19,7 @@ function help(cli) {
19
19
  summary: "Where a repo's bytes come from (a filesystem directory today; git/S3/… via adapters later)",
20
20
  usage: "source <add|list|attach|detach|rm> …",
21
21
  options: [
22
- ["add <dir> [--repo <slug>] [--name <n>] [-y]", "point a repo at a directory: creates the repo (named after the dir unless --repo) + runs the initial sync (-y skips the consent prompt)"],
22
+ ["add <dir> [--repo <slug>] [--name <n>] [-y] [--no-history]", "point a repo at a directory: creates the repo (named after the dir unless --repo) + runs the initial sync (-y skips the consent prompt; --no-history creates it with history capture off — omg history enable turns it on later)"],
23
23
  ["list", "list sources and which repos they feed"],
24
24
  ["attach <name> [--repo <slug>]", "attach an existing source to a repo"],
25
25
  ["detach <name> [--repo <slug>]", "detach a source from a repo"],
@@ -82,7 +82,7 @@ async function runAdd(cli, args) {
82
82
  const { values, positionals } = parseArgs({
83
83
  args,
84
84
  allowPositionals: true,
85
- options: { name: { type: "string" }, yes: { type: "boolean", short: "y" }, help: { type: "boolean" } },
85
+ options: { name: { type: "string" }, yes: { type: "boolean", short: "y" }, "no-history": { type: "boolean" }, help: { type: "boolean" } },
86
86
  });
87
87
  if (values.help)
88
88
  return help(cli);
@@ -117,7 +117,9 @@ async function runAdd(cli, args) {
117
117
  // A repo owns identity; register its fs source, then run the initial sync
118
118
  // through the ordinary freshness path (the same reconcile every later sync
119
119
  // uses). ensureRepo(…, null) mints identity only — the source is explicit.
120
- const repoId = ensureRepo(ws.store, slug, null);
120
+ // `--no-history` (store 17.0 Phase 4a, spec/store §3.1): the repository
121
+ // starts with capture off, so the import itself writes no history row.
122
+ const repoId = ensureRepo(ws.store, slug, null, values["no-history"] ? { history: false } : {});
121
123
  ensureFsAdapter(ws.store);
122
124
  const sourceId = createSource(ws.store, { name: sourceName, adapter: FS_ADAPTER, config: { root: abs } });
123
125
  attachSourceToRepo(ws.store, repoId, sourceId);
@@ -1,10 +1,12 @@
1
1
  import { parseArgs } from "node:util";
2
- import { reposStatus, syncStatus, watchLeaseLive, staleEmbedCount, resolveSettings, embeddingSettings } from "@omgbase/core";
2
+ import { reposStatus, syncStatus, watchLeaseLive, resolveSettings, embeddingSettings } from "@omgbase/core";
3
3
  import { EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  // `omg status` (11 §5.2): repos_status + sync_status + watch-lease probe +
5
5
  // embedding queue depth. The "where am I" command — the visual showcase. The
6
6
  // machine shape is the two tools' results, nested as they are on the wire
7
- // (spec/cli §6): `{ ...repos_status, sync: sync_status, watcher, embedQueue }`.
7
+ // (spec/cli §6): `{ ...repos_status, sync: sync_status, watcher, embedQueue }`;
8
+ // the human grid's `commits` figure and `commit#` are the feed's head (store
9
+ // 17.0: `repos_status.feed`, `sync_status.head`).
8
10
  function runStatus(cli, args) {
9
11
  const { values } = parseArgs({ args, allowPositionals: true, options: { help: { type: "boolean" } } });
10
12
  if (values.help) {
@@ -23,7 +25,9 @@ function runStatus(cli, args) {
23
25
  // Embedding queue depth (spec/search §2.3): the embeddable blocks whose
24
26
  // current context has no cached vector — for the configured model when one is
25
27
  // named, else for any. Counted from the cache alone: no provider is spawned.
26
- const queued = staleEmbedCount(ws.store, repo.repoId, embeddingSettings(resolveSettings(ws.store, repo.repoId)).model);
28
+ // The count is the semantic capability's; a store without the module has
29
+ // nothing queued (modular-core Phase 2).
30
+ const queued = ws.store.capabilities.semantic?.staleCount(repo.repoId, embeddingSettings(resolveSettings(ws.store, repo.repoId)).model) ?? 0;
27
31
  const payload = { ...rs, sync: ss, watcher: watcher ? "live" : "none", embedQueue: queued };
28
32
  if (cli.flags.mode !== "human")
29
33
  return emitMachine(cli, payload);
@@ -36,14 +40,14 @@ function runStatus(cli, args) {
36
40
  const left = [
37
41
  [`${style.path(g.doc)} docs`, String(rs.docs)],
38
42
  [`${style.dim(g.block)} blocks`, String(rs.blocks)],
39
- [`${g.diamond} commits`, String(rs.commits)],
43
+ [`${g.diamond} commits`, String(rs.feed.head)],
40
44
  [`${g.arrow} edges`, String(rs.openEdges)],
41
45
  ];
42
46
  const right = [
43
47
  ["watcher", watcher ? render.statusDot("live", "live") : render.statusDot("none", "none")],
44
- ["synced", ss.convergent ? render.statusDot("ok", "converged") : render.statusDot("warn", syncedLabel(ss, rs.unconverged))],
48
+ ["synced", ss.convergent ? render.statusDot("ok", "converged") : render.statusDot("warn", syncedLabel(ss))],
45
49
  ["queue", queued === 0 ? style.dim("empty") : style.warn(`${queued} queued`)],
46
- ["commit#", style.dim(String(ss.lastCommitSeq))],
50
+ ["commit#", style.dim(String(ss.head))],
47
51
  ];
48
52
  for (let i = 0; i < Math.max(left.length, right.length); i++) {
49
53
  const l = left[i];
@@ -54,11 +58,10 @@ function runStatus(cli, args) {
54
58
  }
55
59
  return EXIT_OK;
56
60
  }
57
- // Summarize why the repo isn't convergent: DB-internal lag and/or on-disk drift.
58
- function syncedLabel(ss, unconverged) {
61
+ // Summarize why the repo isn't convergent: on-disk drift (store 17.0, D9: the
62
+ // DB-internal `unconverged` count was identically zero and is gone).
63
+ function syncedLabel(ss) {
59
64
  const parts = [];
60
- if (unconverged > 0)
61
- parts.push(`${unconverged} behind`);
62
65
  if (ss.disk.deleted > 0)
63
66
  parts.push(`${ss.disk.deleted} deleted`);
64
67
  if (ss.disk.changed > 0)
@@ -95,6 +95,13 @@ function runOneShot(cli, ws, repo) {
95
95
  for (const p of result.conflicted)
96
96
  io.out(` ${style.warn(p)}`);
97
97
  }
98
+ // spec/sync §4.3 step 5 (4.4): the legacy documents whose separator bytes
99
+ // were repaired from their files (spec/store §5.11) — no commit, no revision.
100
+ if (result.repaired.length > 0) {
101
+ io.out(` ${style.ok(g.ok)} repaired ${result.repaired.length}`);
102
+ for (const p of result.repaired)
103
+ io.out(` ${style.accent(p)}`);
104
+ }
98
105
  if (!result.changed)
99
106
  io.out(` ${style.dim("already up to date")}`);
100
107
  return EXIT_OK;
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { versionInfo } from "@omgbase/core";
2
+ import { versionInfo, repoEpoch, capabilityStates } from "@omgbase/core";
3
3
  import { EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { hostInfo } from "../build.js";
5
5
  import { remoteCall } from "./_remote.js";
@@ -28,13 +28,27 @@ async function runVersion(cli, args) {
28
28
  // `schema` is the open database's user_version; without a workspace the
29
29
  // command still answers (spec/cli §3.7: version ∈ NO_WORKSPACE_OK).
30
30
  let store = null;
31
+ let epoch = null;
32
+ let capabilities;
31
33
  try {
32
- store = cli.workspace().store;
34
+ const ws = cli.workspace();
35
+ store = ws.store;
36
+ // The default repository's feed epoch (spec/surface §4 `version.epoch`, 6.0) and
37
+ // its capability manifest (6.1, Phase 4a — `coverage_from`/`writer_requires`/`model`
38
+ // are the selected repository's row); the workspace layer's answer when none selects.
39
+ try {
40
+ const repoId = cli.repo(ws).repoId;
41
+ epoch = repoEpoch(store.db, repoId);
42
+ capabilities = capabilityStates(store, repoId);
43
+ }
44
+ catch {
45
+ epoch = null;
46
+ }
33
47
  }
34
48
  catch {
35
49
  store = null;
36
50
  }
37
- info = versionInfo(store, hostInfo());
51
+ info = versionInfo(store, hostInfo(), "full", capabilities, epoch);
38
52
  }
39
53
  cli.capture?.(info);
40
54
  if (cli.flags.mode !== "human")
@@ -61,6 +75,9 @@ async function runVersion(cli, args) {
61
75
  // The catalog profile served (spec/surface §4 "Profiles", 4.3): `full` for
62
76
  // the library itself; over --server, whatever the remote serves.
63
77
  line("profile", info.profile);
78
+ // The capability manifest (modular-core Phase 2) rides in `--json` only:
79
+ // the human rendering is pinned line for line by spec/cli (`read::version`,
80
+ // `invoke::version-verb-no-workspace`) and both binaries render it alike.
64
81
  return EXIT_OK;
65
82
  }
66
83
  export const cmdVersion = {
@@ -1,4 +1,4 @@
1
- import { freshnessSweep, watchLeaseLive, withWriterLock } from "@omgbase/core";
1
+ import { activeAlerts, freshnessSweep, watchLeaseLive, withWriterLock } from "@omgbase/core";
2
2
  import { NO_WORKSPACE_OK, SKIP_FRESHNESS, REMOTE_OK, asksForHelp } from "./context.js";
3
3
  import { CliUsageError, EngineErrorLike, renderError, usageFromParseArgs } from "./output.js";
4
4
  import { resolveCommand, unknownCommandError } from "./commands.js";
@@ -159,7 +159,9 @@ export async function runCommand(cli, command, rest) {
159
159
  }
160
160
  }
161
161
  }
162
- return await resolved.run(cli, args);
162
+ const code = await resolved.run(cli, args);
163
+ printAlerts(cli, resolved.name, help);
164
+ return code;
163
165
  }
164
166
  catch (err) {
165
167
  // A command's own `parseArgs` rejecting an option is a usage error (exit 2),
@@ -167,4 +169,27 @@ export async function runCommand(cli, command, rest) {
167
169
  return renderError(usageFromParseArgs(err, cli.prog, resolved.name), cli.io, cli.style, cli.flags.mode !== "human");
168
170
  }
169
171
  }
172
+ // The alert channel on the CLI (spec/cli §3.1, store 17.0 — spec/store §3.1
173
+ // `alerts`): after a verb that ran against a repository, every ACTIVE alert of
174
+ // that repository is one stderr line, `alert[<severity>] <code>: <message>`,
175
+ // so a stalled materialization or a refused writer is told where the user
176
+ // works. Help, the workspace-less verbs, the servers (`mcp`, `shell` — whose
177
+ // lines each run through here) and `--server` (no local store) print none; a
178
+ // repository that does not resolve prints none either (the verb said so).
179
+ function printAlerts(cli, name, help) {
180
+ if (help || cli.flags.server !== undefined || NO_WORKSPACE_OK.has(name) || NO_ALERTS.has(name))
181
+ return;
182
+ let alerts;
183
+ try {
184
+ const ws = cli.workspace();
185
+ alerts = activeAlerts(ws.store.db, cli.repo(ws).repoId);
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ for (const a of alerts)
191
+ cli.io.err(`alert[${a.severity}] ${a.code}: ${a.message}`);
192
+ }
193
+ /** The verbs whose own run is a server or a session, not a command over the repository. */
194
+ const NO_ALERTS = new Set(["mcp", "shell"]);
170
195
  //# sourceMappingURL=dispatch.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omgbase",
3
- "version": "0.26.3",
3
+ "version": "0.29.0",
4
4
  "description": "Open Markdown Graph Base — CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,9 +45,9 @@
45
45
  "omg": "./dist/src/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@omgbase/core": "^0.29.3",
48
+ "@omgbase/core": "^0.32.0",
49
49
  "@omgbase/fs-adapter": "^0.2.0",
50
- "@omgbase/sync": "^0.6.4",
50
+ "@omgbase/sync": "^0.7.2",
51
51
  "yaml": "^2.9.0"
52
52
  },
53
53
  "devDependencies": {