@brianmsm/quarto-bento 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +759 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2423 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +433 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/** Bounded pure-data agent operations over the same saved project as Studio. */
|
|
2
|
+
import { normalizeNotesText, notesIdentity } from "../source/notes.ts";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { projectPaths, readProject, saveProject } from "./project.ts";
|
|
5
|
+
import { applySlideAction } from "./slide-actions.ts";
|
|
6
|
+
import { assertJson } from "../state/json.ts";
|
|
7
|
+
import {
|
|
8
|
+
type BentoDoc,
|
|
9
|
+
type BentoSlide,
|
|
10
|
+
clone,
|
|
11
|
+
type JsonObject,
|
|
12
|
+
type ProjectState,
|
|
13
|
+
StateError,
|
|
14
|
+
} from "../state/mod.ts";
|
|
15
|
+
import {
|
|
16
|
+
assertProjectDocument,
|
|
17
|
+
BENTO_CONTRACT_REVISION,
|
|
18
|
+
} from "../render/validate.ts";
|
|
19
|
+
import { authoredText } from "../render/lists.ts";
|
|
20
|
+
import { inlineHtmlToMarkdown } from "../render/inline.ts";
|
|
21
|
+
import { hashSource, scanSource, type SourceDocument } from "../source/mod.ts";
|
|
22
|
+
import {
|
|
23
|
+
previewStructure,
|
|
24
|
+
type StructureOperation,
|
|
25
|
+
} from "../source/structure.ts";
|
|
26
|
+
|
|
27
|
+
export const MAX_AGENT_PATCH_BYTES = 1024 * 1024;
|
|
28
|
+
export const MAX_AGENT_CREATE_ELEMENTS = 50;
|
|
29
|
+
export const AGENT_ELEMENT_FIELDS = [
|
|
30
|
+
"x",
|
|
31
|
+
"y",
|
|
32
|
+
"w",
|
|
33
|
+
"h",
|
|
34
|
+
"rotation",
|
|
35
|
+
"opacity",
|
|
36
|
+
"fontSize",
|
|
37
|
+
"fontFamily",
|
|
38
|
+
"fontWeight",
|
|
39
|
+
"color",
|
|
40
|
+
"align",
|
|
41
|
+
"valign",
|
|
42
|
+
"lineHeight",
|
|
43
|
+
"letterSpacing",
|
|
44
|
+
"fill",
|
|
45
|
+
"stroke",
|
|
46
|
+
"strokeWidth",
|
|
47
|
+
"strokeStyle",
|
|
48
|
+
"strokeDash",
|
|
49
|
+
"radius",
|
|
50
|
+
"html",
|
|
51
|
+
"content",
|
|
52
|
+
"src",
|
|
53
|
+
"markup",
|
|
54
|
+
"rows",
|
|
55
|
+
"columns",
|
|
56
|
+
"header",
|
|
57
|
+
"option",
|
|
58
|
+
] as const;
|
|
59
|
+
export const AGENT_SLIDE_FIELDS = [
|
|
60
|
+
"background",
|
|
61
|
+
"name",
|
|
62
|
+
"notes",
|
|
63
|
+
"transition",
|
|
64
|
+
"unnumbered",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
export type AgentOperation =
|
|
68
|
+
| {
|
|
69
|
+
op: "element.patch";
|
|
70
|
+
slideId: string;
|
|
71
|
+
elementId: string;
|
|
72
|
+
fields: JsonObject;
|
|
73
|
+
}
|
|
74
|
+
| { op: "slide.patch"; slideId: string; fields: JsonObject }
|
|
75
|
+
| { op: "slide.move"; slideId: string; afterId: string | null }
|
|
76
|
+
| {
|
|
77
|
+
op: "slide.create";
|
|
78
|
+
slideId: string;
|
|
79
|
+
afterId: string | null;
|
|
80
|
+
slide: BentoSlide;
|
|
81
|
+
}
|
|
82
|
+
| { op: "slide.hide" | "slide.show"; slideId: string };
|
|
83
|
+
export interface AgentPatch {
|
|
84
|
+
version: 1;
|
|
85
|
+
docId: string;
|
|
86
|
+
expectedRevision: number;
|
|
87
|
+
operations: AgentOperation[];
|
|
88
|
+
}
|
|
89
|
+
function fail(code: string, message: string): never {
|
|
90
|
+
throw new StateError(code, message);
|
|
91
|
+
}
|
|
92
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
function keys(value: Record<string, unknown>, expected: string[]) {
|
|
96
|
+
if (
|
|
97
|
+
Object.keys(value).length !== expected.length ||
|
|
98
|
+
expected.some((key) => !Object.hasOwn(value, key))
|
|
99
|
+
) fail("AGENT_INVALID_REQUEST", "Missing or unknown request properties.");
|
|
100
|
+
}
|
|
101
|
+
function identity(value: unknown): asserts value is string {
|
|
102
|
+
if (typeof value !== "string" || !value || value.length > 200) {
|
|
103
|
+
fail(
|
|
104
|
+
"AGENT_INVALID_TARGET",
|
|
105
|
+
"Use an exact nonempty stable ID, at most 200 characters.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function validatePatch(value: unknown): asserts value is AgentPatch {
|
|
110
|
+
assertJson(value);
|
|
111
|
+
if (!object(value)) fail("AGENT_INVALID_REQUEST", "Expected a patch object.");
|
|
112
|
+
keys(value, ["version", "docId", "expectedRevision", "operations"]);
|
|
113
|
+
if (
|
|
114
|
+
value.version !== 1 || typeof value.docId !== "string" || !value.docId ||
|
|
115
|
+
!Number.isSafeInteger(value.expectedRevision) ||
|
|
116
|
+
(value.expectedRevision as number) < 0 ||
|
|
117
|
+
!Array.isArray(value.operations) || !value.operations.length ||
|
|
118
|
+
value.operations.length > 100
|
|
119
|
+
) {
|
|
120
|
+
fail(
|
|
121
|
+
"AGENT_INVALID_REQUEST",
|
|
122
|
+
"Expected version 1, docId, a nonnegative revision and 1..100 operations.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
new TextEncoder().encode(JSON.stringify(value)).length >
|
|
127
|
+
MAX_AGENT_PATCH_BYTES
|
|
128
|
+
) fail("AGENT_INPUT_TOO_LARGE", "Patch exceeds the 1 MiB limit.");
|
|
129
|
+
for (const operation of value.operations) {
|
|
130
|
+
if (!object(operation)) {
|
|
131
|
+
fail("AGENT_INVALID_REQUEST", "Expected operation objects.");
|
|
132
|
+
}
|
|
133
|
+
identity(operation.slideId);
|
|
134
|
+
switch (operation.op) {
|
|
135
|
+
case "element.patch":
|
|
136
|
+
case "slide.patch": {
|
|
137
|
+
keys(
|
|
138
|
+
operation,
|
|
139
|
+
operation.op === "element.patch"
|
|
140
|
+
? ["op", "slideId", "elementId", "fields"]
|
|
141
|
+
: ["op", "slideId", "fields"],
|
|
142
|
+
);
|
|
143
|
+
if (operation.op === "element.patch") identity(operation.elementId);
|
|
144
|
+
const allowed: readonly string[] = operation.op === "element.patch"
|
|
145
|
+
? AGENT_ELEMENT_FIELDS
|
|
146
|
+
: AGENT_SLIDE_FIELDS;
|
|
147
|
+
if (
|
|
148
|
+
!object(operation.fields) || !Object.keys(operation.fields).length
|
|
149
|
+
) {
|
|
150
|
+
fail(
|
|
151
|
+
"AGENT_INVALID_REQUEST",
|
|
152
|
+
"Patch fields must be a nonempty object.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
for (const field of Object.keys(operation.fields)) {
|
|
156
|
+
if (!allowed.includes(field)) {
|
|
157
|
+
fail(
|
|
158
|
+
"AGENT_UNKNOWN_FIELD",
|
|
159
|
+
`Unsupported ${operation.op} field: ${field}.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "slide.move":
|
|
166
|
+
keys(operation, ["op", "slideId", "afterId"]);
|
|
167
|
+
if (operation.afterId !== null) identity(operation.afterId);
|
|
168
|
+
if (operation.afterId === operation.slideId) {
|
|
169
|
+
fail("AGENT_INVALID_TARGET", "A slide cannot move after itself.");
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
case "slide.create": {
|
|
173
|
+
keys(operation, ["op", "slideId", "afterId", "slide"]);
|
|
174
|
+
if (operation.afterId !== null) identity(operation.afterId);
|
|
175
|
+
if (
|
|
176
|
+
!object(operation.slide) || operation.slide.id !== operation.slideId
|
|
177
|
+
) {
|
|
178
|
+
fail(
|
|
179
|
+
"AGENT_INVALID_TARGET",
|
|
180
|
+
"Created slide.id must match slideId exactly.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const slide = operation.slide;
|
|
184
|
+
if (
|
|
185
|
+
!Array.isArray(slide.elements) || !slide.elements.length ||
|
|
186
|
+
slide.elements.length > MAX_AGENT_CREATE_ELEMENTS
|
|
187
|
+
) {
|
|
188
|
+
fail(
|
|
189
|
+
"AGENT_INVALID_REQUEST",
|
|
190
|
+
"Create requires 1..50 native elements.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (slide.title !== undefined && slide.title !== "") {
|
|
194
|
+
fail(
|
|
195
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
196
|
+
"Separate title metadata requires explicit QMD authoring.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
for (const entity of [slide, ...slide.elements]) {
|
|
200
|
+
if (!object(entity)) {
|
|
201
|
+
fail("AGENT_INVALID_REQUEST", "Expected native element objects.");
|
|
202
|
+
}
|
|
203
|
+
identity(entity.id);
|
|
204
|
+
if (!/^[A-Za-z][\w:.-]*$/.test(entity.id)) {
|
|
205
|
+
fail(
|
|
206
|
+
"AGENT_INVALID_TARGET",
|
|
207
|
+
"Created identities must be source-safe: a letter followed by letters, digits, _, :, . or -.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
["origin", "producer", "provenance", "source"].some((key) =>
|
|
212
|
+
Object.hasOwn(entity, key)
|
|
213
|
+
)
|
|
214
|
+
) {
|
|
215
|
+
fail(
|
|
216
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
217
|
+
"Creation cannot declare computation provenance.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
slide.elements.some((e) =>
|
|
223
|
+
!object(e) || typeof e.type !== "string" ||
|
|
224
|
+
!["text", "shape"].includes(e.type)
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
227
|
+
fail(
|
|
228
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
229
|
+
"Create supports authored text and pure visual decorations only.",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (!slide.elements.some((e) => object(e) && e.type === "text")) {
|
|
233
|
+
fail("AGENT_UNSUPPORTED_CREATION", "Create requires a text title.");
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "slide.hide":
|
|
238
|
+
case "slide.show":
|
|
239
|
+
keys(operation, ["op", "slideId"]);
|
|
240
|
+
if (value.operations.length !== 1) {
|
|
241
|
+
fail(
|
|
242
|
+
"AGENT_INVALID_REQUEST",
|
|
243
|
+
"Visibility operations must be submitted alone.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
default:
|
|
248
|
+
fail(
|
|
249
|
+
"AGENT_UNKNOWN_OPERATION",
|
|
250
|
+
`Unsupported agent operation: ${operation.op}.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Refuse reading another QMD's deck from a shared directory. */
|
|
257
|
+
async function readBoundProject(source: string) {
|
|
258
|
+
const paths = projectPaths(source);
|
|
259
|
+
let binding;
|
|
260
|
+
try {
|
|
261
|
+
binding = JSON.parse(
|
|
262
|
+
await Deno.readTextFile(join(paths.stateDir, "project.json")),
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof Deno.errors.NotFound) {
|
|
266
|
+
fail("NO_BASELINE", "Render this QMD before using the agent API.");
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
if (binding?.version !== 1 || binding.source !== basename(paths.source)) {
|
|
271
|
+
fail(
|
|
272
|
+
"PROJECT_SOURCE_MISMATCH",
|
|
273
|
+
".qbento belongs to a different QMD source.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const state = await readProject(source);
|
|
277
|
+
assertProjectDocument(state.emitted);
|
|
278
|
+
return state;
|
|
279
|
+
}
|
|
280
|
+
function snapshot(state: ProjectState) {
|
|
281
|
+
return {
|
|
282
|
+
version: 1,
|
|
283
|
+
contractRevision: BENTO_CONTRACT_REVISION,
|
|
284
|
+
docId: state.docId,
|
|
285
|
+
revision: state.revision,
|
|
286
|
+
deck: state.emitted,
|
|
287
|
+
provenance: state.provenance,
|
|
288
|
+
ownership: state.ownership,
|
|
289
|
+
pending: state.pending,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** Does not execute Quarto or capture uncommitted native HTML changes. */
|
|
293
|
+
export async function getAgentDeck(source: string) {
|
|
294
|
+
return snapshot(await readBoundProject(source));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function slide(doc: BentoDoc, id: string) {
|
|
298
|
+
const target = doc.slides.find((item) => item.id === id);
|
|
299
|
+
if (!target) {
|
|
300
|
+
fail(
|
|
301
|
+
"AGENT_TARGET_NOT_FOUND",
|
|
302
|
+
`Slide ${id} is absent from the saved deck.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return target;
|
|
306
|
+
}
|
|
307
|
+
/** Creation needs a source-backed baseline matching the entire live QMD. */
|
|
308
|
+
function assertCurrentCreationSource(
|
|
309
|
+
state: ProjectState,
|
|
310
|
+
source: SourceDocument,
|
|
311
|
+
) {
|
|
312
|
+
const hashes = new Set(
|
|
313
|
+
Object.values(state.provenance).map((proof) => proof.source?.hash).filter(
|
|
314
|
+
(hash): hash is string => typeof hash === "string",
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
if (!hashes.size) {
|
|
318
|
+
fail(
|
|
319
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
320
|
+
"Creation needs a source-backed baseline; author an identified slide in QMD and render first.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (hashes.size !== 1 || !hashes.has(source.hash)) {
|
|
324
|
+
fail(
|
|
325
|
+
"STALE_SOURCE",
|
|
326
|
+
"QMD differs from the executed baseline; recalculate before creating slides. Existing pending edits are preserved.",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Reserve live source IDs, retained baseline IDs and pending creation identities. */
|
|
331
|
+
function assertFreshCreation(
|
|
332
|
+
state: ProjectState,
|
|
333
|
+
doc: BentoDoc,
|
|
334
|
+
source: SourceDocument,
|
|
335
|
+
created: BentoSlide,
|
|
336
|
+
) {
|
|
337
|
+
const reserved = new Set(source.blocks.map((block) => block.id));
|
|
338
|
+
function retain(item: BentoSlide) {
|
|
339
|
+
reserved.add(item.id);
|
|
340
|
+
item.elements.forEach((element) => reserved.add(element.id));
|
|
341
|
+
}
|
|
342
|
+
state.generatedBaseline.slides.forEach(retain);
|
|
343
|
+
doc.slides.forEach(retain);
|
|
344
|
+
for (const proof of Object.values(state.provenance)) {
|
|
345
|
+
if (typeof proof.source?.blockId === "string") {
|
|
346
|
+
reserved.add(proof.source.blockId);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const pending of state.pending) {
|
|
350
|
+
if (pending.kind === "create-slide" && pending.after.present) {
|
|
351
|
+
const item = pending.after.value as BentoSlide;
|
|
352
|
+
retain(item);
|
|
353
|
+
item.elements.filter((e) => e.type === "text").slice(1).forEach(
|
|
354
|
+
(_element, i) => {
|
|
355
|
+
reserved.add(`${item.id}-text-${i + 1}`);
|
|
356
|
+
reserved.add(`${item.id}-text-${i + 1}/paragraph:1`);
|
|
357
|
+
reserved.add(`${item.id}-text-${i + 1}/list:1`);
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const sourceIds = created.elements.filter((e) => e.type === "text").slice(1)
|
|
363
|
+
.flatMap((
|
|
364
|
+
_element,
|
|
365
|
+
i,
|
|
366
|
+
) => [
|
|
367
|
+
`${created.id}-text-${i + 1}`,
|
|
368
|
+
`${created.id}-text-${i + 1}/paragraph:1`,
|
|
369
|
+
`${created.id}-text-${i + 1}/list:1`,
|
|
370
|
+
]);
|
|
371
|
+
for (
|
|
372
|
+
const id of [
|
|
373
|
+
created.id,
|
|
374
|
+
...created.elements.map((e) => e.id),
|
|
375
|
+
...sourceIds,
|
|
376
|
+
]
|
|
377
|
+
) {
|
|
378
|
+
if (reserved.has(id)) {
|
|
379
|
+
fail(
|
|
380
|
+
"AGENT_ID_COLLISION",
|
|
381
|
+
`Created identity ${id} is already retained by source or native state.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Use the same inline codec and structural authoring grammar as reviewed sync. */
|
|
387
|
+
function previewCreationSource(source: SourceDocument, created: BentoSlide) {
|
|
388
|
+
if (created.title !== undefined && created.title !== "") {
|
|
389
|
+
fail(
|
|
390
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
391
|
+
"Separate title metadata requires explicit QMD authoring.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const texts = created.elements.filter((e) => e.type === "text");
|
|
395
|
+
for (const element of texts) {
|
|
396
|
+
if (/\$\$|\\\[/.test(String(element.html))) {
|
|
397
|
+
fail(
|
|
398
|
+
"AGENT_UNSUPPORTED_CREATION",
|
|
399
|
+
"Create display equations explicitly in QMD, not as new native text.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const title = inlineHtmlToMarkdown(texts[0].html as string);
|
|
404
|
+
const body = texts.slice(1).map((e) =>
|
|
405
|
+
authoredText(e.html as string).markdown
|
|
406
|
+
);
|
|
407
|
+
const operations: StructureOperation[] = [{
|
|
408
|
+
type: "insert-slide",
|
|
409
|
+
id: created.id,
|
|
410
|
+
title,
|
|
411
|
+
body: body.join("\n\n"),
|
|
412
|
+
bodyFragments: body,
|
|
413
|
+
titleElementId: texts[0].id,
|
|
414
|
+
bodyElementIds: texts.slice(1).map((e) => e.id),
|
|
415
|
+
}];
|
|
416
|
+
if (created.notes) {
|
|
417
|
+
operations.push({
|
|
418
|
+
type: "insert-notes",
|
|
419
|
+
slideId: created.id,
|
|
420
|
+
id: notesIdentity(created.id),
|
|
421
|
+
text: normalizeNotesText(created.notes as string),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return scanSource(previewStructure(source, operations).result);
|
|
425
|
+
}
|
|
426
|
+
/** Validate the whole batch, then one existing CAS save; never writes QMD. */
|
|
427
|
+
export async function patchAgentDeck(source: string, input: unknown) {
|
|
428
|
+
validatePatch(input);
|
|
429
|
+
const request = clone(input);
|
|
430
|
+
const state = await readBoundProject(source);
|
|
431
|
+
if (state.docId !== request.docId) {
|
|
432
|
+
fail("UNRELATED_DOCUMENT", "Patch docId differs from the saved project.");
|
|
433
|
+
}
|
|
434
|
+
if (state.revision !== request.expectedRevision) {
|
|
435
|
+
fail("STALE_REVISION", "Get the current deck before patching.");
|
|
436
|
+
}
|
|
437
|
+
let saved: ProjectState;
|
|
438
|
+
const first = request.operations[0];
|
|
439
|
+
if (first.op === "slide.hide" || first.op === "slide.show") {
|
|
440
|
+
saved = await applySlideAction(source, {
|
|
441
|
+
action: first.op === "slide.hide" ? "hide" : "show",
|
|
442
|
+
slideId: first.slideId,
|
|
443
|
+
revision: request.expectedRevision,
|
|
444
|
+
expectedDocId: request.docId,
|
|
445
|
+
});
|
|
446
|
+
} else {
|
|
447
|
+
const doc = clone(state.emitted);
|
|
448
|
+
const sourceText = request.operations.some((op) => op.op === "slide.create")
|
|
449
|
+
? await Deno.readTextFile(projectPaths(source).source)
|
|
450
|
+
: undefined;
|
|
451
|
+
let creationSource = sourceText === undefined
|
|
452
|
+
? undefined
|
|
453
|
+
: scanSource(sourceText);
|
|
454
|
+
if (creationSource) assertCurrentCreationSource(state, creationSource);
|
|
455
|
+
for (const operation of request.operations) {
|
|
456
|
+
if (operation.op === "slide.create") {
|
|
457
|
+
if (operation.afterId !== null) slide(doc, operation.afterId);
|
|
458
|
+
assertFreshCreation(state, doc, creationSource!, operation.slide);
|
|
459
|
+
const after = operation.afterId === null
|
|
460
|
+
? -1
|
|
461
|
+
: doc.slides.findIndex((item) => item.id === operation.afterId);
|
|
462
|
+
doc.slides.splice(after + 1, 0, clone(operation.slide));
|
|
463
|
+
} else if (operation.op === "slide.move") {
|
|
464
|
+
const target = slide(doc, operation.slideId);
|
|
465
|
+
if (operation.afterId !== null) slide(doc, operation.afterId);
|
|
466
|
+
doc.slides = doc.slides.filter((item) => item.id !== target.id);
|
|
467
|
+
const after = operation.afterId === null
|
|
468
|
+
? -1
|
|
469
|
+
: doc.slides.findIndex((item) => item.id === operation.afterId);
|
|
470
|
+
doc.slides.splice(after + 1, 0, target);
|
|
471
|
+
} else if (
|
|
472
|
+
operation.op === "element.patch" || operation.op === "slide.patch"
|
|
473
|
+
) {
|
|
474
|
+
const target = slide(doc, operation.slideId);
|
|
475
|
+
const entity = operation.op === "slide.patch"
|
|
476
|
+
? target
|
|
477
|
+
: target.elements.find((item) => item.id === operation.elementId);
|
|
478
|
+
if (!entity) {
|
|
479
|
+
fail(
|
|
480
|
+
"AGENT_TARGET_NOT_FOUND",
|
|
481
|
+
`Requested element is absent from ${target.id}.`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
for (const [field, value] of Object.entries(operation.fields)) {
|
|
485
|
+
entity[field] = clone(value);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
assertProjectDocument(doc);
|
|
489
|
+
if (operation.op === "slide.create") {
|
|
490
|
+
creationSource = previewCreationSource(
|
|
491
|
+
creationSource!,
|
|
492
|
+
operation.slide,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// A later field patch can change a new slide's authored text or notes.
|
|
497
|
+
// Check the final creations as well before the single save is allowed.
|
|
498
|
+
if (sourceText !== undefined) {
|
|
499
|
+
let finalSource = scanSource(sourceText);
|
|
500
|
+
for (const operation of request.operations) {
|
|
501
|
+
if (operation.op === "slide.create") {
|
|
502
|
+
finalSource = previewCreationSource(
|
|
503
|
+
finalSource,
|
|
504
|
+
slide(doc, operation.slideId),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
saved = await saveProject(
|
|
510
|
+
source,
|
|
511
|
+
doc,
|
|
512
|
+
request.expectedRevision,
|
|
513
|
+
sourceText === undefined ? undefined : hashSource(sourceText),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return { ...snapshot(saved), sourceUnchanged: true, renderNeeded: true };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Incremental file input refuses oversized/growing files before unbounded allocation. */
|
|
520
|
+
export async function readAgentPatchFile(path: string): Promise<unknown> {
|
|
521
|
+
const info = await Deno.lstat(path);
|
|
522
|
+
if (!info.isFile || info.isSymlink) {
|
|
523
|
+
fail(
|
|
524
|
+
"AGENT_INPUT_FILE",
|
|
525
|
+
"Patch input must be a regular JSON file, not a symlink.",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const file = await Deno.open(path, { read: true });
|
|
529
|
+
try {
|
|
530
|
+
const opened = await file.stat();
|
|
531
|
+
if (!opened.isFile) {
|
|
532
|
+
fail("AGENT_INPUT_FILE", "Patch input must be a regular JSON file.");
|
|
533
|
+
}
|
|
534
|
+
if (opened.size > MAX_AGENT_PATCH_BYTES) {
|
|
535
|
+
fail("AGENT_INPUT_TOO_LARGE", "Patch exceeds the 1 MiB limit.");
|
|
536
|
+
}
|
|
537
|
+
const chunks: Uint8Array[] = [];
|
|
538
|
+
let size = 0;
|
|
539
|
+
while (true) {
|
|
540
|
+
const chunk = new Uint8Array(16 * 1024);
|
|
541
|
+
const read = await file.read(chunk);
|
|
542
|
+
if (read === null) break;
|
|
543
|
+
size += read;
|
|
544
|
+
if (size > MAX_AGENT_PATCH_BYTES) {
|
|
545
|
+
fail("AGENT_INPUT_TOO_LARGE", "Patch exceeds the 1 MiB limit.");
|
|
546
|
+
}
|
|
547
|
+
chunks.push(chunk.subarray(0, read));
|
|
548
|
+
}
|
|
549
|
+
const bytes = new Uint8Array(size);
|
|
550
|
+
let offset = 0;
|
|
551
|
+
for (const chunk of chunks) {
|
|
552
|
+
bytes.set(chunk, offset);
|
|
553
|
+
offset += chunk.length;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
return JSON.parse(
|
|
557
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
558
|
+
);
|
|
559
|
+
} catch {
|
|
560
|
+
fail("AGENT_INVALID_JSON", "Patch input must contain valid UTF-8 JSON.");
|
|
561
|
+
}
|
|
562
|
+
} finally {
|
|
563
|
+
file.close();
|
|
564
|
+
}
|
|
565
|
+
}
|