@omgbase/core 0.1.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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/cli/eval-matcher.d.ts +13 -0
  3. package/dist/src/cli/eval-matcher.js +36 -0
  4. package/dist/src/core/attach.d.ts +5 -0
  5. package/dist/src/core/attach.js +38 -0
  6. package/dist/src/core/cursor.d.ts +11 -0
  7. package/dist/src/core/cursor.js +35 -0
  8. package/dist/src/core/hash.d.ts +23 -0
  9. package/dist/src/core/hash.js +82 -0
  10. package/dist/src/core/ids.d.ts +6 -0
  11. package/dist/src/core/ids.js +26 -0
  12. package/dist/src/core/index.d.ts +1 -0
  13. package/dist/src/core/index.js +2 -0
  14. package/dist/src/core/ingest.d.ts +43 -0
  15. package/dist/src/core/ingest.js +287 -0
  16. package/dist/src/core/order-key.d.ts +8 -0
  17. package/dist/src/core/order-key.js +72 -0
  18. package/dist/src/core/parse/parse.d.ts +3 -0
  19. package/dist/src/core/parse/parse.js +104 -0
  20. package/dist/src/core/parse/render.d.ts +3 -0
  21. package/dist/src/core/parse/render.js +20 -0
  22. package/dist/src/core/parse/tree.d.ts +8 -0
  23. package/dist/src/core/parse/tree.js +43 -0
  24. package/dist/src/core/parse/types.d.ts +52 -0
  25. package/dist/src/core/parse/types.js +9 -0
  26. package/dist/src/core/read/document.d.ts +107 -0
  27. package/dist/src/core/read/document.js +191 -0
  28. package/dist/src/core/read/nodes.d.ts +47 -0
  29. package/dist/src/core/read/nodes.js +102 -0
  30. package/dist/src/core/read/outline.d.ts +12 -0
  31. package/dist/src/core/read/outline.js +76 -0
  32. package/dist/src/core/read/reader.d.ts +125 -0
  33. package/dist/src/core/read/reader.js +206 -0
  34. package/dist/src/core/read/refs.d.ts +15 -0
  35. package/dist/src/core/read/refs.js +51 -0
  36. package/dist/src/core/store/edges.d.ts +26 -0
  37. package/dist/src/core/store/edges.js +101 -0
  38. package/dist/src/core/store/fts.d.ts +10 -0
  39. package/dist/src/core/store/fts.js +22 -0
  40. package/dist/src/core/store/gc.d.ts +18 -0
  41. package/dist/src/core/store/gc.js +69 -0
  42. package/dist/src/core/store/nodes.d.ts +5 -0
  43. package/dist/src/core/store/nodes.js +57 -0
  44. package/dist/src/core/store/properties.d.ts +38 -0
  45. package/dist/src/core/store/properties.js +177 -0
  46. package/dist/src/core/store/rebuild.d.ts +3 -0
  47. package/dist/src/core/store/rebuild.js +29 -0
  48. package/dist/src/core/store/schema.d.ts +9 -0
  49. package/dist/src/core/store/schema.js +386 -0
  50. package/dist/src/core/store/sections.d.ts +2 -0
  51. package/dist/src/core/store/sections.js +29 -0
  52. package/dist/src/core/store/store.d.ts +24 -0
  53. package/dist/src/core/store/store.js +164 -0
  54. package/dist/src/core/store/writers.d.ts +52 -0
  55. package/dist/src/core/store/writers.js +89 -0
  56. package/dist/src/core/vec.d.ts +10 -0
  57. package/dist/src/core/vec.js +33 -0
  58. package/dist/src/format/adapter.d.ts +97 -0
  59. package/dist/src/format/adapter.js +14 -0
  60. package/dist/src/format/index.d.ts +6 -0
  61. package/dist/src/format/index.js +15 -0
  62. package/dist/src/format/json.d.ts +3 -0
  63. package/dist/src/format/json.js +324 -0
  64. package/dist/src/format/markdown.d.ts +5 -0
  65. package/dist/src/format/markdown.js +217 -0
  66. package/dist/src/format/registry.d.ts +6 -0
  67. package/dist/src/format/registry.js +24 -0
  68. package/dist/src/format/yaml.d.ts +5 -0
  69. package/dist/src/format/yaml.js +413 -0
  70. package/dist/src/graph/extract.d.ts +38 -0
  71. package/dist/src/graph/extract.js +242 -0
  72. package/dist/src/graph/history.d.ts +103 -0
  73. package/dist/src/graph/history.js +210 -0
  74. package/dist/src/graph/inbound-links.d.ts +33 -0
  75. package/dist/src/graph/inbound-links.js +130 -0
  76. package/dist/src/graph/link-destinations.d.ts +22 -0
  77. package/dist/src/graph/link-destinations.js +84 -0
  78. package/dist/src/graph/link-health.d.ts +79 -0
  79. package/dist/src/graph/link-health.js +146 -0
  80. package/dist/src/graph/links.d.ts +23 -0
  81. package/dist/src/graph/links.js +42 -0
  82. package/dist/src/index.d.ts +60 -0
  83. package/dist/src/index.js +70 -0
  84. package/dist/src/mcp/errors.d.ts +17 -0
  85. package/dist/src/mcp/errors.js +18 -0
  86. package/dist/src/mcp/graph.d.ts +63 -0
  87. package/dist/src/mcp/graph.js +212 -0
  88. package/dist/src/mcp/reference.d.ts +1 -0
  89. package/dist/src/mcp/reference.js +227 -0
  90. package/dist/src/mcp/server.d.ts +30 -0
  91. package/dist/src/mcp/server.js +1076 -0
  92. package/dist/src/mcp/stdio.d.ts +13 -0
  93. package/dist/src/mcp/stdio.js +24 -0
  94. package/dist/src/migrate/mrplex.d.ts +27 -0
  95. package/dist/src/migrate/mrplex.js +29 -0
  96. package/dist/src/mutate/apply.d.ts +83 -0
  97. package/dist/src/mutate/apply.js +302 -0
  98. package/dist/src/mutate/doc-store.d.ts +49 -0
  99. package/dist/src/mutate/doc-store.js +71 -0
  100. package/dist/src/mutate/docs.d.ts +60 -0
  101. package/dist/src/mutate/docs.js +232 -0
  102. package/dist/src/mutate/known-ids.d.ts +12 -0
  103. package/dist/src/mutate/known-ids.js +154 -0
  104. package/dist/src/mutate/load.d.ts +3 -0
  105. package/dist/src/mutate/load.js +66 -0
  106. package/dist/src/mutate/lower.d.ts +32 -0
  107. package/dist/src/mutate/lower.js +232 -0
  108. package/dist/src/mutate/macros.d.ts +91 -0
  109. package/dist/src/mutate/macros.js +230 -0
  110. package/dist/src/mutate/ops.d.ts +40 -0
  111. package/dist/src/mutate/ops.js +513 -0
  112. package/dist/src/mutate/opset.d.ts +92 -0
  113. package/dist/src/mutate/opset.js +79 -0
  114. package/dist/src/mutate/plan-update.d.ts +51 -0
  115. package/dist/src/mutate/plan-update.js +187 -0
  116. package/dist/src/mutate/tree.d.ts +46 -0
  117. package/dist/src/mutate/tree.js +167 -0
  118. package/dist/src/oqx/run.d.ts +1 -0
  119. package/dist/src/oqx/run.js +6 -0
  120. package/dist/src/oqx-js/context.d.ts +23 -0
  121. package/dist/src/oqx-js/context.js +564 -0
  122. package/dist/src/oqx-js/planner.d.ts +10 -0
  123. package/dist/src/oqx-js/planner.js +91 -0
  124. package/dist/src/oqx-js/run.d.ts +41 -0
  125. package/dist/src/oqx-js/run.js +332 -0
  126. package/dist/src/oqx-js/sql/translate.d.ts +27 -0
  127. package/dist/src/oqx-js/sql/translate.js +290 -0
  128. package/dist/src/reconcile/crossdoc.d.ts +34 -0
  129. package/dist/src/reconcile/crossdoc.js +76 -0
  130. package/dist/src/reconcile/eval/generator.d.ts +21 -0
  131. package/dist/src/reconcile/eval/generator.js +129 -0
  132. package/dist/src/reconcile/eval/metrics.d.ts +32 -0
  133. package/dist/src/reconcile/eval/metrics.js +74 -0
  134. package/dist/src/reconcile/flatten.d.ts +12 -0
  135. package/dist/src/reconcile/flatten.js +37 -0
  136. package/dist/src/reconcile/phase5.d.ts +8 -0
  137. package/dist/src/reconcile/phase5.js +167 -0
  138. package/dist/src/reconcile/phase6a.d.ts +2 -0
  139. package/dist/src/reconcile/phase6a.js +159 -0
  140. package/dist/src/reconcile/phases.d.ts +28 -0
  141. package/dist/src/reconcile/phases.js +0 -0
  142. package/dist/src/reconcile/reconcile.d.ts +21 -0
  143. package/dist/src/reconcile/reconcile.js +115 -0
  144. package/dist/src/reconcile/similarity.d.ts +7 -0
  145. package/dist/src/reconcile/similarity.js +41 -0
  146. package/dist/src/reconcile/types.d.ts +46 -0
  147. package/dist/src/reconcile/types.js +19 -0
  148. package/dist/src/search/cel/ast.d.ts +74 -0
  149. package/dist/src/search/cel/ast.js +3 -0
  150. package/dist/src/search/cel/compile.d.ts +60 -0
  151. package/dist/src/search/cel/compile.js +836 -0
  152. package/dist/src/search/cel/lexer.d.ts +9 -0
  153. package/dist/src/search/cel/lexer.js +139 -0
  154. package/dist/src/search/cel/parser.d.ts +7 -0
  155. package/dist/src/search/cel/parser.js +219 -0
  156. package/dist/src/search/drain.d.ts +39 -0
  157. package/dist/src/search/drain.js +97 -0
  158. package/dist/src/search/embeddings.d.ts +131 -0
  159. package/dist/src/search/embeddings.js +263 -0
  160. package/dist/src/search/external.d.ts +13 -0
  161. package/dist/src/search/external.js +177 -0
  162. package/dist/src/search/fts-query.d.ts +1 -0
  163. package/dist/src/search/fts-query.js +64 -0
  164. package/dist/src/search/provider.d.ts +17 -0
  165. package/dist/src/search/provider.js +13 -0
  166. package/dist/src/search/resolve.d.ts +18 -0
  167. package/dist/src/search/resolve.js +23 -0
  168. package/dist/src/search/rrf.d.ts +36 -0
  169. package/dist/src/search/rrf.js +84 -0
  170. package/dist/src/search/tasks.d.ts +12 -0
  171. package/dist/src/search/tasks.js +131 -0
  172. package/dist/src/search/text.d.ts +16 -0
  173. package/dist/src/search/text.js +21 -0
  174. package/dist/src/search/vector.d.ts +18 -0
  175. package/dist/src/search/vector.js +49 -0
  176. package/dist/src/sync/admin.d.ts +59 -0
  177. package/dist/src/sync/admin.js +53 -0
  178. package/dist/src/sync/attach.d.ts +13 -0
  179. package/dist/src/sync/attach.js +29 -0
  180. package/dist/src/sync/checkpoint.d.ts +21 -0
  181. package/dist/src/sync/checkpoint.js +52 -0
  182. package/dist/src/sync/driver.d.ts +25 -0
  183. package/dist/src/sync/driver.js +80 -0
  184. package/dist/src/sync/external-source.d.ts +14 -0
  185. package/dist/src/sync/external-source.js +137 -0
  186. package/dist/src/sync/freshness.d.ts +42 -0
  187. package/dist/src/sync/freshness.js +152 -0
  188. package/dist/src/sync/fs-util.d.ts +12 -0
  189. package/dist/src/sync/fs-util.js +55 -0
  190. package/dist/src/sync/git-heuristics.d.ts +6 -0
  191. package/dist/src/sync/git-heuristics.js +16 -0
  192. package/dist/src/sync/observe.d.ts +63 -0
  193. package/dist/src/sync/observe.js +84 -0
  194. package/dist/src/sync/plugin.d.ts +54 -0
  195. package/dist/src/sync/plugin.js +12 -0
  196. package/dist/src/sync/reconciling-ingest.d.ts +12 -0
  197. package/dist/src/sync/reconciling-ingest.js +209 -0
  198. package/dist/src/sync/recovery.d.ts +6 -0
  199. package/dist/src/sync/recovery.js +34 -0
  200. package/dist/src/sync/settings.d.ts +18 -0
  201. package/dist/src/sync/settings.js +58 -0
  202. package/dist/src/sync/sources.d.ts +37 -0
  203. package/dist/src/sync/sources.js +75 -0
  204. package/dist/src/sync/tombstone.d.ts +9 -0
  205. package/dist/src/sync/tombstone.js +55 -0
  206. package/dist/src/sync/watch-lease.d.ts +14 -0
  207. package/dist/src/sync/watch-lease.js +91 -0
  208. package/dist/src/sync/watcher.d.ts +19 -0
  209. package/dist/src/sync/watcher.js +34 -0
  210. package/dist/src/sync/workspace.d.ts +34 -0
  211. package/dist/src/sync/workspace.js +120 -0
  212. package/dist/src/sync/writer-lock.d.ts +16 -0
  213. package/dist/src/sync/writer-lock.js +121 -0
  214. package/package.json +80 -0
@@ -0,0 +1,513 @@
1
+ import { mintId } from "../core/ids.js";
2
+ import { parseTree } from "../core/parse/tree.js";
3
+ import { adapterForFormat } from "../format/index.js";
4
+ import { MutationError, locate, rawHashHex, parentChildrenHash, markContainerDirty, ownerOf } from "./tree.js";
5
+ function defaultTrivia(format) {
6
+ if (format === "json")
7
+ return ",\n";
8
+ if (format === "yaml")
9
+ return "\n";
10
+ return "\n\n";
11
+ }
12
+ // Parse op-supplied content into MutBlocks (ids minted). Uses the format's
13
+ // adapter when available; falls back to markdown. The parsed blocks are CLEAN:
14
+ // a freshly parsed block's raw is the byte-exact source for its whole subtree,
15
+ // so renderDoc must emit it verbatim. Marking it dirty would make renderBlock
16
+ // rebuild the container from its children, which cannot reproduce a blockquote
17
+ // (`> ` markers live only in the container raw) or a table (the delimiter row
18
+ // is not a child node) — the op that splices these blocks in is responsible
19
+ // for invalidating the CONTAINER it lands in (markContainerDirty).
20
+ function parseContentToBlocks(content, format) {
21
+ const adapter = adapterForFormat(format);
22
+ const normalized = content.endsWith("\n") ? content : content + "\n";
23
+ const tree = adapter ? adapter.parse(normalized) : parseTree(normalized);
24
+ const trivia = defaultTrivia(format);
25
+ const toMut = (b) => ({
26
+ id: mintId("b"),
27
+ type: b.type,
28
+ raw: b.raw,
29
+ trivia: b.trivia || trivia,
30
+ attrs: b.attrs,
31
+ children: b.children.map(toMut),
32
+ dirty: false,
33
+ });
34
+ return tree.children.filter((b) => b.type !== "frontmatter").map(toMut);
35
+ }
36
+ // Resolve a To into a target sibling list + insertion index.
37
+ function resolveTarget(doc, to) {
38
+ let siblings;
39
+ if (to.parent === null || (typeof to.parent === "object" && "doc" in to.parent)) {
40
+ siblings = doc.children;
41
+ }
42
+ else if (typeof to.parent === "object" && "heading" in to.parent) {
43
+ // scope:section — resolve the section's range at top level: insert before
44
+ // the next peer/higher heading (04 §1.1). Section blocks live at top level.
45
+ return resolveSection(doc, to.parent.heading, to.at);
46
+ }
47
+ else {
48
+ const found = locate(doc, to.parent);
49
+ if (!found)
50
+ throw new MutationError("parent_missing", `parent ${String(to.parent)} not found`);
51
+ siblings = found.block.children;
52
+ }
53
+ return { siblings, index: resolveIndex(siblings, to.at) };
54
+ }
55
+ function resolveIndex(siblings, at) {
56
+ if (at === "start")
57
+ return 0;
58
+ if (at === "end")
59
+ return siblings.length;
60
+ if ("before" in at) {
61
+ const i = siblings.findIndex((b) => b.id === at.before);
62
+ if (i < 0)
63
+ throw new MutationError("target_missing", `anchor ${at.before} not found`);
64
+ return i;
65
+ }
66
+ const i = siblings.findIndex((b) => b.id === at.after);
67
+ if (i < 0)
68
+ throw new MutationError("target_missing", `anchor ${at.after} not found`);
69
+ return i + 1;
70
+ }
71
+ // A section runs from its heading to the block before the next heading of equal
72
+ // or higher level. Blocks are top-level (flat containment, 01 §3.2).
73
+ function resolveSection(doc, headingId, at) {
74
+ const hIdx = doc.children.findIndex((b) => b.id === headingId);
75
+ if (hIdx < 0)
76
+ throw new MutationError("target_missing", `heading ${headingId} not found`);
77
+ const level = Number(doc.children[hIdx].attrs.level ?? 1);
78
+ let end = doc.children.length;
79
+ for (let i = hIdx + 1; i < doc.children.length; i++) {
80
+ const b = doc.children[i];
81
+ if (b.type === "heading" && Number(b.attrs.level ?? 1) <= level) {
82
+ end = i;
83
+ break;
84
+ }
85
+ }
86
+ if (at === "end")
87
+ return { siblings: doc.children, index: end };
88
+ if (at === "start")
89
+ return { siblings: doc.children, index: hIdx + 1 };
90
+ return { siblings: doc.children, index: resolveIndex(doc.children, at) };
91
+ }
92
+ function checkContentHash(block, expect, opIndex) {
93
+ if (!expect?.content_hash) {
94
+ // Hand back the current hash + bytes so the caller can retry immediately
95
+ // (same actionable shape as a hash MISMATCH below) instead of a separate
96
+ // hydration read — an agent may hold fresh ids yet lack the CAS token.
97
+ throw new MutationError("stale_expectation", "expect.content_hash required", {
98
+ op_index: opIndex,
99
+ block: block.id,
100
+ current: { content_hash: rawHashHex(block.raw), markdown: block.raw },
101
+ retriable: true,
102
+ });
103
+ }
104
+ const current = rawHashHex(block.raw);
105
+ if (current !== expect.content_hash) {
106
+ throw new MutationError("stale_expectation", "content hash mismatch", {
107
+ op_index: opIndex,
108
+ block: block.id,
109
+ expected_content_hash: expect.content_hash,
110
+ current: { content_hash: current, markdown: block.raw },
111
+ retriable: true,
112
+ });
113
+ }
114
+ }
115
+ // Assign known ids onto a freshly-parsed subtree by positional key (parentKey +
116
+ // '/' + index), the same convention flatten()/known-ids use. Keys absent from
117
+ // the map leave the block's minted id in place (a genuinely new child).
118
+ function assignChildIds(children, childIds, parentKey) {
119
+ children.forEach((c, i) => {
120
+ const key = `${parentKey}/${i}`;
121
+ const id = childIds[key];
122
+ if (id)
123
+ c.id = id;
124
+ if (c.children.length > 0)
125
+ assignChildIds(c.children, childIds, key);
126
+ });
127
+ }
128
+ // Wrap a non-list parsed block as a list_item (marker prepended) so it can be
129
+ // inserted into a list. The marker is normalized on render (renderItem strips
130
+ // and re-applies it, renumbering ordered lists), so `- ` here is just a seed.
131
+ function wrapAsItem(b) {
132
+ return { id: mintId("b"), type: "list_item", raw: `- ${b.raw}`, trivia: "", attrs: {}, children: [], dirty: true };
133
+ }
134
+ // Mark a block and its whole subtree clean (dirty=false), so renderDoc emits the
135
+ // block's retained raw verbatim rather than rebuilding it from children.
136
+ function markSubtreeClean(b) {
137
+ b.dirty = false;
138
+ for (const c of b.children)
139
+ markSubtreeClean(c);
140
+ }
141
+ // ---- the six ops ------------------------------------------------------------
142
+ export function opInsert(doc, to, markdown) {
143
+ const { siblings, index } = resolveTarget(doc, to);
144
+ let blocks = parseContentToBlocks(markdown, doc.format);
145
+ // Inserting into a list: the content must land as list ITEMS, not a nested
146
+ // list. Markdown `- x` parses to a `list` wrapping a `list_item`; unwrap it to
147
+ // its items (a bare block is wrapped as one item). Then mark the list dirty so
148
+ // it re-renders from its new items. (04 §1 / lists_insert_item.)
149
+ const container = ownerOf(doc, siblings);
150
+ if (container?.type === "list" && doc.format === "markdown") {
151
+ blocks = blocks.flatMap((b) => (b.type === "list" ? b.children : [wrapAsItem(b)]));
152
+ siblings.splice(index, 0, ...blocks);
153
+ container.dirty = true;
154
+ return { ids: blocks.map((b) => b.id) };
155
+ }
156
+ // Trivia is document tiling rendered independently of a block's raw, so the
157
+ // separators fixed up here set no `dirty` flag — no block content changed.
158
+ // Only the top-level list is rendered from trivia (nested containers are
159
+ // re-emitted from children with single-newline joins, so their trivia is
160
+ // inert); confine separator normalization to that list.
161
+ if (siblings === doc.children) {
162
+ const sep = defaultTrivia(doc.format);
163
+ // Separator BEFORE the run: the block now preceding it must end at a real
164
+ // block boundary, or the two render jammed together — e.g. appending a
165
+ // heading at the end of a section whose last paragraph carried only a
166
+ // single trailing "\n" glued "…text\n## Heading" with no blank line.
167
+ if (index > 0) {
168
+ const prev = siblings[index - 1];
169
+ if (!separatesBlocks(prev.trivia, doc.format))
170
+ prev.trivia = sep;
171
+ }
172
+ // Separator AFTER the run: a mid-document insert leaves a following block,
173
+ // and the parsed run's last block ends in a lone "\n" — bump it too.
174
+ if (blocks.length > 0 && index < siblings.length) {
175
+ const last = blocks[blocks.length - 1];
176
+ if (!separatesBlocks(last.trivia, doc.format))
177
+ last.trivia = sep;
178
+ }
179
+ }
180
+ siblings.splice(index, 0, ...blocks);
181
+ // A nested insert (into a blockquote/table/list item) changes the owning
182
+ // container's children, so its cached raw is stale — rebuild it from children.
183
+ if (siblings !== doc.children)
184
+ markContainerDirty(doc, siblings);
185
+ return { ids: blocks.map((b) => b.id) };
186
+ }
187
+ // Does this trailing trivia hold a real block boundary? Markdown block-level
188
+ // content needs a blank line between blocks (a lone "\n" renders as a soft
189
+ // continuation, jamming an ATX heading onto the previous paragraph); other
190
+ // formats only need the separator to be non-empty.
191
+ function separatesBlocks(trivia, format) {
192
+ return format === "markdown" ? trivia.includes("\n\n") : trivia !== "";
193
+ }
194
+ export function opUpdate(doc, blockId, opIndex, markdown, attrs, expect, trivia, childIds) {
195
+ const found = locate(doc, blockId);
196
+ if (!found)
197
+ throw new MutationError("block_missing", `block ${blockId} not found`, { op_index: opIndex, block: blockId });
198
+ // Content CAS is required only for a content edit (`markdown`). Attr- and
199
+ // trivia-only updates change no block content, so they follow the placement
200
+ // convention (no content CAS); a supplied `expect.content_hash` is still
201
+ // honored when present (CAS honesty, README invariant 6). This lets the
202
+ // whole-doc planner set exact trailing trivia after structural ops without
203
+ // juggling post-op content hashes across the changeset.
204
+ if (markdown !== undefined || expect?.content_hash !== undefined)
205
+ checkContentHash(found.block, expect, opIndex);
206
+ // Blocks minted from MULTI-block content: the target takes the first parsed
207
+ // block (keeping its id); the rest become fresh siblings right after it.
208
+ const extra = [];
209
+ if (markdown !== undefined) {
210
+ if (doc.format === "markdown" && found.block.type === "list_item") {
211
+ // A list item can't be re-parsed as a standalone block (`- x` parses to a
212
+ // list, `x` loses the marker). Unwrap: accept a list (its first item
213
+ // replaces this one, further items become following siblings) or a bare
214
+ // block (wrap it), keep this block a list_item, and mark the containing
215
+ // list dirty so it re-renders from its items.
216
+ const parsed = parseContentToBlocks(markdown, doc.format);
217
+ if (parsed.length !== 1) {
218
+ throw new MutationError("type_mismatch", "list-item update content must be ONE list (`- a\\n- b`: first item replaces, the rest follow as siblings) or ONE bare block", {
219
+ op_index: opIndex, block: blockId, hint: "to put mixed content under an item, update the item then blocks_insert the rest with `to` = the item",
220
+ });
221
+ }
222
+ const only = parsed[0];
223
+ if (only.type === "list") {
224
+ if (only.children.length === 0)
225
+ throw new MutationError("type_mismatch", "list-item update must yield at least one item", { op_index: opIndex, block: blockId });
226
+ found.block.raw = only.children[0].raw;
227
+ found.block.children = only.children[0].children;
228
+ extra.push(...only.children.slice(1));
229
+ }
230
+ else {
231
+ found.block.raw = `- ${only.raw}`;
232
+ found.block.children = [];
233
+ }
234
+ found.block.dirty = true;
235
+ markContainerDirty(doc, found.siblings);
236
+ }
237
+ else if (doc.format === "markdown") {
238
+ const parsed = parseContentToBlocks(markdown, doc.format);
239
+ if (parsed.length === 0)
240
+ throw new MutationError("type_mismatch", "update content must contain at least one block", { op_index: opIndex, block: blockId });
241
+ if (parsed.length > 1 && childIds !== undefined)
242
+ throw new MutationError("type_mismatch", "update with childIds must be a single block", { op_index: opIndex, block: blockId });
243
+ extra.push(...parsed.slice(1));
244
+ const nb = parsed[0];
245
+ found.block.raw = nb.raw;
246
+ found.block.type = nb.type;
247
+ found.block.attrs = nb.attrs;
248
+ found.block.children = nb.children;
249
+ // Thread known child identities onto the re-parsed subtree, positionally
250
+ // (mirroring flatten()/known-ids keys, relative to this block's children).
251
+ // A whole-container update (e.g. a list) re-parses its children, minting
252
+ // fresh ids by default; supplying childIds lets the whole-document update
253
+ // planner preserve reconcile-carried identity for the container's items
254
+ // (nested identity) — the commit re-parse threads exactly these ids.
255
+ if (childIds !== undefined && found.block.children.length > 0) {
256
+ assignChildIds(found.block.children, childIds, "");
257
+ }
258
+ // Render the block VERBATIM from its op-supplied raw (the exact target
259
+ // bytes) rather than rebuilding from children — the splice renderer's
260
+ // child rebuild can't reproduce nested/loose list formatting, blockquote
261
+ // markers, or a table's delimiter row. The re-parsed children remain
262
+ // (clean) only to carry ids for the commit re-parse. The block's OWN raw
263
+ // is fresh, so it is not dirty; what is stale is the raw of the container
264
+ // it lives in (a nested update), which is invalidated instead.
265
+ markSubtreeClean(found.block);
266
+ markContainerDirty(doc, found.siblings);
267
+ }
268
+ else {
269
+ // Non-markdown: raw swap preserves the block's type and attrs. Fragments
270
+ // like JSON properties aren't valid standalone documents, so re-parsing
271
+ // would corrupt the block kind (e.g. json:property → json:scalar).
272
+ found.block.raw = markdown;
273
+ found.block.children = [];
274
+ found.block.dirty = true;
275
+ }
276
+ }
277
+ if (attrs) {
278
+ found.block.attrs = { ...found.block.attrs, ...attrs };
279
+ // attr-only edits (e.g. task checkbox) re-render the marker: mark dirty and
280
+ // rewrite the raw checkbox if present.
281
+ if ("checked" in attrs && (found.block.type === "task" || found.block.type === "list_item")) {
282
+ found.block.raw = found.block.raw.replace(/\[[ xX]\]/, attrs.checked ? "[x]" : "[ ]");
283
+ found.block.type = "task";
284
+ found.block.dirty = true;
285
+ }
286
+ }
287
+ // Trailing trivia (positional tiling): set verbatim. No content change, no
288
+ // dirty flag — renderDoc emits a top-level block's trivia unconditionally.
289
+ if (trivia !== undefined)
290
+ found.block.trivia = trivia;
291
+ if (extra.length > 0) {
292
+ // Splice the surplus blocks in after the target. The target's trailing
293
+ // trivia is the document's tiling AFTER the whole run, so it moves to the
294
+ // last new block; every seam inside the run must separate blocks (top level
295
+ // only — nested containers re-render from their children).
296
+ const tail = found.block.trivia;
297
+ if (found.siblings === doc.children) {
298
+ const sep = defaultTrivia(doc.format);
299
+ if (!separatesBlocks(found.block.trivia, doc.format))
300
+ found.block.trivia = sep;
301
+ for (const b of extra.slice(0, -1))
302
+ if (!separatesBlocks(b.trivia, doc.format))
303
+ b.trivia = sep;
304
+ }
305
+ extra[extra.length - 1].trivia = tail;
306
+ found.siblings.splice(found.index + 1, 0, ...extra);
307
+ markContainerDirty(doc, found.siblings);
308
+ }
309
+ return { ids: [blockId, ...extra.map((b) => b.id)] };
310
+ }
311
+ export function opMove(doc, blockIds, to, opIndex) {
312
+ if (blockIds.length === 0)
313
+ throw new MutationError("not_contiguous", "move requires ≥1 block");
314
+ // Locate all; assert contiguous siblings.
315
+ const located = blockIds.map((id) => {
316
+ const f = locate(doc, id);
317
+ if (!f)
318
+ throw new MutationError("block_missing", `block ${id} not found`, { op_index: opIndex, block: id });
319
+ return f;
320
+ });
321
+ const siblings = located[0].siblings;
322
+ const indices = located.map((l) => l.index).sort((a, b) => a - b);
323
+ for (let i = 1; i < indices.length; i++) {
324
+ if (located[i].siblings !== siblings || indices[i] !== indices[i - 1] + 1) {
325
+ throw new MutationError("not_contiguous", "move blocks must be a contiguous sibling run", { op_index: opIndex });
326
+ }
327
+ }
328
+ // cycle_move: target parent must not be inside the moved subtree.
329
+ const movedIds = new Set();
330
+ const collect = (b) => { movedIds.add(b.id); b.children.forEach(collect); };
331
+ located.forEach((l) => collect(l.block));
332
+ if (typeof to.parent === "string" && movedIds.has(to.parent)) {
333
+ throw new MutationError("cycle_move", "target is inside the moved subtree", { op_index: opIndex });
334
+ }
335
+ // Extract (preserve order) then re-insert at the target.
336
+ const moving = located.map((l) => l.block);
337
+ for (const m of moving) {
338
+ const idx = siblings.indexOf(m);
339
+ if (idx >= 0)
340
+ siblings.splice(idx, 1);
341
+ }
342
+ const { siblings: dstSiblings, index } = resolveTarget(doc, to);
343
+ dstSiblings.splice(index, 0, ...moving);
344
+ // A move within/into a nested container invalidates that container's cached
345
+ // raw (and the source's) — mark them so they re-render from children. No-op
346
+ // for the top-level list, which renders directly and is healed below.
347
+ markContainerDirty(doc, siblings);
348
+ markContainerDirty(doc, dstSiblings);
349
+ // Heal top-level seams so relocated blocks never render jammed together.
350
+ // Trivia is positional tiling (03 §2.2), not owned by the block: a block that
351
+ // was last in the source list carried a lone "\n" that, once interior, would
352
+ // soft-merge with its new neighbour (two paragraphs → one). Both the source
353
+ // gap and the destination seam are re-tiled. Only the top-level list renders
354
+ // from trivia (nested containers re-emit from children), so confine to it.
355
+ if (siblings === doc.children)
356
+ healTopLevelSeams(doc.children, doc.format);
357
+ if (dstSiblings === doc.children && dstSiblings !== siblings)
358
+ healTopLevelSeams(doc.children, doc.format);
359
+ return { ids: blockIds };
360
+ }
361
+ // Ensure every non-last top-level block ends at a real block boundary. Leaves
362
+ // already-separating trivia untouched (idempotent, byte-preserving where the
363
+ // document is already well-formed) and never touches the last block's trailing
364
+ // trivia (that is the document's trailing bytes). Exact target trivia is the
365
+ // planner's job via `update { trivia }`; this only guarantees well-formedness.
366
+ function healTopLevelSeams(children, format) {
367
+ const sep = defaultTrivia(format);
368
+ for (let i = 0; i < children.length - 1; i++) {
369
+ const b = children[i];
370
+ if (!separatesBlocks(b.trivia, format))
371
+ b.trivia = sep;
372
+ }
373
+ }
374
+ export function opRemove(doc, blockIds, opIndex, expectPer) {
375
+ // Pass 1 — validate while every block is still in place: a genuinely unknown
376
+ // id is block_missing, and each CAS expectation is checked against the block's
377
+ // current bytes. Then collapse the set to its TOP-MOST members: a block whose
378
+ // ancestor is also being removed goes with that ancestor's subtree. A caller
379
+ // holding a flat id list (docs_read include_ids) can hand over a container
380
+ // and its children together without the child's lookup failing after the
381
+ // container has already taken it. Duplicates collapse too.
382
+ const set = new Set(blockIds);
383
+ const tops = [];
384
+ const seen = new Set();
385
+ for (const id of blockIds) {
386
+ if (seen.has(id))
387
+ continue;
388
+ seen.add(id);
389
+ const found = locate(doc, id);
390
+ if (!found) {
391
+ throw new MutationError("block_missing", `block ${id} not found in this document`, {
392
+ op_index: opIndex,
393
+ block: id,
394
+ hint: "the id is not a live block of the targeted document — it may have been removed by an earlier op in this changeset (removing a block removes its whole subtree), or never existed",
395
+ });
396
+ }
397
+ if (expectPer?.[id])
398
+ checkContentHash(found.block, expectPer[id], opIndex);
399
+ if (!hasAncestorIn(doc, found.siblings, set))
400
+ tops.push(id);
401
+ }
402
+ // Pass 2 — remove the top-most blocks; `removed` reports every block that
403
+ // actually left the tree (each subtree walked), so the caller sees the
404
+ // collapsed descendants too.
405
+ const removed = [];
406
+ for (const id of tops) {
407
+ const found = locate(doc, id);
408
+ const collect = (b) => { removed.push(b.id); b.children.forEach(collect); };
409
+ collect(found.block);
410
+ found.siblings.splice(found.index, 1);
411
+ // Removing a nested block invalidates its container's cached raw; a container
412
+ // emptied by the removal is itself removed (an empty list would render blank).
413
+ pruneOrDirty(doc, found.siblings);
414
+ }
415
+ return { ids: blockIds, removed };
416
+ }
417
+ // Does any block owning `siblings` (walking up to the document root) have an id
418
+ // in `set`? I.e. is a block found in `siblings` a descendant of a block in `set`.
419
+ function hasAncestorIn(doc, siblings, set) {
420
+ let cur = siblings;
421
+ for (;;) {
422
+ const owner = ownerOf(doc, cur);
423
+ if (!owner)
424
+ return false;
425
+ if (set.has(owner.id))
426
+ return true;
427
+ const f = locate(doc, owner.id);
428
+ if (!f)
429
+ return false;
430
+ cur = f.siblings;
431
+ }
432
+ }
433
+ // After removing from `siblings`: if that list is now empty, remove the empty
434
+ // container from its own parent (recursing); otherwise mark it dirty so it
435
+ // re-renders from its remaining children.
436
+ function pruneOrDirty(doc, siblings) {
437
+ const owner = ownerOf(doc, siblings);
438
+ if (!owner)
439
+ return; // top-level list: renderDoc renders it directly
440
+ if (owner.children.length === 0) {
441
+ const f = locate(doc, owner.id);
442
+ if (f) {
443
+ f.siblings.splice(f.index, 1);
444
+ pruneOrDirty(doc, f.siblings);
445
+ }
446
+ }
447
+ else {
448
+ owner.dirty = true;
449
+ }
450
+ }
451
+ export function opSplit(doc, blockId, at, opIndex, expect) {
452
+ const found = locate(doc, blockId);
453
+ if (!found)
454
+ throw new MutationError("block_missing", `block ${blockId} not found`, { op_index: opIndex, block: blockId });
455
+ checkContentHash(found.block, expect, opIndex);
456
+ const raw = found.block.raw;
457
+ const cuts = [0, ...at, raw.length].sort((a, b) => a - b);
458
+ const pieces = [];
459
+ for (let i = 1; i < cuts.length; i++)
460
+ pieces.push(raw.slice(cuts[i - 1], cuts[i]));
461
+ const nonEmpty = pieces.filter((p) => p.trim().length > 0);
462
+ if (nonEmpty.length < 2)
463
+ throw new MutationError("type_mismatch", "split must yield ≥2 non-empty fragments");
464
+ // First fragment carries the id (authored intent, 04 §1); rest minted.
465
+ found.block.raw = nonEmpty[0];
466
+ found.block.dirty = true;
467
+ const newBlocks = nonEmpty.slice(1).map((p) => ({
468
+ id: mintId("b"), type: found.block.type, raw: p, trivia: "\n\n", attrs: { ...found.block.attrs }, children: [], dirty: true,
469
+ }));
470
+ found.siblings.splice(found.index + 1, 0, ...newBlocks);
471
+ return { ids: [blockId, ...newBlocks.map((b) => b.id)] };
472
+ }
473
+ export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
474
+ if (blockIds.length < 2)
475
+ throw new MutationError("not_contiguous", "merge requires ≥2 blocks");
476
+ const located = blockIds.map((id) => {
477
+ const f = locate(doc, id);
478
+ if (!f)
479
+ throw new MutationError("block_missing", `block ${id} not found`, { op_index: opIndex, block: id });
480
+ return f;
481
+ });
482
+ const siblings = located[0].siblings;
483
+ const type = located[0].block.type;
484
+ const indices = located.map((l) => l.index).sort((a, b) => a - b);
485
+ for (let i = 0; i < located.length; i++) {
486
+ if (located[i].siblings !== siblings)
487
+ throw new MutationError("not_contiguous", "merge blocks must share a parent", { op_index: opIndex });
488
+ if (located[i].block.type !== type)
489
+ throw new MutationError("type_mismatch", "merge blocks must share a type", { op_index: opIndex });
490
+ if (expectPer?.[blockIds[i]])
491
+ checkContentHash(located[i].block, expectPer[blockIds[i]], opIndex);
492
+ }
493
+ for (let i = 1; i < indices.length; i++)
494
+ if (indices[i] !== indices[i - 1] + 1)
495
+ throw new MutationError("not_contiguous", "merge blocks must be contiguous", { op_index: opIndex });
496
+ const ordered = indices.map((i) => siblings[i]);
497
+ const first = ordered[0];
498
+ first.raw = ordered.map((b) => b.raw).join(separator);
499
+ first.dirty = true;
500
+ const mergedInto = ordered.slice(1).map((b) => b.id);
501
+ // remove the others (highest index first)
502
+ for (let i = indices.length - 1; i >= 1; i--)
503
+ siblings.splice(indices[i], 1);
504
+ return { ids: [first.id], mergedInto };
505
+ }
506
+ export function checkParentChildrenHash(doc, parentId, expected, opIndex) {
507
+ const list = parentId ? locate(doc, parentId)?.block.children ?? doc.children : doc.children;
508
+ const current = parentChildrenHash(list);
509
+ if (current !== expected) {
510
+ throw new MutationError("stale_expectation", "parent_children_hash mismatch", { op_index: opIndex, current: { parent_children_hash: current }, retriable: true });
511
+ }
512
+ }
513
+ //# sourceMappingURL=ops.js.map
@@ -0,0 +1,92 @@
1
+ import type { Op } from "./apply.js";
2
+ export type OpsetKind = "doc_update";
3
+ /**
4
+ * The identity consequence of one planned op, in the reconciler's vocabulary
5
+ * (03 §4). Kept as a string so mutate/ carries no runtime dependency on
6
+ * reconcile/; the values mirror reconcile/types.ts DispositionKind plus the
7
+ * synthetic `retiled` for a trailing-trivia-only fixup the planner emits to
8
+ * reproduce the proposed bytes exactly.
9
+ */
10
+ export type PlanDisposition = "same" | "edited" | "moved" | "edited_moved" | "inserted" | "deleted" | "split_from" | "merged_into" | "copied_from" | "resurrected" | "bulk_rewrite" | "retiled";
11
+ /** State the opset was planned against; applying against different state fails. */
12
+ export interface OpsetPrecondition {
13
+ /** doc id the plan targets */
14
+ doc: string;
15
+ path: string;
16
+ /** the document's current revision id at plan time (null for a doc with no committed revision yet) */
17
+ baseRevision: string | null;
18
+ /** sha256 hex of the document's current rendered bytes (docs.file_hash) at plan time */
19
+ baseContentHash: string;
20
+ }
21
+ /** One kernel op plus the identity consequence the planner attributes to it. */
22
+ export interface PlanOp {
23
+ /** the executable kernel op (unchanged semantics) */
24
+ op: Op;
25
+ /** identity disposition this op realizes (planning metadata) */
26
+ disposition: PlanDisposition;
27
+ /**
28
+ * subject block ids for human/agent inspection: the carried id(s) an
29
+ * update/move/remove acts on, the survivor for merge, the origin for split.
30
+ * Empty for an insert of genuinely new structure (its id is minted at apply).
31
+ */
32
+ blocks: string[];
33
+ confidence: number | null;
34
+ reason: string | null;
35
+ detail?: Record<string, unknown>;
36
+ }
37
+ /** Identity accounting over the reconciliation (for dry-run summaries). */
38
+ export interface OpsetSummary {
39
+ /** blocks carried with unchanged content and position */
40
+ preserved: number;
41
+ updated: number;
42
+ moved: number;
43
+ created: number;
44
+ removed: number;
45
+ split: number;
46
+ merged: number;
47
+ /** low-confidence carries / recorded near-misses the matcher flagged */
48
+ ambiguous: number;
49
+ }
50
+ export interface Opset {
51
+ version: 1;
52
+ kind: OpsetKind;
53
+ target: {
54
+ doc: string;
55
+ path: string;
56
+ };
57
+ precondition: OpsetPrecondition;
58
+ /** matcher version that produced the identity decisions (reproducibility) */
59
+ matcherV: string;
60
+ /** the planned ops, each annotated with its identity consequence */
61
+ ops: PlanOp[];
62
+ /**
63
+ * Document-level frontmatter change, when the proposed content changes it.
64
+ * `raw` is the new frontmatter incl. fences + trailing separator (null drops
65
+ * it). Frontmatter is a materialization unit, not a block op; apply sets it
66
+ * via ApplyRequest.setFrontmatter.
67
+ */
68
+ frontmatter?: {
69
+ raw: string | null;
70
+ };
71
+ summary: OpsetSummary;
72
+ /**
73
+ * Verified by the planner: replaying `ops` from the base state renders to
74
+ * exactly the proposed content. When false, `apply(opset)` refuses (a plan
75
+ * that cannot reproduce the intended bytes is never applied silently); the
76
+ * caller inspects `diagnostics` and re-plans or edits.
77
+ */
78
+ converges: boolean;
79
+ diagnostics: string[];
80
+ }
81
+ /** The bare kernel ops, in order — what `apply` actually replays. */
82
+ export declare function opsetKernelOps(opset: Opset): Op[];
83
+ /** Compute the summary from the planned ops + carried/ambiguous counts. */
84
+ export declare function summarizeOps(ops: PlanOp[], counts: {
85
+ preserved: number;
86
+ ambiguous: number;
87
+ }): OpsetSummary;
88
+ /** Serialize an opset to a stable JSON string (ops are plain JSON). */
89
+ export declare function serializeOpset(opset: Opset): string;
90
+ /** Parse a serialized opset, validating the version tag. */
91
+ export declare function parseOpset(text: string): Opset;
92
+ export declare function renderOpsetPlan(opset: Opset): string;