@agent-native/core 0.154.3 → 0.154.5

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 (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/.agents/skills/content/SKILL.md +2 -2
  3. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
  4. package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
  5. package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
  6. package/corpus/templates/content/actions/_database-utils.ts +55 -3
  7. package/corpus/templates/content/actions/_property-utils.ts +141 -31
  8. package/corpus/templates/content/actions/add-database-item.ts +62 -240
  9. package/corpus/templates/content/actions/configure-document-property.ts +170 -1
  10. package/corpus/templates/content/actions/delete-document-property.ts +56 -17
  11. package/corpus/templates/content/actions/delete-document.ts +3 -0
  12. package/corpus/templates/content/actions/edit-document.ts +18 -1
  13. package/corpus/templates/content/actions/export-document.ts +41 -0
  14. package/corpus/templates/content/actions/remove-database-items.ts +10 -0
  15. package/corpus/templates/content/actions/set-document-property.ts +140 -3
  16. package/corpus/templates/content/actions/update-database-item.ts +62 -0
  17. package/corpus/templates/content/actions/update-document.ts +24 -0
  18. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
  19. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
  20. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +10 -8
  22. package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
  23. package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
  24. package/corpus/templates/content/app/i18n-data.ts +20 -0
  25. package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
  26. package/corpus/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md +6 -0
  27. package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
  28. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
  29. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
  30. package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
  31. package/corpus/templates/content/parity/matrix.md +1 -1
  32. package/corpus/templates/content/parity/matrix.ts +2 -0
  33. package/corpus/templates/content/server/db/schema.ts +100 -3
  34. package/corpus/templates/content/server/plugins/db.ts +85 -0
  35. package/corpus/templates/content/shared/api.ts +78 -2
  36. package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
  37. package/corpus/templates/content/shared/document-export.ts +33 -1
  38. package/dist/action.d.ts +22 -0
  39. package/dist/action.js +27 -0
  40. package/dist/collab/awareness.d.ts +2 -2
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/extensions/url-safety.d.ts +7 -2
  43. package/dist/extensions/url-safety.js +27 -12
  44. package/dist/index.browser.d.ts +1 -1
  45. package/dist/index.browser.js +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mcp-client/oauth-client.d.ts +16 -1
  49. package/dist/mcp-client/oauth-client.js +177 -83
  50. package/dist/mcp-client/remote-store.js +46 -20
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/oauth-tokens/index.d.ts +2 -1
  53. package/dist/oauth-tokens/index.js +2 -1
  54. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  55. package/dist/oauth-tokens/lifecycle.js +434 -0
  56. package/dist/oauth-tokens/migrations.js +10 -0
  57. package/dist/oauth-tokens/store.d.ts +22 -0
  58. package/dist/oauth-tokens/store.js +141 -18
  59. package/dist/observability/routes.d.ts +2 -2
  60. package/dist/progress/routes.d.ts +1 -1
  61. package/dist/secrets/routes.d.ts +6 -6
  62. package/dist/server/action-routes.js +10 -2
  63. package/dist/server/realtime-token.d.ts +1 -1
  64. package/package.json +2 -1
@@ -0,0 +1,554 @@
1
+ import { docToNfm, nfmToDoc, type PMNode } from "./nfm.js";
2
+
3
+ export const BLOCKS_FIELD_IDENTITY_VERSION = 1;
4
+ export const BLOCK_TOMBSTONE_REVISION_WINDOW = 20;
5
+ export const MAX_BLOCK_TOMBSTONES_PER_FIELD = 500;
6
+
7
+ export type BlocksFieldIdentityStatus = "legacy" | "materialized" | "stale";
8
+
9
+ export interface BlocksFieldBlock {
10
+ id: string;
11
+ parentId: string | null;
12
+ kind: string;
13
+ position: number;
14
+ addressable: boolean;
15
+ }
16
+
17
+ export interface BlocksFieldTombstone {
18
+ id: string;
19
+ kind: string;
20
+ deletedAtRevision: number;
21
+ }
22
+
23
+ export interface BlocksFieldIdentity {
24
+ version: typeof BLOCKS_FIELD_IDENTITY_VERSION;
25
+ fieldId: string;
26
+ revision: number;
27
+ contentHash: string;
28
+ identityStatus: BlocksFieldIdentityStatus;
29
+ blocks: BlocksFieldBlock[];
30
+ tombstones: BlocksFieldTombstone[];
31
+ recoveryMode: "editor-undo-with-tombstones";
32
+ }
33
+
34
+ export interface StoredBlocksFieldBlock extends BlocksFieldBlock {
35
+ contentHash: string;
36
+ markdown: string;
37
+ deletedAtRevision: number | null;
38
+ recoveredAtRevision: number | null;
39
+ state: "live" | "deleted";
40
+ }
41
+
42
+ export interface StoredBlocksFieldIdentity {
43
+ fieldId: string;
44
+ revision: number;
45
+ contentHash: string;
46
+ blocks: StoredBlocksFieldBlock[];
47
+ }
48
+
49
+ interface BlockSnapshot {
50
+ path: string;
51
+ parentPath: string | null;
52
+ kind: string;
53
+ position: number;
54
+ addressable: boolean;
55
+ contentHash: string;
56
+ markdown: string;
57
+ preferredId: string | null;
58
+ }
59
+
60
+ const BLOCK_NODE_TYPES = new Set([
61
+ "paragraph",
62
+ "heading",
63
+ "horizontalRule",
64
+ "codeBlock",
65
+ "blockquote",
66
+ "bulletList",
67
+ "orderedList",
68
+ "listItem",
69
+ "taskList",
70
+ "taskItem",
71
+ "notionToggle",
72
+ "notionCallout",
73
+ "notionColumns",
74
+ "notionColumn",
75
+ "notionSyncedBlock",
76
+ "table",
77
+ "tableRow",
78
+ "tableHeader",
79
+ "tableCell",
80
+ "image",
81
+ "video",
82
+ "audio",
83
+ "notionBlockAtom",
84
+ "registryBlock",
85
+ "contentReference",
86
+ "localMdxComponent",
87
+ ]);
88
+
89
+ const NON_ADDRESSABLE_NODE_TYPES = new Set([
90
+ "bulletList",
91
+ "orderedList",
92
+ "taskList",
93
+ "tableRow",
94
+ "tableHeader",
95
+ "tableCell",
96
+ ]);
97
+
98
+ function hashString(value: string): string {
99
+ let first = 0x811c9dc5;
100
+ let second = 0x9e3779b9;
101
+ for (let index = 0; index < value.length; index++) {
102
+ const code = value.charCodeAt(index);
103
+ first ^= code;
104
+ first = Math.imul(first, 0x01000193);
105
+ second ^= code + index;
106
+ second = Math.imul(second, 0x85ebca6b);
107
+ }
108
+ return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
109
+ .toString(16)
110
+ .padStart(8, "0")}`;
111
+ }
112
+
113
+ export function blocksFieldId(documentId: string, propertyId: string): string {
114
+ return `blocks_field_${hashString(`${documentId}\0${propertyId}`)}`;
115
+ }
116
+
117
+ export function blocksContentHash(markdown: string): string {
118
+ return `nfm_${hashString(markdown)}`;
119
+ }
120
+
121
+ function stableNodeValue(value: unknown): unknown {
122
+ if (Array.isArray(value)) return value.map(stableNodeValue);
123
+ if (!value || typeof value !== "object") return value;
124
+ return Object.fromEntries(
125
+ Object.entries(value as Record<string, unknown>)
126
+ .filter(([key]) => key !== "blockId")
127
+ .sort(([left], [right]) => left.localeCompare(right))
128
+ .map(([key, child]) => [key, stableNodeValue(child)]),
129
+ );
130
+ }
131
+
132
+ function nodeMarkdown(node: PMNode): string {
133
+ return docToNfm({ type: "doc", content: [node] });
134
+ }
135
+
136
+ function snapshotMarkdown(markdown: string): BlockSnapshot[] {
137
+ const snapshots: BlockSnapshot[] = [];
138
+ const doc = nfmToDoc(markdown);
139
+
140
+ function visit(nodes: PMNode[] | undefined, parentPath: string | null) {
141
+ let blockPosition = 0;
142
+ for (let index = 0; index < (nodes?.length ?? 0); index++) {
143
+ const node = nodes![index]!;
144
+ if (!BLOCK_NODE_TYPES.has(node.type)) continue;
145
+ const path =
146
+ parentPath === null
147
+ ? `${blockPosition}`
148
+ : `${parentPath}.${blockPosition}`;
149
+ const canonical = JSON.stringify(stableNodeValue(node));
150
+ snapshots.push({
151
+ path,
152
+ parentPath,
153
+ kind: node.type,
154
+ position: blockPosition,
155
+ addressable: !NON_ADDRESSABLE_NODE_TYPES.has(node.type),
156
+ contentHash: `block_${hashString(canonical)}`,
157
+ markdown: nodeMarkdown(node),
158
+ preferredId:
159
+ node.type === "registryBlock" &&
160
+ typeof node.attrs?.blockId === "string"
161
+ ? node.attrs.blockId
162
+ : null,
163
+ });
164
+ visit(node.content, path);
165
+ blockPosition++;
166
+ }
167
+ }
168
+
169
+ visit(doc.content, null);
170
+ return snapshots;
171
+ }
172
+
173
+ function deterministicBlockId(
174
+ fieldId: string,
175
+ snapshot: Pick<BlockSnapshot, "path" | "kind" | "contentHash">,
176
+ ): string {
177
+ return `block_${hashString(
178
+ `${fieldId}\0${snapshot.path}\0${snapshot.kind}\0${snapshot.contentHash}`,
179
+ )}`;
180
+ }
181
+
182
+ function fieldScopedBlockId(fieldId: string, candidateId: string): string {
183
+ return `block_${hashString(fieldId)}_${hashString(candidateId)}`;
184
+ }
185
+
186
+ function publicIdentity(
187
+ stored: StoredBlocksFieldIdentity,
188
+ identityStatus: BlocksFieldIdentityStatus,
189
+ ): BlocksFieldIdentity {
190
+ return {
191
+ version: BLOCKS_FIELD_IDENTITY_VERSION,
192
+ fieldId: stored.fieldId,
193
+ revision: stored.revision,
194
+ contentHash: stored.contentHash,
195
+ identityStatus,
196
+ blocks: stored.blocks
197
+ .filter((block) => block.state === "live")
198
+ .map(({ id, parentId, kind, position, addressable }) => ({
199
+ id,
200
+ parentId,
201
+ kind,
202
+ position,
203
+ addressable,
204
+ })),
205
+ tombstones: stored.blocks
206
+ .filter(
207
+ (
208
+ block,
209
+ ): block is StoredBlocksFieldBlock & {
210
+ deletedAtRevision: number;
211
+ } => block.state === "deleted" && block.deletedAtRevision !== null,
212
+ )
213
+ .map(({ id, kind, deletedAtRevision }) => ({
214
+ id,
215
+ kind,
216
+ deletedAtRevision,
217
+ })),
218
+ recoveryMode: "editor-undo-with-tombstones",
219
+ };
220
+ }
221
+
222
+ export function legacyBlocksFieldIdentity(args: {
223
+ documentId: string;
224
+ propertyId: string;
225
+ markdown: string;
226
+ }): BlocksFieldIdentity {
227
+ const fieldId = blocksFieldId(args.documentId, args.propertyId);
228
+ const snapshots = snapshotMarkdown(args.markdown);
229
+ const idByPath = new Map<string, string>();
230
+ const usedIds = new Set<string>();
231
+ const blocks: StoredBlocksFieldBlock[] = snapshots.map((snapshot) => {
232
+ const preferred = snapshot.preferredId
233
+ ? fieldScopedBlockId(fieldId, snapshot.preferredId)
234
+ : null;
235
+ const id =
236
+ preferred && !usedIds.has(preferred)
237
+ ? preferred
238
+ : deterministicBlockId(fieldId, snapshot);
239
+ usedIds.add(id);
240
+ idByPath.set(snapshot.path, id);
241
+ return {
242
+ id,
243
+ parentId: snapshot.parentPath
244
+ ? (idByPath.get(snapshot.parentPath) ?? null)
245
+ : null,
246
+ kind: snapshot.kind,
247
+ position: snapshot.position,
248
+ addressable: snapshot.addressable,
249
+ contentHash: snapshot.contentHash,
250
+ markdown: snapshot.markdown,
251
+ state: "live",
252
+ deletedAtRevision: null,
253
+ recoveredAtRevision: null,
254
+ };
255
+ });
256
+ return publicIdentity(
257
+ {
258
+ fieldId,
259
+ revision: 0,
260
+ contentHash: blocksContentHash(args.markdown),
261
+ blocks,
262
+ },
263
+ "legacy",
264
+ );
265
+ }
266
+
267
+ function uniqueIndexByKey<T>(
268
+ values: T[],
269
+ key: (value: T) => string,
270
+ ): Map<string, number> {
271
+ const counts = new Map<string, number>();
272
+ const index = new Map<string, number>();
273
+ values.forEach((value, valueIndex) => {
274
+ const valueKey = key(value);
275
+ counts.set(valueKey, (counts.get(valueKey) ?? 0) + 1);
276
+ index.set(valueKey, valueIndex);
277
+ });
278
+ for (const [valueKey, count] of counts) {
279
+ if (count !== 1) index.delete(valueKey);
280
+ }
281
+ return index;
282
+ }
283
+
284
+ function characterBigrams(value: string): Set<string> {
285
+ const normalized = value.toLocaleLowerCase().replace(/\s+/g, " ").trim();
286
+ if (normalized.length < 2) return new Set(normalized ? [normalized] : []);
287
+ const result = new Set<string>();
288
+ for (let index = 0; index < normalized.length - 1; index++) {
289
+ result.add(normalized.slice(index, index + 2));
290
+ }
291
+ return result;
292
+ }
293
+
294
+ function editSimilarity(left: string, right: string): number {
295
+ const leftBigrams = characterBigrams(left);
296
+ const rightBigrams = characterBigrams(right);
297
+ if (leftBigrams.size === 0 || rightBigrams.size === 0) return 0;
298
+ let shared = 0;
299
+ for (const bigram of leftBigrams) {
300
+ if (rightBigrams.has(bigram)) shared++;
301
+ }
302
+ return (2 * shared) / (leftBigrams.size + rightBigrams.size);
303
+ }
304
+
305
+ export function reconcileBlocksFieldIdentity(args: {
306
+ documentId: string;
307
+ propertyId: string;
308
+ previous: StoredBlocksFieldIdentity;
309
+ markdown: string;
310
+ createId: () => string;
311
+ }): StoredBlocksFieldIdentity {
312
+ const nextRevision = args.previous.revision + 1;
313
+ const snapshots = snapshotMarkdown(args.markdown);
314
+ const previousLive = args.previous.blocks.filter(
315
+ (block) => block.state === "live",
316
+ );
317
+ const previousDeleted = args.previous.blocks.filter(
318
+ (block) => block.state === "deleted",
319
+ );
320
+ const matchedPrevious = new Set<number>();
321
+ const assigned = new Map<number, StoredBlocksFieldBlock>();
322
+ const usedIds = new Set(args.previous.blocks.map((block) => block.id));
323
+
324
+ const previousExact = uniqueIndexByKey(
325
+ previousLive,
326
+ (block) => `${block.kind}\0${block.contentHash}`,
327
+ );
328
+ const nextExact = uniqueIndexByKey(
329
+ snapshots,
330
+ (snapshot) => `${snapshot.kind}\0${snapshot.contentHash}`,
331
+ );
332
+ for (const [key, previousIndex] of previousExact) {
333
+ const nextIndex = nextExact.get(key);
334
+ if (nextIndex === undefined) continue;
335
+ matchedPrevious.add(previousIndex);
336
+ assigned.set(nextIndex, previousLive[previousIndex]!);
337
+ }
338
+
339
+ // Split keeps the leading fragment's ID; merge keeps the receiving block's
340
+ // ID. These are the only cardinality-changing cases where position conveys
341
+ // more identity than text similarity.
342
+ const unmatchedKinds = new Set([
343
+ ...previousLive
344
+ .filter((_block, index) => !matchedPrevious.has(index))
345
+ .map((block) => block.kind),
346
+ ...snapshots
347
+ .filter((_snapshot, index) => !assigned.has(index))
348
+ .map((snapshot) => snapshot.kind),
349
+ ]);
350
+ for (const kind of unmatchedKinds) {
351
+ const previousIndexes = previousLive.flatMap((block, index) =>
352
+ !matchedPrevious.has(index) && block.kind === kind ? [index] : [],
353
+ );
354
+ const nextIndexes = snapshots.flatMap((snapshot, index) =>
355
+ !assigned.has(index) && snapshot.kind === kind ? [index] : [],
356
+ );
357
+ if (previousIndexes.length !== 1 && nextIndexes.length !== 1) {
358
+ continue;
359
+ }
360
+ if (previousIndexes.length === 0 || nextIndexes.length === 0) continue;
361
+ const samePosition = nextIndexes.find(
362
+ (nextIndex) =>
363
+ snapshots[nextIndex]!.position ===
364
+ previousLive[previousIndexes[0]!]!.position,
365
+ );
366
+ if (samePosition === undefined) continue;
367
+ matchedPrevious.add(previousIndexes[0]!);
368
+ assigned.set(samePosition, previousLive[previousIndexes[0]!]!);
369
+ }
370
+
371
+ // A reorder and a text edit can happen in one editor transaction. Pair only
372
+ // mutual, sufficiently similar best matches before considering position; an
373
+ // ambiguous match is left for the conservative positional rule below.
374
+ const bestNextForPrevious = new Map<
375
+ number,
376
+ { index: number; score: number }
377
+ >();
378
+ const bestPreviousForNext = new Map<
379
+ number,
380
+ { index: number; score: number }
381
+ >();
382
+ for (
383
+ let previousIndex = 0;
384
+ previousIndex < previousLive.length;
385
+ previousIndex++
386
+ ) {
387
+ if (matchedPrevious.has(previousIndex)) continue;
388
+ const previous = previousLive[previousIndex]!;
389
+ for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) {
390
+ if (assigned.has(nextIndex)) continue;
391
+ const snapshot = snapshots[nextIndex]!;
392
+ if (previous.kind !== snapshot.kind) continue;
393
+ const score = editSimilarity(previous.markdown, snapshot.markdown);
394
+ if (score < 0.45) continue;
395
+ const previousBest = bestNextForPrevious.get(previousIndex);
396
+ if (!previousBest || score > previousBest.score) {
397
+ bestNextForPrevious.set(previousIndex, { index: nextIndex, score });
398
+ }
399
+ const nextBest = bestPreviousForNext.get(nextIndex);
400
+ if (!nextBest || score > nextBest.score) {
401
+ bestPreviousForNext.set(nextIndex, { index: previousIndex, score });
402
+ }
403
+ }
404
+ }
405
+ for (const [previousIndex, nextBest] of bestNextForPrevious) {
406
+ const previousBest = bestPreviousForNext.get(nextBest.index);
407
+ if (previousBest?.index !== previousIndex) continue;
408
+ matchedPrevious.add(previousIndex);
409
+ assigned.set(nextBest.index, previousLive[previousIndex]!);
410
+ }
411
+
412
+ for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) {
413
+ if (assigned.has(nextIndex)) continue;
414
+ const snapshot = snapshots[nextIndex]!;
415
+ const samePosition = previousLive.findIndex(
416
+ (block, previousIndex) =>
417
+ !matchedPrevious.has(previousIndex) &&
418
+ block.kind === snapshot.kind &&
419
+ block.position === snapshot.position,
420
+ );
421
+ if (samePosition !== -1) {
422
+ matchedPrevious.add(samePosition);
423
+ assigned.set(nextIndex, previousLive[samePosition]!);
424
+ continue;
425
+ }
426
+ const sameKind = previousLive.findIndex(
427
+ (block, previousIndex) =>
428
+ !matchedPrevious.has(previousIndex) && block.kind === snapshot.kind,
429
+ );
430
+ if (sameKind !== -1) {
431
+ matchedPrevious.add(sameKind);
432
+ assigned.set(nextIndex, previousLive[sameKind]!);
433
+ }
434
+ }
435
+
436
+ const recoverable = uniqueIndexByKey(
437
+ previousDeleted,
438
+ (block) => `${block.kind}\0${block.contentHash}`,
439
+ );
440
+ const recoveredIds = new Set<string>();
441
+ const idByPath = new Map<string, string>();
442
+ const nextBlocks = snapshots.map((snapshot, nextIndex) => {
443
+ let previous = assigned.get(nextIndex);
444
+ if (!previous) {
445
+ const recoveredIndex = recoverable.get(
446
+ `${snapshot.kind}\0${snapshot.contentHash}`,
447
+ );
448
+ if (recoveredIndex !== undefined) {
449
+ previous = previousDeleted[recoveredIndex];
450
+ if (previous) {
451
+ recoveredIds.add(previous.id);
452
+ recoverable.delete(`${snapshot.kind}\0${snapshot.contentHash}`);
453
+ }
454
+ }
455
+ }
456
+ let id =
457
+ previous?.id ??
458
+ fieldScopedBlockId(
459
+ args.previous.fieldId,
460
+ snapshot.preferredId ?? args.createId(),
461
+ );
462
+ while (usedIds.has(id) && id !== previous?.id) {
463
+ id = fieldScopedBlockId(args.previous.fieldId, args.createId());
464
+ }
465
+ usedIds.add(id);
466
+ idByPath.set(snapshot.path, id);
467
+ return {
468
+ id,
469
+ parentId: snapshot.parentPath
470
+ ? (idByPath.get(snapshot.parentPath) ?? null)
471
+ : null,
472
+ kind: snapshot.kind,
473
+ position: snapshot.position,
474
+ addressable: snapshot.addressable,
475
+ contentHash: snapshot.contentHash,
476
+ markdown: snapshot.markdown,
477
+ state: "live" as const,
478
+ deletedAtRevision: null,
479
+ recoveredAtRevision: recoveredIds.has(id) ? nextRevision : null,
480
+ };
481
+ });
482
+
483
+ const deletedNow = previousLive
484
+ .filter((_block, index) => !matchedPrevious.has(index))
485
+ .map((block) => ({
486
+ ...block,
487
+ state: "deleted" as const,
488
+ deletedAtRevision: nextRevision,
489
+ recoveredAtRevision: null,
490
+ }));
491
+ const oldestRecoverableRevision =
492
+ nextRevision - BLOCK_TOMBSTONE_REVISION_WINDOW;
493
+ const retainedTombstones = [...previousDeleted, ...deletedNow]
494
+ .filter(
495
+ (block) =>
496
+ !recoveredIds.has(block.id) &&
497
+ block.deletedAtRevision !== null &&
498
+ block.deletedAtRevision > oldestRecoverableRevision,
499
+ )
500
+ .sort(
501
+ (left, right) =>
502
+ (right.deletedAtRevision ?? 0) - (left.deletedAtRevision ?? 0),
503
+ )
504
+ .slice(0, MAX_BLOCK_TOMBSTONES_PER_FIELD);
505
+
506
+ return {
507
+ fieldId: args.previous.fieldId,
508
+ revision: nextRevision,
509
+ contentHash: blocksContentHash(args.markdown),
510
+ blocks: [...nextBlocks, ...retainedTombstones],
511
+ };
512
+ }
513
+
514
+ export function exposeBlocksFieldIdentity(
515
+ stored: StoredBlocksFieldIdentity,
516
+ markdown: string,
517
+ ): BlocksFieldIdentity {
518
+ const currentHash = blocksContentHash(markdown);
519
+ if (currentHash === stored.contentHash) {
520
+ return publicIdentity(stored, "materialized");
521
+ }
522
+ let provisionalIndex = 0;
523
+ const reconciled = reconcileBlocksFieldIdentity({
524
+ documentId: "stale-read",
525
+ propertyId: stored.fieldId,
526
+ previous: stored,
527
+ markdown,
528
+ createId: () =>
529
+ `block_${hashString(`${stored.fieldId}\0${markdown}\0${provisionalIndex++}`)}`,
530
+ });
531
+ return publicIdentity({ ...reconciled, revision: stored.revision }, "stale");
532
+ }
533
+
534
+ export function materializeLegacyBlocksFieldIdentity(args: {
535
+ documentId: string;
536
+ propertyId: string;
537
+ markdown: string;
538
+ }): StoredBlocksFieldIdentity {
539
+ const publicState = legacyBlocksFieldIdentity(args);
540
+ const snapshots = snapshotMarkdown(args.markdown);
541
+ return {
542
+ fieldId: publicState.fieldId,
543
+ revision: 0,
544
+ contentHash: publicState.contentHash,
545
+ blocks: publicState.blocks.map((block, index) => ({
546
+ ...block,
547
+ contentHash: snapshots[index]!.contentHash,
548
+ markdown: snapshots[index]!.markdown,
549
+ state: "live",
550
+ deletedAtRevision: null,
551
+ recoveredAtRevision: null,
552
+ })),
553
+ };
554
+ }
@@ -1,3 +1,4 @@
1
+ import type { BlocksFieldIdentity } from "./blocks-field-identity.js";
1
2
  import { matchInlineMathAt } from "./inline-math.js";
2
3
  import { KATEX_STYLESHEET_URL, renderMathToHtml } from "./math-rendering.js";
3
4
 
@@ -9,6 +10,16 @@ export interface DocumentExportInput {
9
10
  content?: string | null;
10
11
  updatedAt?: string | null;
11
12
  format: DocumentExportFormat;
13
+ blocksFields?: BlocksFieldExport[];
14
+ }
15
+
16
+ export interface BlocksFieldExport {
17
+ databaseId: string;
18
+ propertyId: string;
19
+ name: string;
20
+ position: number;
21
+ markdown: string;
22
+ identity: BlocksFieldIdentity;
12
23
  }
13
24
 
14
25
  export interface DocumentExportPayload {
@@ -19,6 +30,15 @@ export interface DocumentExportPayload {
19
30
  mimeType: string;
20
31
  content: string;
21
32
  print: boolean;
33
+ blocksFields?: BlocksFieldExport[];
34
+ }
35
+
36
+ function serializeBlocksFieldsManifest(fields: BlocksFieldExport[]): string {
37
+ return JSON.stringify({ version: 1, fields })
38
+ .replace(/</g, "\\u003c")
39
+ .replace(/>/g, "\\u003e")
40
+ .replace(/&/g, "\\u0026")
41
+ .replace(/--/g, "\\u002d\\u002d");
22
42
  }
23
43
 
24
44
  const EXTENSION_BY_FORMAT: Record<DocumentExportFormat, string> = {
@@ -638,7 +658,10 @@ export function buildDocumentExport(
638
658
  const filename = exportFilename(title, input.format);
639
659
  const markdown = markdownWithTitle(title, input.content);
640
660
  const isHtmlLike = input.format === "html" || input.format === "pdf";
641
- const content = isHtmlLike
661
+ const manifest = input.blocksFields?.length
662
+ ? serializeBlocksFieldsManifest(input.blocksFields)
663
+ : null;
664
+ let content = isHtmlLike
642
665
  ? buildHtmlDocument({
643
666
  title,
644
667
  content: input.content ?? "",
@@ -646,6 +669,14 @@ export function buildDocumentExport(
646
669
  print: input.format === "pdf",
647
670
  })
648
671
  : markdown;
672
+ if (manifest) {
673
+ content = isHtmlLike
674
+ ? content.replace(
675
+ "</body>",
676
+ `<script type="application/json" id="agent-native-blocks">${manifest}</script>\n</body>`,
677
+ )
678
+ : `${content.trimEnd()}\n\n<!-- agent-native-blocks:${manifest} -->\n`;
679
+ }
649
680
 
650
681
  return {
651
682
  id: input.id,
@@ -655,5 +686,6 @@ export function buildDocumentExport(
655
686
  mimeType: MIME_BY_FORMAT[input.format],
656
687
  content,
657
688
  print: input.format === "pdf",
689
+ ...(input.blocksFields?.length ? { blocksFields: input.blocksFields } : {}),
658
690
  };
659
691
  }
package/dist/action.d.ts CHANGED
@@ -135,6 +135,28 @@ export interface AgentActionStopOptions {
135
135
  /** Optional short tool-result text. Defaults to the user-facing message. */
136
136
  toolResult?: string;
137
137
  }
138
+ export interface ActionContractErrorOptions {
139
+ /** Stable machine-readable code safe to expose on every action transport. */
140
+ errorCode: string;
141
+ /** Safe structured context for callers. Never include secrets or raw driver errors. */
142
+ details?: Record<string, unknown>;
143
+ /** HTTP status for the action route. Contract conflicts normally use 409. */
144
+ statusCode?: number;
145
+ }
146
+ /**
147
+ * A deterministic, caller-correctable action contract failure.
148
+ *
149
+ * Unlike an ordinary Error, its code and explicitly safe details survive the
150
+ * framework HTTP transport. Internal failures remain generic 500 responses.
151
+ */
152
+ export declare class ActionContractError extends Error {
153
+ readonly actionContractError = true;
154
+ readonly errorCode: string;
155
+ readonly details?: Record<string, unknown>;
156
+ readonly statusCode: number;
157
+ constructor(message: string, options: ActionContractErrorOptions);
158
+ }
159
+ export declare function isActionContractError(error: unknown): error is ActionContractError;
138
160
  /**
139
161
  * Throw from an action when the agent should stop the current turn instead of
140
162
  * feeding the failure back to the model for another retry.
package/dist/action.js CHANGED
@@ -1,5 +1,32 @@
1
1
  import { normalizeActionChatUIConfig, } from "./action-ui.js";
2
2
  import { normalizeAuditConfig, resolveAuditAttach } from "./audit/config.js";
3
+ /**
4
+ * A deterministic, caller-correctable action contract failure.
5
+ *
6
+ * Unlike an ordinary Error, its code and explicitly safe details survive the
7
+ * framework HTTP transport. Internal failures remain generic 500 responses.
8
+ */
9
+ export class ActionContractError extends Error {
10
+ actionContractError = true;
11
+ errorCode;
12
+ details;
13
+ statusCode;
14
+ constructor(message, options) {
15
+ super(message);
16
+ this.name = "ActionContractError";
17
+ this.errorCode = options.errorCode;
18
+ this.details = options.details;
19
+ this.statusCode = options.statusCode ?? 409;
20
+ }
21
+ }
22
+ export function isActionContractError(error) {
23
+ return (error instanceof ActionContractError ||
24
+ (!!error &&
25
+ typeof error === "object" &&
26
+ error.actionContractError ===
27
+ true &&
28
+ typeof error.errorCode === "string"));
29
+ }
3
30
  /**
4
31
  * Throw from an action when the agent should stop the current turn instead of
5
32
  * feeding the failure back to the model for another retry.
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  states: {
66
67
  clientId: number;
67
68
  state: string;
68
69
  }[];
69
- error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
+ error?: undefined;
80
81
  users: {
81
82
  clientId: number;
82
83
  lastSeen: number;
83
84
  }[];
84
- error?: undefined;
85
85
  }>>;