@omgbase/core 0.19.0 → 0.23.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.
Files changed (55) hide show
  1. package/dist/src/core/ids.d.ts +1 -1
  2. package/dist/src/core/ingest.d.ts +2 -0
  3. package/dist/src/core/ingest.js +13 -0
  4. package/dist/src/core/read/document.d.ts +13 -9
  5. package/dist/src/core/read/document.js +18 -8
  6. package/dist/src/core/store/archive.d.ts +196 -0
  7. package/dist/src/core/store/archive.js +606 -0
  8. package/dist/src/core/store/gc.d.ts +37 -4
  9. package/dist/src/core/store/gc.js +62 -28
  10. package/dist/src/core/store/rebuild.d.ts +2 -0
  11. package/dist/src/core/store/rebuild.js +8 -2
  12. package/dist/src/core/store/retention.d.ts +138 -0
  13. package/dist/src/core/store/retention.js +409 -0
  14. package/dist/src/core/store/schema.d.ts +3 -2
  15. package/dist/src/core/store/schema.js +30 -4
  16. package/dist/src/core/store/size.d.ts +24 -2
  17. package/dist/src/core/store/size.js +33 -3
  18. package/dist/src/core/store/store.d.ts +1 -0
  19. package/dist/src/core/store/store.js +53 -1
  20. package/dist/src/graph/history.d.ts +2 -0
  21. package/dist/src/graph/history.js +8 -4
  22. package/dist/src/index.d.ts +3 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/mcp/arguments.d.ts +10 -2
  25. package/dist/src/mcp/arguments.js +37 -3
  26. package/dist/src/mcp/errors.d.ts +1 -1
  27. package/dist/src/mcp/errors.js +4 -0
  28. package/dist/src/mcp/reference.d.ts +1 -1
  29. package/dist/src/mcp/reference.js +58 -8
  30. package/dist/src/mcp/server.js +51 -24
  31. package/dist/src/mutate/effects.d.ts +23 -5
  32. package/dist/src/mutate/effects.js +111 -22
  33. package/dist/src/mutate/known-ids.d.ts +19 -0
  34. package/dist/src/mutate/known-ids.js +47 -7
  35. package/dist/src/mutate/ops.d.ts +1 -0
  36. package/dist/src/mutate/ops.js +20 -1
  37. package/dist/src/mutate/select.d.ts +0 -11
  38. package/dist/src/mutate/select.js +33 -9
  39. package/dist/src/oqx-js/context.d.ts +9 -2
  40. package/dist/src/oqx-js/context.js +92 -30
  41. package/dist/src/oqx-js/dates.d.ts +105 -0
  42. package/dist/src/oqx-js/dates.js +427 -0
  43. package/dist/src/oqx-js/names.d.ts +40 -0
  44. package/dist/src/oqx-js/names.js +358 -0
  45. package/dist/src/oqx-js/planner.d.ts +3 -3
  46. package/dist/src/oqx-js/planner.js +30 -11
  47. package/dist/src/oqx-js/root-row.d.ts +15 -4
  48. package/dist/src/oqx-js/root-row.js +18 -10
  49. package/dist/src/oqx-js/run.d.ts +19 -1
  50. package/dist/src/oqx-js/run.js +97 -13
  51. package/dist/src/oqx-js/sql/translate.js +1 -1
  52. package/dist/src/spec-versions.d.ts +5 -5
  53. package/dist/src/spec-versions.js +4 -4
  54. package/dist/src/sync/observe.js +9 -3
  55. package/package.json +2 -2
@@ -19,10 +19,10 @@ correlates to the current row: \`from docs where nodes exists { where kind ==
19
19
  "md:task" }\` returns only the docs that themselves contain a matching node.
20
20
 
21
21
  [select <items>] from <target> [where <pred>] [frontier <pred>] [follow <dest> [{ … }], …]
22
- [order by <expr> [asc|desc], …] [limit N] [offset N]
22
+ [group by <key>, …] [having <pred>] [order by <expr> [asc|desc], …] [limit N] [offset N]
23
23
  <target> count|exists|none|first|single { <block> } (scalar/one-row form: a bare target at the root scope)
24
24
 
25
- CLAUSE ORDER IS FIXED: select, from, where, frontier, follow, order by, limit, offset —
25
+ CLAUSE ORDER IS FIXED: select, from, where, frontier, follow, group by, having, order by, limit, offset —
26
26
  each at most once; an out-of-order clause is a parse error naming the order.
27
27
  Only \`select\` may drop its keyword, and only as the first clause
28
28
  (\`path, era from docs where …\`). A top-level predicate needs \`where\`, because
@@ -109,12 +109,19 @@ read a bare non-colliding key performs), \`frontmatter.<k>\`, \`inline.<k>\`. So
109
109
  everywhere: a hit's \`id\` is docs_read's \`id\` and the \`id\` argument of blocks_read.
110
110
 
111
111
  Reserved bare names per kind (engine attributes; handles; self; relations):
112
- docs id path raw hash updated_at title tags format | data frontmatter inline | doc | blocks nodes out in out_links in_links
112
+ docs id path raw hash updated_at title tags format | data frontmatter inline | doc | blocks nodes out_links in_links revisions
113
113
  blocks id path doc_id raw hash updated_at type text attrs depth ordinal | block doc | children nodes out_links section
114
114
  nodes id path doc_id block_id kind name value attrs | section doc block | blocks subsections children
115
115
  links id path doc_id block_id via_id dst_id dst_path uri field dst_kind predicate provenance anchor | doc
116
+ revisions id path rev seq doc_id commit ts origin actor reason hash is_current archived | doc
116
117
  A key of yours with any of these names is \`data.<k>\` (or frontmatter.<k> /
117
118
  inline.<k> by source; attrs.<k> on a block or node).
119
+ UNKNOWN NAMES: a name that is none of the above AND that no object of the
120
+ kind in this repository carries is reported before any row is read — on
121
+ links as a filter_invalid error naming the fields; on docs/blocks/nodes the
122
+ result gains \`hints\` (one line per name, nearest match when there is one)
123
+ and the rows still come back; in a plan/apply/execute selector it is refused.
124
+ A property some objects carry is known (partial presence is normal).
118
125
 
119
126
  docs:
120
127
  - bare identifier = a document PROPERTY (frontmatter + inline \`key:: value\`,
@@ -136,7 +143,8 @@ docs:
136
143
  links as rows; the documents at their other end are
137
144
  out_links collect distinct { from dst } and
138
145
  in_links collect distinct { from doc } — there is no
139
- out / in relation).
146
+ out / in relation), revisions (the document's stored
147
+ revisions, oldest first — see revisions below).
140
148
 
141
149
  blocks:
142
150
  - bare fields = doc_id (the owning doc), type, text (normalized), ordinal
@@ -181,20 +189,39 @@ links: (the authored link graph as rows — one per open link: the relation a l
181
189
  open edges only (history is blocks_history/changes_since).
182
190
  - text()/semantic() are N/A on links.
183
191
 
192
+ revisions: (history as rows — one per stored revision of every document of the
193
+ repository, deleted documents included: what docs_history lists, queryable)
194
+ - bare fields = rev (= id), seq (1, 2, … per document), doc_id, commit,
195
+ ts (the commit's time, sorts chronologically), origin
196
+ ("observed"/"api"/"import"/"projection"), actor, reason,
197
+ hash (the rendered file's sha256 — docs_history's hash),
198
+ is_current (true for the document's current revision),
199
+ archived (true when the bodies moved to an archive segment:
200
+ the row stays, docs_read { at } reads through the segment).
201
+ - attributes = id (the revision id, r_…), path (the path the revision
202
+ was committed under — a move re-paths only the current
203
+ revision, so earlier ones keep the old path). doc.<key>
204
+ reaches the document's CURRENT row; absent for a deleted
205
+ document, so where doc == null is the revisions of deleted documents.
206
+ - on docs = revisions is a relation (oldest first): a document with
207
+ more than one version is docs { revisions count { } > 1 }.
208
+ - text()/semantic() are N/A on revisions. The body as of a revision is
209
+ docs_read { path, at: "<rev>" } (docs_read_at is the same call).
210
+
184
211
  ## Scoping (ADR-015; the root row since surface 2.0): bare names are LOCAL
185
212
 
186
213
  name the CURRENT row only — never falls through to an enclosing row
187
214
  or the repository; an absent field is simply absent.
188
215
  ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);
189
216
  ^^name two scopes out. \`^path\` = the enclosing row's path.
190
- ^docs ^nodes ^blocks ^links
217
+ ^docs ^nodes ^blocks ^links ^revisions
191
218
  THE ROOT ROW (surface 2.0, oqx 0.18): the repository is the root
192
219
  scope's row, one scope out from a top-level row, so ^docs there is
193
220
  the whole documents scan (uncorrelated until you add a ^ predicate);
194
221
  one caret per enclosing block (^^docs from depth two), or the
195
222
  absolute 0^docs from ANY depth. ^id / 0^id = the repository id.
196
223
  ^$it = the root object: ^$it.docs ≡ ^docs, entries(^$it) names the
197
- four collections. At the root scope itself a bare target is the scan
224
+ five collections. At the root scope itself a bare target is the scan
198
225
  (docs count { … }, from docs, entries(docs)).
199
226
  ERRORS: a bare \`docs\` INSIDE a block reads a property of the current
200
227
  row, which has none — refused ("did you mean ^docs"); \`^docs\` AT THE
@@ -231,10 +258,29 @@ links: (the authored link graph as rows — one per open link: the relation a l
231
258
  strings x.contains("s") x.startsWith("p") x.endsWith("s") x.matches("^re$")
232
259
  (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE —
233
260
  fold with .lower(): title.lower().contains("aurora").
261
+ Infix regex: x ~ "re" is x.matches("re"); x ~* "re" is the
262
+ case-insensitive match; x !~ "re" and x !~* "re" negate them.
263
+ Unanchored unless the pattern anchors; an absent x is false
264
+ under ~ and TRUE under !~ (not SQL's NULL).
234
265
  functions has(f) size(x) list(x) entries(x) range(s)
266
+ dates a date is an ISO string, a duration a NUMBER OF SECONDS; arithmetic
267
+ is METHODS, never + or - (+ concatenates strings).
268
+ date("today"|"now"|"yesterday"|"tomorrow"|"sow"|"eow"|"som"|"eom"|
269
+ "soy"|"eoy") — the host clock, UTC; date("2026-10-15") canonical ISO
270
+ (absent if unparseable). dur("1h30m") dur("2 days, 3 hours")
271
+ dur("P1DT2H") dur(90) → seconds (year = 365 d, month = 30 d).
272
+ d.plus(dur("30 days")) d.minus(86400) d.plus("1 month") (calendar:
273
+ Jan 31 → Feb 28) a.until(b) → signed seconds d.year() d.month()
274
+ d.day() d.weekday() (1 = Mon) d.hour() d.minute() d.second()
275
+ d.date() d.format("EEE dd MMM yyyy") n.dur_text() → "1h 30m"
276
+ n.dur_iso() → "PT1H30M". Total: a non-date is absent, so
277
+ \`where date(due) < date("today").minus(dur("30 days"))\` just
278
+ filters. A datetime is never < its own date: compare
279
+ updated_at.date() == date("today"), or < date("tomorrow").
235
280
  literals "str" 'str' 123 1.5 true false null
236
- determinism no now()/clock/random. Lists/structs are not literals; ternary is
237
- not supported. Errors surface as filter_invalid.
281
+ determinism no random; the only clock is date("today"/"now"/…), read once per
282
+ query. Lists/structs are not literals; ternary is not supported.
283
+ Errors surface as filter_invalid.
238
284
  scalars vs lists a list-authored key never == a scalar (tags == "x" is false
239
285
  when tags is a list) — use \`"x" in tags\` / \`in list(tags)\`.
240
286
 
@@ -422,6 +468,10 @@ text()/where prune the candidate set; only order by reweights.
422
468
  from blocks order by semantic("identity preservation across edits") desc limit 10
423
469
  select uri from links where dst_kind == "external"
424
470
  docs count { where layer == "canon" }
471
+ select seq, origin, ts, hash from revisions where path == "/processes/calcination.md" order by seq desc
472
+ from docs where revisions count { where origin == "api" } > 0
473
+ select p: path, d: doc.path from revisions where doc == null
474
+ select layer, n: count(), titles: collect(title), open: rows count { where nodes exists { where kind == "md:task" and not checked } } from docs group by layer having count() > 1 order by count() desc
425
475
 
426
476
  Common mistake: \`$path\`/\`$id\` (the 2.x spelling) — omgbase has no $ names; write
427
477
  \`path\`/\`id\`. Another: \`where tags == "x"\` on a list-valued key is false — write
@@ -12,7 +12,7 @@ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
12
12
  import { graphNeighborhood } from "./graph.js";
13
13
  import { textSearch } from "../search/text.js";
14
14
  import { EngineError, errorBody } from "./errors.js";
15
- import { argumentError, validateArguments } from "./arguments.js";
15
+ import { argumentError, stringList, validateArguments } from "./arguments.js";
16
16
  import { linksRetarget, linksRepair } from "../mutate/macros.js";
17
17
  import { canonicalDocPath } from "../mutate/docs.js";
18
18
  import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
@@ -84,7 +84,7 @@ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
84
84
  function selectorArgs(args) {
85
85
  return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
86
86
  }
87
- const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
87
+ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target: document, field: \"frontmatter.<key>\", expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }. Target: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"attrs.checked\"; a document's \"frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
88
88
  // An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
89
89
  // each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
90
90
  // a rung is a line beginning `{` with the call's arguments as JSON, three
@@ -99,6 +99,10 @@ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "uns
99
99
  // thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
100
100
  // `mcp/examples.test.ts`).
101
101
  const ladder = (...lines) => lines.join("\n");
102
+ // The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
103
+ // the spec minter gives `/processes/calcination.md`'s ingest revision over the
104
+ // alchemy corpus (`examples.json` runs the rung before any write).
105
+ const REVISION_RUNG = "{\"path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: from revisions where path == \"/processes/calcination.md\")";
102
106
  function ok(payload) {
103
107
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
104
108
  }
@@ -236,6 +240,15 @@ export function buildServer(ctx) {
236
240
  }
237
241
  return (byId ?? byPath).docId;
238
242
  }
243
+ // `docs_read { at }` and its sugar `docs_read_at` (spec/surface §2, 4.7): the
244
+ // document AS OF a revision, `spec/store` §6.2 reconstruct; a revision that is
245
+ // not the document's is `target_missing`.
246
+ function docsReadAt(docId, rev) {
247
+ const res = readDocumentAtRevision(store, docId, rev);
248
+ if (!res)
249
+ throw new EngineError("target_missing", `no revision ${JSON.stringify(rev)} for document ${docId}`, { data: { doc: docId, rev } });
250
+ return res;
251
+ }
239
252
  // The owning document of a block id (`blocks_read`): the block must be live.
240
253
  function docOfBlock(blockId) {
241
254
  const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
@@ -415,16 +428,26 @@ export function buildServer(ctx) {
415
428
  }
416
429
  });
417
430
  tool("docs_read", {
418
- description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
431
+ description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
419
432
  inputSchema: {
420
433
  ...DOC_ARGS,
421
434
  include_ids: z.boolean().optional(),
435
+ at: z.string().optional(),
422
436
  ...REPO_ARG,
423
437
  },
424
438
  }, async (args) => {
425
439
  try {
426
440
  const { repoId } = repoScope(args.repo);
427
- const docId = locateDoc(repoId, "docs_read", registry.get("docs_read").shape, args);
441
+ const shape = registry.get("docs_read").shape;
442
+ const docId = locateDoc(repoId, "docs_read", shape, args);
443
+ if (args.at !== undefined) {
444
+ // `at` (4.7): the document AS OF that revision — `docs_read_at`'s
445
+ // code path, so the two return the same bytes. Block ids at a past
446
+ // revision are a later question: `include_ids` is refused with `at`.
447
+ if (args.include_ids)
448
+ throw argumentError("docs_read", "`include_ids` is not available with `at`", shape);
449
+ return ok(docsReadAt(docId, args.at));
450
+ }
428
451
  const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
429
452
  if (!res)
430
453
  throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
@@ -437,7 +460,7 @@ export function buildServer(ctx) {
437
460
  tool("docs_read_many", {
438
461
  description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`raw` = complete file bytes verbatim, `properties` grouped by source, plus `id`/`path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `id` → block `hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
439
462
  inputSchema: {
440
- docs: z.array(z.string()),
463
+ docs: stringList(),
441
464
  include_ids: z.boolean().optional(),
442
465
  budget_tokens: z.number().int().optional(),
443
466
  ...REPO_ARG,
@@ -502,7 +525,7 @@ export function buildServer(ctx) {
502
525
  tool("blocks_read_many", {
503
526
  description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
504
527
  inputSchema: {
505
- ids: z.array(z.string()),
528
+ ids: stringList(),
506
529
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
507
530
  budget_tokens: z.number().int().optional(),
508
531
  ...REPO_ARG,
@@ -573,7 +596,7 @@ export function buildServer(ctx) {
573
596
  inputSchema: {},
574
597
  }, async () => ok({ syntax: QUERY_SYNTAX }));
575
598
  tool("query", {
576
- description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
599
+ description: ladder("query — rows (id, path, projection) from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where \\\"process\\\" in tags order by path limit 5\"} frontmatter; membership; order, limit", "{\"query\":\"select type, text from blocks where path == \\\"/processes/calcination.md\\\"\"} a document's blocks in order", "{\"query\":\"select block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id", "{\"query\":\"select path, value from nodes where kind == \\\"md:task\\\" and not checked\"} open tasks", "{\"query\":\"select path, block_id from links where dst_path == \\\"/processes/calcination.md\\\"\"} inbound links (path = source)", "{\"query\":\"select path from docs where title ~* \\\"salt\\\"\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
577
600
  inputSchema: {
578
601
  query: z.string(),
579
602
  limit: z.number().int().optional(),
@@ -601,11 +624,11 @@ export function buildServer(ctx) {
601
624
  tool("graph", {
602
625
  description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow out_links collect { from dst }` / `follow in_links collect { from doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections — a bare-name selection such as \"title\" is its own key, `id`/`path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `id`, `doc_id`/`block_id` (the source), `via_id`, `dst_id`, `dst_path`, `uri`, `dst_kind`, `predicate`, `provenance`, `anchor`, `field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ id, path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
603
626
  inputSchema: {
604
- roots: z.array(z.string()).min(1),
627
+ roots: stringList({ min: 1 }),
605
628
  degrees: z.number().int().optional(),
606
629
  direction: z.enum(["in", "out", "both"]).optional(),
607
630
  predicate: z.string().optional(),
608
- select: z.array(z.string()).optional(),
631
+ select: stringList().optional(),
609
632
  max_documents: z.number().int().optional(),
610
633
  ...REPO_ARG,
611
634
  },
@@ -678,14 +701,21 @@ export function buildServer(ctx) {
678
701
  // commits exactly a plan (the plan form, or the digest form `plan_id` +
679
702
  // `effects`); `apply { effects }` above is plan + execute in one call.
680
703
  tool("plan", {
681
- description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target = the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget. Grammar: query_syntax."),
682
- inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
704
+ description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. diffs: true for file bodies.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget; grammar: query_syntax."),
705
+ inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
683
706
  }, async (args) => {
684
707
  try {
685
708
  const { repoId, rootPath } = repoScope(args.repo);
686
709
  if (!rootPath)
687
710
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
688
- return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
711
+ const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
712
+ // (4.5) `preview.diffs` — whole before/after bodies of every touched
713
+ // document — only on request: `diffs: true`. The digest is over the
714
+ // core, never the preview, so `plan_id` is the same either way.
715
+ if (args.diffs === true || plan.preview === undefined)
716
+ return ok(plan);
717
+ const { diffs: _diffs, ...preview } = plan.preview;
718
+ return ok({ ...plan, preview });
689
719
  }
690
720
  catch (e) {
691
721
  return fail(e);
@@ -762,7 +792,7 @@ export function buildServer(ctx) {
762
792
  });
763
793
  tool("blocks_move", {
764
794
  description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
765
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
795
+ inputSchema: { blocks: stringList(), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
766
796
  }, async (args) => {
767
797
  try {
768
798
  const { repoId, rootPath } = repoScope(args.repo);
@@ -783,7 +813,7 @@ export function buildServer(ctx) {
783
813
  });
784
814
  tool("blocks_remove", {
785
815
  description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
786
- inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
816
+ inputSchema: { blocks: stringList(), ...REPO_ARG },
787
817
  }, async (args) => {
788
818
  try {
789
819
  const { repoId, rootPath } = repoScope(args.repo);
@@ -813,7 +843,7 @@ export function buildServer(ctx) {
813
843
  });
814
844
  tool("blocks_merge", {
815
845
  description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
816
- inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
846
+ inputSchema: { blocks: stringList(), separator: z.string().optional(), ...REPO_ARG },
817
847
  }, async (args) => {
818
848
  try {
819
849
  const { repoId, rootPath } = repoScope(args.repo);
@@ -828,7 +858,7 @@ export function buildServer(ctx) {
828
858
  });
829
859
  tool("blocks_check", {
830
860
  description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
831
- inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
861
+ inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
832
862
  }, async (args) => {
833
863
  try {
834
864
  const { repoId, rootPath } = repoScope(args.repo);
@@ -1056,7 +1086,7 @@ export function buildServer(ctx) {
1056
1086
  });
1057
1087
  tool("docs_set_meta", {
1058
1088
  description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
1059
- inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
1089
+ inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
1060
1090
  }, async (args) => {
1061
1091
  try {
1062
1092
  const scope = repoScope(args.repo);
@@ -1127,7 +1157,7 @@ export function buildServer(ctx) {
1127
1157
  }
1128
1158
  });
1129
1159
  tool("blocks_history", {
1130
- description: "A block's biography: the commits that touched the block `id` names, with disposition kind/confidence/reason, newest first (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1160
+ description: "A block's biography: the commits that CHANGED the block `id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1131
1161
  inputSchema: { id: z.string(), limit: z.number().int().optional() },
1132
1162
  }, async (args) => {
1133
1163
  try {
@@ -1172,23 +1202,20 @@ export function buildServer(ctx) {
1172
1202
  }
1173
1203
  });
1174
1204
  tool("docs_read_at", {
1175
- description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. `properties` are CURRENT values (propertiesAreCurrent:true), since property history is not stored. Address the document by `id` or `path` plus a `rev` id.",
1205
+ description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Sugar for `docs_read { id | path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`from revisions where path == … order by seq`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `id` or `path` plus a `rev` id. A revision whose bodies were moved to an archive segment (`archived` in docs_history / the revisions target) reads through the segment when it is attached; otherwise the call fails `revision_archived` with `data: { rev, archive, path? }` — an operator attaches the file with `omg history attach`.",
1176
1206
  inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
1177
1207
  }, async (args) => {
1178
1208
  try {
1179
1209
  const { repoId } = repoScope(args.repo);
1180
1210
  const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
1181
- const res = readDocumentAtRevision(store, docId, args.rev);
1182
- if (!res)
1183
- throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
1184
- return ok(res);
1211
+ return ok(docsReadAt(docId, args.rev));
1185
1212
  }
1186
1213
  catch (e) {
1187
1214
  return fail(e);
1188
1215
  }
1189
1216
  });
1190
1217
  tool("docs_history", {
1191
- description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`id`, `path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`updated_at`/`origin`/`actor`/`hash`/`isCurrent`. Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `id` or `path`; at least one is required. `hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
1218
+ description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`id`, `path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`updated_at`/`origin`/`actor`/`hash`/`isCurrent`/`archived` (true when the revision's bodies live in an archive segment — the row stays, docs_read_at reads through the segment while it is attached). Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `id` or `path`; at least one is required. `hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
1192
1219
  inputSchema: {
1193
1220
  path_glob: z.string().optional(),
1194
1221
  ...DOC_ARGS,
@@ -8,6 +8,7 @@ import { type LinkRepairCount, type RetargetHit } from "./macros.js";
8
8
  import type { At, Expect } from "./ops.js";
9
9
  import type { IdentityPreview } from "./opset.js";
10
10
  import { MutationError } from "./tree.js";
11
+ import { type FixRequest } from "./canonical.js";
11
12
  /** What an effect addresses. `section` is a derived range (ADR-005), never a stored id.
12
13
  * `symbol` (3.2, §11.8 "Sugar"): a bare `"$new.n"` target whose kind — document or block — the
13
14
  * symbol table decides when the effect is resolved; it never reaches a resolved effect. */
@@ -172,8 +173,25 @@ export type ResolvedEffect = {
172
173
  }[];
173
174
  path_glob?: string;
174
175
  };
175
- /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }`. */
176
- export declare function effectInvalid(message: string): MutationError;
176
+ /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
177
+ export declare function effectInvalid(message: string, fix?: string): MutationError;
178
+ /**
179
+ * What the shape check needs to spell a `fix` for the shape errors of §8
180
+ * (3.4): the request as given (the corrected call is that request with the
181
+ * offending effect rewritten — the effect at `effects[i]` AS WRITTEN, before
182
+ * any desugaring) and a live-row lookup for the one fix that needs the store:
183
+ * `parentOf(block id)` → the block's parent id (`null` at the top level) and
184
+ * its document's path (storage form), or `null` when no live block has the id
185
+ * (then there is no fix). The planner's compile (`select.ts`) supplies it; a
186
+ * library call without it gets the same errors with no `fix`.
187
+ */
188
+ export interface ShapeFixContext {
189
+ request: FixRequest;
190
+ parentOf: (blockId: string) => {
191
+ parent: string | null;
192
+ path: string;
193
+ } | null;
194
+ }
177
195
  /** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
178
196
  export declare const EXPRESSION_LEADER = "-> ";
179
197
  /** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
@@ -191,10 +209,10 @@ export declare function isTargetId(s: string): boolean;
191
209
  */
192
210
  export declare function bareTarget(ref: string, here: string): EffectTarget;
193
211
  export declare function validateTarget(v: unknown, here: string, kinds: readonly EffectTarget["kind"][]): EffectTarget;
194
- /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
195
- export declare function validateEffect(v: unknown, i: number): Effect;
212
+ /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
213
+ export declare function validateEffect(v: unknown, i: number, fix?: ShapeFixContext): Effect;
196
214
  /** The whole list; a non-array or an empty list is a shape error too. */
197
- export declare function validateEffects(v: unknown): Effect[];
215
+ export declare function validateEffects(v: unknown, fix?: ShapeFixContext): Effect[];
198
216
  export declare const FRONTMATTER_FIELD: RegExp;
199
217
  /** The focus a conflict names: `{ kind, target, field? }`. */
200
218
  export interface Focus {