@agent-native/core 0.175.3 → 0.175.4-nightly-20260826190205

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 (36) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/server/plugins/agent-chat.ts +3 -0
  3. package/corpus/templates/content/actions/_linked-local-document-edit.ts +167 -0
  4. package/corpus/templates/content/actions/edit-document.ts +181 -61
  5. package/corpus/templates/content/actions/import-content-source.ts +33 -1
  6. package/corpus/templates/content/actions/update-document.ts +2 -0
  7. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +68 -0
  8. package/corpus/templates/content/app/components/editor/LinkedLocalDocumentAgentBridge.tsx +245 -0
  9. package/corpus/templates/content/app/lib/local-content-source-files.ts +96 -9
  10. package/corpus/templates/content/shared/api.ts +2 -0
  11. package/corpus/templates/content/shared/content-source.ts +14 -0
  12. package/corpus/templates/content/shared/document-text-edits.ts +39 -0
  13. package/corpus/templates/content/shared/nfm.ts +347 -1
  14. package/corpus/templates/slides/server/plugins/agent-chat.ts +2 -0
  15. package/dist/agent/engine/ai-sdk-engine.js +14 -4
  16. package/dist/agent/engine/anthropic-engine.js +6 -4
  17. package/dist/agent/engine/builder-engine.js +11 -4
  18. package/dist/agent/engine/tool-name.d.ts +9 -0
  19. package/dist/agent/engine/tool-name.js +56 -0
  20. package/dist/agent/engine/translate-ai-sdk.d.ts +5 -3
  21. package/dist/agent/engine/translate-ai-sdk.js +16 -13
  22. package/dist/agent/engine/translate-anthropic.d.ts +8 -6
  23. package/dist/agent/engine/translate-anthropic.js +21 -22
  24. package/dist/agent/production-agent.js +23 -5
  25. package/dist/collab/routes.d.ts +1 -1
  26. package/dist/collab/struct-routes.d.ts +1 -1
  27. package/dist/extensions/fetch-tool.js +73 -5
  28. package/dist/extensions/proxy-security.d.ts +5 -0
  29. package/dist/extensions/proxy-security.js +21 -13
  30. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  31. package/dist/notifications/routes.d.ts +3 -3
  32. package/dist/server/agent-chat/context-tools.js +5 -0
  33. package/dist/server/credential-provider.js +47 -10
  34. package/dist/server/realtime-token.d.ts +1 -1
  35. package/docs/content/webmcp.mdx +5 -5
  36. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8620
34
+ - template files: 8623
@@ -49,6 +49,9 @@ export default createAgentChatPlugin({
49
49
  `<clips-transcript-guidance>
50
50
  The transcript in view-screen and get-recording-player-data are bounded previews when called by the agent. When previewTruncated is true, the text is expected to end mid-sentence and is never evidence that transcription stopped early. Use the bounded payload for a concise summary and do not request or reconstruct the omitted transcript by searching other recordings. For a failed or pending transcript, use request-transcript with force=true. For an explicit fresh retry of an existing ready transcript, also pass regenerate=true. Agent-triggered retries are queued for the durable worker and return pending while processing.
51
51
  </clips-transcript-guidance>
52
+ <clips-agent-routing>
53
+ For summaries, action-item lists, follow-up lists, and todo lists based on the recording in view, use Clips' recording/transcript actions and answer directly in this chat. Do not call Plan or another app for a generic written list. Only delegate to Plan when the user explicitly asks for a visual or structured Agent-Native Plan, a Plan artifact, or the Plans app.
54
+ </clips-agent-routing>
52
55
  <clips-recording-discovery>
53
56
  Clips public recordings are unlisted-by-link, not a searchable public catalog. Treat a recording as known only when it is owned by the current user, already appears in the current screen, or the current user has already viewed it. Do not use list-recordings or search-recordings to discover someone else's clips, recover from a failed direct lookup, answer a date/time question, or search by a title that was not supplied by the user. A question such as when this clip was created refers to the clip already in context; answer it from current-screen or get-recording-player-data. If a direct recording lookup fails, report the failure and stop instead of broadening the search. Only inspect another clip when the user provides its share URL or recording id in the request.
54
57
  </clips-recording-discovery>`,
@@ -0,0 +1,167 @@
1
+ import {
2
+ callBrowserSession,
3
+ listBrowserSessions,
4
+ } from "@agent-native/core/server";
5
+
6
+ import type { DocumentTextEdit } from "../shared/document-text-edits.js";
7
+
8
+ export const linkedLocalDocumentEditActionName = (documentId: string) =>
9
+ `content-edit-linked-document:${documentId}`;
10
+
11
+ type LinkedLocalEditReceipt =
12
+ | {
13
+ status: "persisted";
14
+ content: string;
15
+ title: string;
16
+ path: string;
17
+ runtime: "browser" | "desktop";
18
+ revision?: string;
19
+ }
20
+ | {
21
+ status: "source-persisted/history-pending";
22
+ content: string;
23
+ title: string;
24
+ description: string;
25
+ metadata: {
26
+ parentId: string | null;
27
+ icon: string | null;
28
+ position: number;
29
+ isFavorite: boolean;
30
+ hideFromSearch: boolean;
31
+ visibility: "private" | "org" | "public";
32
+ };
33
+ path: string;
34
+ runtime: "browser" | "desktop";
35
+ revision?: string;
36
+ }
37
+ | {
38
+ status: "source-persisted/readback-pending";
39
+ content: string;
40
+ title: string;
41
+ path: string;
42
+ runtime: "browser" | "desktop";
43
+ revision?: string;
44
+ }
45
+ | {
46
+ status: "conflict" | "unavailable" | "failed";
47
+ error: string;
48
+ };
49
+
50
+ function isReceipt(value: unknown): value is LinkedLocalEditReceipt {
51
+ if (!value || typeof value !== "object") return false;
52
+ const receipt = value as Record<string, unknown>;
53
+ if (
54
+ receipt.status === "persisted" ||
55
+ receipt.status === "source-persisted/history-pending" ||
56
+ receipt.status === "source-persisted/readback-pending"
57
+ ) {
58
+ const hasPersistedFields =
59
+ typeof receipt.content === "string" &&
60
+ typeof receipt.title === "string" &&
61
+ receipt.title.length > 0 &&
62
+ typeof receipt.path === "string" &&
63
+ receipt.path.length > 0 &&
64
+ (receipt.runtime === "browser" || receipt.runtime === "desktop") &&
65
+ (receipt.revision === undefined ||
66
+ (typeof receipt.revision === "string" && receipt.revision.length > 0));
67
+ if (!hasPersistedFields) return false;
68
+ if (receipt.status !== "source-persisted/history-pending") return true;
69
+ const metadata = receipt.metadata as Record<string, unknown> | undefined;
70
+ return (
71
+ typeof receipt.description === "string" &&
72
+ !!metadata &&
73
+ (metadata.parentId === null || typeof metadata.parentId === "string") &&
74
+ (metadata.icon === null || typeof metadata.icon === "string") &&
75
+ typeof metadata.position === "number" &&
76
+ typeof metadata.isFavorite === "boolean" &&
77
+ typeof metadata.hideFromSearch === "boolean" &&
78
+ (metadata.visibility === "private" ||
79
+ metadata.visibility === "org" ||
80
+ metadata.visibility === "public")
81
+ );
82
+ }
83
+ return (
84
+ (receipt.status === "conflict" ||
85
+ receipt.status === "unavailable" ||
86
+ receipt.status === "failed") &&
87
+ typeof receipt.error === "string" &&
88
+ receipt.error.length > 0
89
+ );
90
+ }
91
+
92
+ export async function editLinkedLocalDocumentThroughBrowser(args: {
93
+ ownerEmail: string;
94
+ documentId: string;
95
+ expectedContent: string;
96
+ expectedTitle: string;
97
+ expectedDescription: string;
98
+ expectedMetadata: string;
99
+ expectedResultContent: string;
100
+ edits: DocumentTextEdit[];
101
+ }): Promise<LinkedLocalEditReceipt> {
102
+ const name = linkedLocalDocumentEditActionName(args.documentId);
103
+ const sessions = await listBrowserSessions(args.ownerEmail, { limit: 100 });
104
+ const matches = sessions.filter((session) =>
105
+ session.actions.some((action) => action.name === name),
106
+ );
107
+ if (matches.length === 0) {
108
+ return {
109
+ status: "unavailable",
110
+ error:
111
+ "Open this linked document in a browser with its local source folder connected, then retry the edit.",
112
+ };
113
+ }
114
+ if (matches.length > 1) {
115
+ return {
116
+ status: "conflict",
117
+ error:
118
+ "This linked document is open in more than one writable browser session. Close the extra tab and retry.",
119
+ };
120
+ }
121
+
122
+ try {
123
+ const result = await callBrowserSession(
124
+ args.ownerEmail,
125
+ matches[0]!.sessionId,
126
+ {
127
+ type: "run-action",
128
+ name,
129
+ args: {
130
+ documentId: args.documentId,
131
+ expectedContent: args.expectedContent,
132
+ expectedTitle: args.expectedTitle,
133
+ expectedDescription: args.expectedDescription,
134
+ expectedMetadata: args.expectedMetadata,
135
+ edits: args.edits,
136
+ },
137
+ timeoutMs: 30_000,
138
+ },
139
+ { timeoutMs: 30_000 },
140
+ );
141
+ if (!isReceipt(result)) {
142
+ return {
143
+ status: "failed",
144
+ error: "The local source returned an invalid receipt.",
145
+ };
146
+ }
147
+ if (
148
+ (result.status === "persisted" ||
149
+ result.status === "source-persisted/readback-pending") &&
150
+ result.content !== args.expectedResultContent
151
+ ) {
152
+ return {
153
+ status: "conflict",
154
+ error: "The local source receipt did not match the requested edit.",
155
+ };
156
+ }
157
+ return result;
158
+ } catch (error) {
159
+ return {
160
+ status: "failed",
161
+ error:
162
+ error instanceof Error
163
+ ? error.message
164
+ : "The local source write failed.",
165
+ };
166
+ }
167
+ }
@@ -5,6 +5,7 @@ import {
5
5
  hasCollabState,
6
6
  searchAndReplace,
7
7
  } from "@agent-native/core/collab";
8
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
8
9
  import { assertAccess } from "@agent-native/core/sharing";
9
10
  import {
10
11
  getGenerationCreativeContext,
@@ -13,14 +14,17 @@ import {
13
14
  validateGenerationCreativeContext,
14
15
  } from "@agent-native/creative-context/server";
15
16
  import type { CreativeContextReuseLabel } from "@agent-native/creative-context/types";
16
- import { eq } from "drizzle-orm";
17
+ import { and, eq } from "drizzle-orm";
17
18
  import { z } from "zod";
18
19
 
19
20
  import { getDb, schema } from "../server/db/index.js";
21
+ import { applyDocumentTextEdits } from "../shared/document-text-edits.js";
22
+ import { inspectNfmFidelity } from "../shared/nfm.js";
20
23
  import {
21
24
  lockPrimaryBlocksFields,
22
25
  persistBlocksFieldIdentity,
23
26
  } from "./_blocks-field-identity.js";
27
+ import { editLinkedLocalDocumentThroughBrowser } from "./_linked-local-document-edit.js";
24
28
 
25
29
  interface TextEdit {
26
30
  find: string;
@@ -110,46 +114,47 @@ export default defineAction({
110
114
 
111
115
  // ─── Apply edits to the document markdown ───────────────────────────────
112
116
  //
113
- // The agent edits the canonical `documents.content` (markdown is the source
114
- // of truth). The change is delivered live to any open editor through the
115
- // framework's normal change-sync: the action bump refetches `get-document`,
116
- // and the editor reconciles the newer content into the live Y.Doc — parsing
117
- // the markdown through the real editor pipeline so new block structure
118
- // (lists, headings, tables) renders correctly and merges with any
119
- // concurrent human edits via the Yjs CRDT. See the `real-time-collab` skill.
117
+ // Native documents edit canonical `documents.content`. A linked local file
118
+ // instead commits through its exact live source bridge before SQL mirrors
119
+ // the accepted bytes. The SQL change is delivered to open editors through
120
+ // normal change-sync and parsed through the real editor pipeline so new
121
+ // block structure renders correctly and merges through Yjs.
120
122
  //
121
123
  // (The old approach POSTed a Yjs search-replace to a localhost collab origin,
122
124
  // which silently no-oped on serverless — different process, no localhost —
123
125
  // and could only patch text inside existing nodes, never create structure.)
124
- let content: string = existing.content ?? "";
125
- const results: string[] = [];
126
- let changeCount = 0;
127
- const appliedEdits: TextEdit[] = [];
128
-
129
- for (const edit of edits) {
130
- const idx = content.indexOf(edit.find);
131
- if (idx === -1) {
132
- results.push(
133
- `NOT FOUND: "${edit.find.slice(0, 60)}${edit.find.length > 60 ? "..." : ""}"`,
134
- );
135
- continue;
136
- }
137
- content =
138
- content.slice(0, idx) +
139
- edit.replace +
140
- content.slice(idx + edit.find.length);
141
- changeCount++;
142
- appliedEdits.push(edit);
143
- const action = edit.replace === "" ? "deleted" : "replaced";
144
- results.push(
145
- `${action}: "${edit.find.slice(0, 40)}${edit.find.length > 40 ? "..." : ""}"`,
146
- );
147
- }
126
+ const applied = applyDocumentTextEdits(existing.content ?? "", edits);
127
+ let { content } = applied;
128
+ const { results, appliedEdits, changeCount } = applied;
148
129
 
149
130
  if (changeCount === 0) {
150
131
  return { applied: 0, total: edits.length, results };
151
132
  }
152
133
 
134
+ let linkedLocalPersistence:
135
+ | {
136
+ status:
137
+ | "persisted"
138
+ | "source-persisted/readback-pending"
139
+ | "source-persisted/history-pending";
140
+ path: string;
141
+ runtime: "browser" | "desktop";
142
+ }
143
+ | undefined;
144
+ let linkedLocalHistoryReconciliation = false;
145
+ let linkedLocalReconciliationDocument:
146
+ | {
147
+ title: string;
148
+ description: string;
149
+ parentId: string | null;
150
+ icon: string | null;
151
+ position: number;
152
+ isFavorite: boolean;
153
+ hideFromSearch: boolean;
154
+ visibility: "private" | "org" | "public";
155
+ }
156
+ | undefined;
157
+
153
158
  const previousGeneration =
154
159
  args.contextModeOverride === "off"
155
160
  ? null
@@ -236,39 +241,146 @@ export default defineAction({
236
241
  };
237
242
  }
238
243
 
244
+ const isLinkedLocalSource =
245
+ existing.sourceMode === "local-files" &&
246
+ existing.sourceKind !== "folder" &&
247
+ Boolean(existing.sourcePath) &&
248
+ !id.startsWith("local-file:") &&
249
+ !id.startsWith("local-folder:");
250
+ if (isLinkedLocalSource) {
251
+ const ownerEmail = getRequestUserEmail();
252
+ if (!ownerEmail) {
253
+ return {
254
+ applied: 0,
255
+ total: edits.length,
256
+ results,
257
+ persistence: "unavailable",
258
+ error:
259
+ "No authenticated user is available for the local source write.",
260
+ };
261
+ }
262
+ const receipt = await editLinkedLocalDocumentThroughBrowser({
263
+ ownerEmail,
264
+ documentId: id,
265
+ expectedContent: existing.content ?? "",
266
+ expectedTitle: existing.title,
267
+ expectedDescription: existing.description ?? "",
268
+ expectedMetadata: JSON.stringify({
269
+ parentId: existing.parentId,
270
+ icon: existing.icon,
271
+ position: existing.position,
272
+ isFavorite: Boolean(existing.isFavorite),
273
+ hideFromSearch: Boolean(existing.hideFromSearch),
274
+ visibility: existing.visibility,
275
+ }),
276
+ expectedResultContent: applied.content,
277
+ edits,
278
+ });
279
+ if (receipt.status === "source-persisted/history-pending") {
280
+ linkedLocalHistoryReconciliation = true;
281
+ linkedLocalReconciliationDocument = {
282
+ title: receipt.title,
283
+ description: receipt.description,
284
+ ...receipt.metadata,
285
+ visibility: existing.visibility,
286
+ };
287
+ }
288
+ if (
289
+ receipt.status !== "persisted" &&
290
+ receipt.status !== "source-persisted/history-pending" &&
291
+ receipt.status !== "source-persisted/readback-pending"
292
+ ) {
293
+ return {
294
+ applied: 0,
295
+ total: edits.length,
296
+ results,
297
+ persistence: receipt.status,
298
+ error: receipt.error,
299
+ };
300
+ }
301
+ content = receipt.content;
302
+ linkedLocalPersistence = {
303
+ status: receipt.status,
304
+ path: receipt.path,
305
+ runtime: receipt.runtime,
306
+ };
307
+ }
308
+
239
309
  // Persist. The fresh updatedAt is the signal the open editor uses to tell an
240
310
  // intentional external edit apart from a stale autosave echo.
241
311
  const db = getDb();
242
312
  const now = new Date().toISOString();
243
- await db.transaction(async (tx: any) => {
244
- const primaryBlocksFields = await lockPrimaryBlocksFields(tx, id);
245
- await tx
246
- .update(schema.documents)
247
- .set({ content, updatedAt: now })
248
- .where(eq(schema.documents.id, id));
249
- for (const field of primaryBlocksFields) {
250
- await persistBlocksFieldIdentity({
251
- db: tx as unknown as ReturnType<typeof getDb>,
252
- ownerEmail: field.ownerEmail,
253
- documentId: id,
254
- propertyId: field.propertyId,
255
- previousMarkdown: existing.content ?? "",
256
- markdown: content,
257
- now,
258
- });
259
- }
260
- if (creativeContext) {
261
- await recordGenerationCreativeContext(
262
- {
263
- appId: "content",
264
- artifactType: "document",
265
- artifactId: id,
266
- ...creativeContext,
267
- },
268
- { db: tx },
269
- );
270
- }
271
- });
313
+ try {
314
+ await db.transaction(async (tx: any) => {
315
+ const primaryBlocksFields = await lockPrimaryBlocksFields(tx, id);
316
+ const mirrored = await tx
317
+ .update(schema.documents)
318
+ .set({
319
+ content,
320
+ updatedAt: now,
321
+ ...(linkedLocalReconciliationDocument ?? {}),
322
+ })
323
+ .where(
324
+ and(
325
+ eq(schema.documents.id, id),
326
+ eq(schema.documents.updatedAt, existing.updatedAt),
327
+ ),
328
+ )
329
+ .returning({ id: schema.documents.id });
330
+ if (mirrored.length !== 1) {
331
+ throw new Error(
332
+ "The document changed before Content history could be updated.",
333
+ );
334
+ }
335
+ for (const field of primaryBlocksFields) {
336
+ await persistBlocksFieldIdentity({
337
+ db: tx as unknown as ReturnType<typeof getDb>,
338
+ ownerEmail: field.ownerEmail,
339
+ documentId: id,
340
+ propertyId: field.propertyId,
341
+ previousMarkdown: existing.content ?? "",
342
+ markdown: content,
343
+ now,
344
+ });
345
+ }
346
+ if (creativeContext) {
347
+ await recordGenerationCreativeContext(
348
+ {
349
+ appId: "content",
350
+ artifactType: "document",
351
+ artifactId: id,
352
+ ...creativeContext,
353
+ },
354
+ { db: tx },
355
+ );
356
+ }
357
+ });
358
+ } catch (error) {
359
+ if (!linkedLocalPersistence) throw error;
360
+ return {
361
+ applied: changeCount,
362
+ total: edits.length,
363
+ results,
364
+ persistence: "source-persisted/history-pending",
365
+ path: linkedLocalPersistence.path,
366
+ error:
367
+ error instanceof Error
368
+ ? error.message
369
+ : "The local file changed, but Content history was not updated.",
370
+ };
371
+ }
372
+
373
+ if (linkedLocalHistoryReconciliation) {
374
+ return {
375
+ applied: 0,
376
+ total: edits.length,
377
+ results,
378
+ persistence: "source-persisted/history-reconciled",
379
+ path: linkedLocalPersistence?.path,
380
+ error:
381
+ "The source changed during verification. Content history was reconciled to the physical file, but the requested agent edit was not confirmed.",
382
+ };
383
+ }
272
384
 
273
385
  // Make the agent edit VISIBLE as a live collaborator. Content's collab doc
274
386
  // binds the TipTap Y.XmlFragment("default"), so a live editing session is
@@ -310,6 +422,14 @@ export default defineAction({
310
422
  applied: changeCount,
311
423
  total: edits.length,
312
424
  results,
425
+ ...(linkedLocalPersistence
426
+ ? {
427
+ persistence: linkedLocalPersistence.status,
428
+ path: linkedLocalPersistence.path,
429
+ runtime: linkedLocalPersistence.runtime,
430
+ }
431
+ : {}),
432
+ contentFidelity: inspectNfmFidelity(content),
313
433
  ...(creativeContext
314
434
  ? {
315
435
  contextMode: creativeContext.contextMode,
@@ -15,6 +15,7 @@ import {
15
15
  parseContentSourceFile,
16
16
  type ParsedContentSourceFile,
17
17
  } from "../shared/content-source.js";
18
+ import { inspectNfmFidelity } from "../shared/nfm.js";
18
19
  import {
19
20
  favoriteDocumentIds,
20
21
  setFavoriteMembership,
@@ -63,6 +64,10 @@ function canEditRole(role: string) {
63
64
  return ROLE_RANK[role as keyof typeof ROLE_RANK] >= ROLE_RANK.editor;
64
65
  }
65
66
 
67
+ function canAdminRole(role: string) {
68
+ return ROLE_RANK[role as keyof typeof ROLE_RANK] >= ROLE_RANK.admin;
69
+ }
70
+
66
71
  function normalizedFileEntries(files: Record<string, string>) {
67
72
  return Object.entries(files)
68
73
  .filter(([filePath]) => isContentSourcePath(filePath))
@@ -214,6 +219,9 @@ export default defineAction({
214
219
  const parsed: ParsedContentSourceFile[] = entries.map(
215
220
  ([filePath, source]) => parseContentSourceFile(filePath, source),
216
221
  );
222
+ const fidelityByPath = new Map(
223
+ parsed.map((file) => [file.path, inspectNfmFidelity(file.content)]),
224
+ );
217
225
  const importPaths = [...new Set(parsed.map((file) => file.path))];
218
226
  const existingLocalDocs =
219
227
  importPaths.length > 0
@@ -292,6 +300,14 @@ export default defineAction({
292
300
  });
293
301
  continue;
294
302
  }
303
+ const fidelity = fidelityByPath.get(file.path);
304
+ if (fidelity?.status === "failed") {
305
+ errors.push({
306
+ path: file.path,
307
+ reason: `Document conversion failed: ${fidelity.error ?? "unreadable content"}.`,
308
+ });
309
+ continue;
310
+ }
295
311
 
296
312
  const sourceMatchedDocument = file.id
297
313
  ? null
@@ -337,6 +353,19 @@ export default defineAction({
337
353
  const discoverabilityChanged =
338
354
  file.hideFromSearch !== undefined &&
339
355
  boolToInt(file.hideFromSearch) !== (existing.hideFromSearch ?? 0);
356
+ const visibilityChanged =
357
+ file.visibility !== undefined &&
358
+ file.visibility !== existing.visibility;
359
+ if (
360
+ visibilityChanged &&
361
+ (!existingRole || !canAdminRole(existingRole))
362
+ ) {
363
+ skipped.push({
364
+ path: file.path,
365
+ reason: `Requires admin access to change visibility on document "${id}".`,
366
+ });
367
+ continue;
368
+ }
340
369
  const sourceUpdates = localSourceFields(file.path, now);
341
370
  const sourceChanged =
342
371
  existing.sourceMode !== sourceUpdates.sourceMode ||
@@ -351,6 +380,7 @@ export default defineAction({
351
380
  iconChanged ||
352
381
  favoriteChanged ||
353
382
  discoverabilityChanged ||
383
+ visibilityChanged ||
354
384
  sourceChanged ||
355
385
  spaceChanged;
356
386
 
@@ -379,6 +409,7 @@ export default defineAction({
379
409
  if (discoverabilityChanged) {
380
410
  updates.hideFromSearch = boolToInt(file.hideFromSearch);
381
411
  }
412
+ if (visibilityChanged) updates.visibility = file.visibility;
382
413
  Object.assign(updates, sourceUpdates);
383
414
 
384
415
  await db
@@ -416,7 +447,7 @@ export default defineAction({
416
447
  isFavorite: boolToInt(file.isFavorite),
417
448
  hideFromSearch: boolToInt(file.hideFromSearch),
418
449
  ...localSourceFields(file.path, now),
419
- visibility: "private",
450
+ visibility: file.visibility ?? "private",
420
451
  createdAt: now,
421
452
  updatedAt: now,
422
453
  });
@@ -544,6 +575,7 @@ export default defineAction({
544
575
  skipped,
545
576
  errors,
546
577
  idByPath: Object.fromEntries(idByPath),
578
+ fidelity: Object.fromEntries(fidelityByPath),
547
579
  };
548
580
  },
549
581
  });
@@ -19,6 +19,7 @@ import {
19
19
  parseDocumentHideFromSearch,
20
20
  } from "../server/lib/documents.js";
21
21
  import type { DocumentUpdateResponse } from "../shared/api.js";
22
+ import { inspectNfmFidelity } from "../shared/nfm.js";
22
23
  import {
23
24
  lockPrimaryBlocksFields,
24
25
  persistBlocksFieldIdentity,
@@ -778,6 +779,7 @@ export default defineAction({
778
779
  canManage: canManageRole(access.role),
779
780
  createdAt: doc.createdAt,
780
781
  updatedAt: doc.updatedAt,
782
+ contentFidelity: inspectNfmFidelity(doc.content),
781
783
  source: serializeDocumentSource(doc),
782
784
  softDeletedDatabaseIds,
783
785
  ...(creativeContext
@@ -105,6 +105,7 @@ import { DocumentEditorSkeleton } from "./DocumentEditorSkeleton";
105
105
  import { DocumentInfoPanel } from "./DocumentInfoPanel";
106
106
  import { DocumentToolbar, type ToolbarBreadcrumbItem } from "./DocumentToolbar";
107
107
  import { EmojiPicker } from "./EmojiPicker";
108
+ import { LinkedLocalDocumentAgentBridge } from "./LinkedLocalDocumentAgentBridge";
108
109
  import {
109
110
  classifyLocalSourceRead,
110
111
  localSourceRevisionForQueuedEdit,
@@ -749,6 +750,13 @@ function DocumentEditorBody({
749
750
  localTitleRef.current = localTitle;
750
751
  const localContentRef = useRef(localContent);
751
752
  localContentRef.current = localContent;
753
+ const getLinkedLocalEditorSnapshot = useCallback(
754
+ () => ({
755
+ title: localTitleRef.current,
756
+ content: localContentRef.current,
757
+ }),
758
+ [],
759
+ );
752
760
  const localSourceWriteErrorShownRef = useRef(false);
753
761
  const documentUpdatedAtRef = useRef<string | null>(
754
762
  document.updatedAt ?? null,
@@ -1267,6 +1275,59 @@ function DocumentEditorBody({
1267
1275
  };
1268
1276
  }, [document.id, document.source, isLinkedLocalSourceDocument]);
1269
1277
 
1278
+ const handleLinkedLocalAgentPersistence = useCallback(
1279
+ (persisted: Document, revision?: DesktopContentFileRevision) => {
1280
+ localSourceRevisionRef.current = revision;
1281
+ localTitleRef.current = persisted.title;
1282
+ localContentRef.current = persisted.content;
1283
+ setLocalTitle(persisted.title);
1284
+ setLocalContent(persisted.content);
1285
+ setLocalContentUpdatedAt(persisted.updatedAt ?? new Date().toISOString());
1286
+ lastSavedTitleRef.current = {
1287
+ title: persisted.title,
1288
+ updatedAt: lastSavedTitleRef.current.updatedAt,
1289
+ };
1290
+ lastSavedContentRef.current = {
1291
+ content: persisted.content,
1292
+ updatedAt: lastSavedContentRef.current.updatedAt,
1293
+ };
1294
+ setLocalFileSyncRevision((revision) => revision + 1);
1295
+ setLocalSourceConflict(null);
1296
+ const sqlUpdatedAt = documentUpdatedAtRef.current;
1297
+ queryClient.setQueriesData(documentQueryFilter(documentId), (old) =>
1298
+ mergeDocumentIntoDocumentCache(old, {
1299
+ ...persisted,
1300
+ updatedAt: sqlUpdatedAt ?? persisted.updatedAt,
1301
+ }),
1302
+ );
1303
+ },
1304
+ [documentId, queryClient],
1305
+ );
1306
+
1307
+ useEffect(() => {
1308
+ if (
1309
+ !isLinkedLocalSourceDocument ||
1310
+ document.title !== localTitleRef.current ||
1311
+ document.content !== localContentRef.current ||
1312
+ !document.updatedAt
1313
+ ) {
1314
+ return;
1315
+ }
1316
+ lastSavedTitleRef.current = {
1317
+ title: document.title,
1318
+ updatedAt: document.updatedAt,
1319
+ };
1320
+ lastSavedContentRef.current = {
1321
+ content: document.content,
1322
+ updatedAt: document.updatedAt,
1323
+ };
1324
+ }, [
1325
+ document.content,
1326
+ document.title,
1327
+ document.updatedAt,
1328
+ isLinkedLocalSourceDocument,
1329
+ ]);
1330
+
1270
1331
  const saveDocumentImmediately = useCallback(
1271
1332
  async (
1272
1333
  title: string,
@@ -2024,6 +2085,13 @@ function DocumentEditorBody({
2024
2085
  registry={contentBlockRegistry}
2025
2086
  ctx={blockRenderContext}
2026
2087
  >
2088
+ {isLinkedLocalSourceDocument && editorCanEdit ? (
2089
+ <LinkedLocalDocumentAgentBridge
2090
+ document={document}
2091
+ getEditorSnapshot={getLinkedLocalEditorSnapshot}
2092
+ onPersisted={handleLinkedLocalAgentPersistence}
2093
+ />
2094
+ ) : null}
2027
2095
  <div
2028
2096
  className="relative flex min-h-0 min-w-0 flex-1"
2029
2097
  data-document-print-root