@gmickel/gno 2.4.0 → 2.6.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 (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -68,6 +68,8 @@ import {
68
68
  getDocumentCapabilities,
69
69
  } from "../../core/document-capabilities";
70
70
  import { EgressAuditService } from "../../core/egress-audit";
71
+ import { MCP_ERRORS } from "../../core/errors";
72
+ import { withWriteLock } from "../../core/file-lock";
71
73
  import {
72
74
  atomicWrite,
73
75
  copyFilePath,
@@ -109,6 +111,18 @@ import {
109
111
  resolveRemoteProjectAffinity,
110
112
  } from "../../core/project-affinity-surface";
111
113
  import { projectRecordEvidenceMetadata } from "../../core/record-metadata";
114
+ import {
115
+ isLeaseBusy,
116
+ localRequestLedger,
117
+ readRequestStatus,
118
+ REQUEST_RECEIPT_HTTP_STATUS,
119
+ type RequestCheckpoint,
120
+ RequestReceiptError,
121
+ type RequestReceiptInfo,
122
+ requestDigest,
123
+ runRequestedWrite,
124
+ validateRequestId,
125
+ } from "../../core/request-receipts";
112
126
  import {
113
127
  retrievalTraceFilters,
114
128
  startRetrievalTraceRequest,
@@ -130,7 +144,7 @@ import {
130
144
  type MetadataPredicate,
131
145
  } from "../../core/typed-metadata";
132
146
  import { validateRelPath } from "../../core/validation";
133
- import { writeLeasePath } from "../../core/write-lease";
147
+ import { DEFAULT_LOCK_WAIT_MS, writeLeasePath } from "../../core/write-lease";
134
148
  import {
135
149
  defaultSyncService,
136
150
  type SyncResult,
@@ -163,8 +177,10 @@ import { exportPublishArtifact } from "../../publish/export-service";
163
177
  import { buildBrowseTree, normalizeBrowsePath } from "../browse-tree";
164
178
  import {
165
179
  classifyResidentCaptureError,
180
+ executeRequestedResidentCapture,
166
181
  executeResidentCapturePlan,
167
182
  planResidentCapture,
183
+ ResidentCapturePlanError,
168
184
  type ResidentCaptureDependencies,
169
185
  } from "../capture-service";
170
186
  import { parseClosedJson } from "../closed-json";
@@ -493,7 +509,10 @@ export interface CreateDocRequestBody {
493
509
  tags?: string[];
494
510
  }
495
511
 
496
- export interface CreateCaptureRequestBody extends PublicCaptureInput {}
512
+ export interface CreateCaptureRequestBody extends PublicCaptureInput {
513
+ /** Opt-in retry identity (see docs/API.md "Request IDs"). */
514
+ requestId?: string;
515
+ }
497
516
 
498
517
  export interface RenameDocRequestBody {
499
518
  name: string;
@@ -546,6 +565,8 @@ export interface UpdateDocRequestBody {
546
565
  expectedModifiedAt?: string;
547
566
  /** Exact document URI when docid is not unique across duplicate content */
548
567
  uri?: string;
568
+ /** Opt-in retry identity (see docs/API.md "Request IDs"). */
569
+ requestId?: string;
549
570
  }
550
571
 
551
572
  export interface CreateEditableCopyRequestBody {
@@ -595,6 +616,16 @@ function errorResponse(
595
616
  );
596
617
  }
597
618
 
619
+ /** Request receipt errors keep their stable code; null for anything else. */
620
+ function requestErrorResponse(error: unknown): Response | null {
621
+ if (!(error instanceof RequestReceiptError)) return null;
622
+ return errorResponse(
623
+ error.code,
624
+ error.message,
625
+ REQUEST_RECEIPT_HTTP_STATUS[error.code]
626
+ );
627
+ }
628
+
598
629
  function parseRestMetadataFilter(
599
630
  value: unknown
600
631
  ):
@@ -3295,9 +3326,87 @@ export async function handleRevealDoc(
3295
3326
  }
3296
3327
  }
3297
3328
 
3329
+ /**
3330
+ * GET /api/requests/:requestId
3331
+ * Local-owner lookup of a capture/remember/document-update request ID.
3332
+ * Returns a content-free pointer, never the stored outcome.
3333
+ */
3334
+ export async function handleRequestStatus(
3335
+ store: SqliteAdapter,
3336
+ requestId: string
3337
+ ): Promise<Response> {
3338
+ try {
3339
+ return jsonResponse(
3340
+ await readRequestStatus({
3341
+ ...localRequestLedger(store.getDbPath()),
3342
+ requestId,
3343
+ })
3344
+ );
3345
+ } catch (error) {
3346
+ return (
3347
+ requestErrorResponse(error) ??
3348
+ errorResponse(
3349
+ "RUNTIME",
3350
+ error instanceof Error ? error.message : String(error),
3351
+ 500
3352
+ )
3353
+ );
3354
+ }
3355
+ }
3356
+
3357
+ /** A document update refused before anything was written. */
3358
+ class DocUpdateRejected extends Error {
3359
+ readonly response: Response;
3360
+
3361
+ constructor(response: Response) {
3362
+ super("document update rejected");
3363
+ this.response = response;
3364
+ }
3365
+ }
3366
+
3367
+ /** Private recovery record for one admitted document update. */
3368
+ interface DocUpdatePlan {
3369
+ fullPath: string;
3370
+ collection: string;
3371
+ relPath: string;
3372
+ docid: string;
3373
+ /** Stable identity; row IDs do not survive an index rebuild. */
3374
+ gnoUri: string;
3375
+ baseHash: string;
3376
+ /** Tag set before and after this update (null when tags are unchanged). */
3377
+ baseTagState: string | null;
3378
+ resultTagState: string | null;
3379
+ /** User-source tags before this update: store-only tag writes change them. */
3380
+ baseUserTagState: string | null;
3381
+ /** Null when only store tags change (no file write). */
3382
+ newHash: string | null;
3383
+ indexedSourceHash: string;
3384
+ indexedModifiedAt: string;
3385
+ writeBack?: "applied" | "skipped_unsupported";
3386
+ }
3387
+
3388
+ /** Retained outcome: the response body plus what deferred sync needs. */
3389
+ interface DocUpdateOutcome {
3390
+ body: {
3391
+ success: true;
3392
+ docId: string;
3393
+ uri: string;
3394
+ path: string;
3395
+ jobId: string | null;
3396
+ writeBack?: "applied" | "skipped_unsupported";
3397
+ version: { sourceHash: string; modifiedAt: string };
3398
+ };
3399
+ target: { gnoUri: string; collection: string; relPath: string };
3400
+ /** The file changed, so lexical sync is deferred work. */
3401
+ contentWritten: boolean;
3402
+ }
3403
+
3298
3404
  /**
3299
3405
  * PUT /api/docs/:id
3300
3406
  * Update an existing document's content and/or tags.
3407
+ *
3408
+ * The revision check and file publication run under the shared write lease
3409
+ * (with or without a request ID). Lexical sync and embedding stay deferred.
3301
3410
  */
3302
3411
  export async function handleUpdateDoc(
3303
3412
  ctxHolder: ContextHolder,
@@ -3306,6 +3415,10 @@ export async function handleUpdateDoc(
3306
3415
  req: Request,
3307
3416
  deps?: {
3308
3417
  syncCollection?: typeof defaultSyncService.syncCollection;
3418
+ /** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */
3419
+ lockPath?: string;
3420
+ lockWaitMs?: number;
3421
+ requestCheckpoint?: (stage: RequestCheckpoint) => Promise<void> | void;
3309
3422
  }
3310
3423
  ): Promise<Response> {
3311
3424
  let body: UpdateDocRequestBody;
@@ -3342,6 +3455,15 @@ export async function handleUpdateDoc(
3342
3455
  if (body.uri !== undefined && typeof body.uri !== "string") {
3343
3456
  return errorResponse("VALIDATION", "uri must be a string");
3344
3457
  }
3458
+ let requestId: string | undefined;
3459
+ try {
3460
+ requestId =
3461
+ body.requestId === undefined
3462
+ ? undefined
3463
+ : validateRequestId(body.requestId);
3464
+ } catch (error) {
3465
+ return requestErrorResponse(error) as Response;
3466
+ }
3345
3467
 
3346
3468
  // Validate tags if provided
3347
3469
  let normalizedTags: string[] | undefined;
@@ -3365,176 +3487,349 @@ export async function handleUpdateDoc(
3365
3487
  }
3366
3488
  }
3367
3489
 
3368
- // Get document to verify it exists
3369
- const docResult = await resolveDocumentReference(store, docId, body.uri);
3370
- if (!docResult.ok) {
3371
- return errorResponse("RUNTIME", docResult.error.message, 500);
3372
- }
3373
- if (!docResult.value) {
3374
- return errorResponse("NOT_FOUND", "Document not found", 404);
3375
- }
3376
-
3377
- const doc = docResult.value;
3378
- const capabilities = capabilitiesForDocument(doc);
3379
- if (hasContent && !capabilities.editable) {
3380
- return errorResponse(
3381
- "READ_ONLY",
3382
- capabilities.reason ??
3383
- "This document cannot be edited in place. Create an editable markdown copy instead.",
3384
- 409
3385
- );
3386
- }
3387
-
3388
- const resolvedDocPath = await resolveAbsoluteDocPath(
3389
- ctxHolder.config.collections,
3390
- doc
3391
- );
3392
- if (!resolvedDocPath) {
3393
- return errorResponse(
3394
- "NOT_FOUND",
3395
- `Collection not found: ${doc.collection}`,
3396
- 404
3397
- );
3398
- }
3399
- const { collection, fullPath } = resolvedDocPath;
3490
+ /** Sorted tag set; with `userTags`, the set once those replace the user tags. */
3491
+ const tagState = async (
3492
+ documentId: number,
3493
+ userTags?: string[]
3494
+ ): Promise<string> => {
3495
+ const rows = await store.getTagsForDoc(documentId);
3496
+ if (!rows.ok) throw new Error(rows.error.message);
3497
+ const kept = userTags
3498
+ ? rows.value.filter((row) => row.source !== "user")
3499
+ : rows.value;
3500
+ return [...new Set([...kept.map((row) => row.tag), ...(userTags ?? [])])]
3501
+ .sort()
3502
+ .join("\n");
3503
+ };
3400
3504
 
3401
- // Verify file exists
3402
- const file = Bun.file(fullPath);
3403
- if (!(await file.exists())) {
3404
- return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404);
3405
- }
3505
+ const userTagState = async (documentId: number): Promise<string> => {
3506
+ const rows = await store.getTagsForDoc(documentId);
3507
+ if (!rows.ok) throw new Error(rows.error.message);
3508
+ return rows.value
3509
+ .filter((row) => row.source === "user")
3510
+ .map((row) => row.tag)
3511
+ .sort()
3512
+ .join("\n");
3513
+ };
3406
3514
 
3407
- if (body.expectedSourceHash || body.expectedModifiedAt) {
3408
- const currentBytes = await file.bytes();
3409
- const currentSourceHash = hashContent(
3410
- new TextDecoder().decode(currentBytes)
3411
- );
3515
+ const readModifiedAt = async (path: string): Promise<string> => {
3412
3516
  const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
3413
- const currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
3517
+ return (await stat(path)).mtime.toISOString();
3518
+ };
3414
3519
 
3415
- if (
3416
- (body.expectedSourceHash &&
3417
- body.expectedSourceHash !== currentSourceHash) ||
3418
- (body.expectedModifiedAt && body.expectedModifiedAt !== currentModifiedAt)
3419
- ) {
3420
- return jsonResponse(
3421
- {
3422
- error: {
3423
- code: "CONFLICT",
3424
- message: "Document changed on disk. Reload before saving.",
3425
- },
3426
- currentVersion: {
3427
- sourceHash: currentSourceHash,
3428
- modifiedAt: currentModifiedAt,
3429
- },
3430
- },
3431
- 409
3520
+ /** Under the lease: resolve, revision-check, plan. Never writes. */
3521
+ const prepare = async (): Promise<{
3522
+ plan: DocUpdatePlan;
3523
+ publish: () => Promise<void>;
3524
+ }> => {
3525
+ const docResult = await resolveDocumentReference(store, docId, body.uri);
3526
+ if (!docResult.ok) {
3527
+ throw new DocUpdateRejected(
3528
+ errorResponse("RUNTIME", docResult.error.message, 500)
3432
3529
  );
3433
3530
  }
3434
- }
3435
-
3436
- let writeBack: "applied" | "skipped_unsupported" | undefined;
3437
-
3438
- try {
3439
- // Determine final content to write
3440
- let contentToWrite: string | undefined;
3441
-
3442
- if (hasContent) {
3443
- contentToWrite = body.content;
3531
+ if (!docResult.value) {
3532
+ throw new DocUpdateRejected(
3533
+ errorResponse("NOT_FOUND", "Document not found", 404)
3534
+ );
3535
+ }
3536
+ const doc = docResult.value;
3537
+ const capabilities = capabilitiesForDocument(doc);
3538
+ if (hasContent && !capabilities.editable) {
3539
+ throw new DocUpdateRejected(
3540
+ errorResponse(
3541
+ "READ_ONLY",
3542
+ capabilities.reason ??
3543
+ "This document cannot be edited in place. Create an editable markdown copy instead.",
3544
+ 409
3545
+ )
3546
+ );
3547
+ }
3548
+ const resolvedDocPath = await resolveAbsoluteDocPath(
3549
+ ctxHolder.config.collections,
3550
+ doc
3551
+ );
3552
+ if (!resolvedDocPath) {
3553
+ throw new DocUpdateRejected(
3554
+ errorResponse(
3555
+ "NOT_FOUND",
3556
+ `Collection not found: ${doc.collection}`,
3557
+ 404
3558
+ )
3559
+ );
3560
+ }
3561
+ const { fullPath } = resolvedDocPath;
3562
+ const file = Bun.file(fullPath);
3563
+ if (!(await file.exists())) {
3564
+ throw new DocUpdateRejected(
3565
+ errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404)
3566
+ );
3567
+ }
3568
+ const currentText = await file.text();
3569
+ const tagStates = normalizedTags
3570
+ ? {
3571
+ base: await tagState(doc.id),
3572
+ result: await tagState(doc.id, normalizedTags),
3573
+ baseUser: await userTagState(doc.id),
3574
+ }
3575
+ : null;
3576
+ const currentSourceHash = hashContent(currentText);
3577
+ if (body.expectedSourceHash || body.expectedModifiedAt) {
3578
+ const currentModifiedAt = await readModifiedAt(fullPath);
3579
+ if (
3580
+ (body.expectedSourceHash &&
3581
+ body.expectedSourceHash !== currentSourceHash) ||
3582
+ (body.expectedModifiedAt &&
3583
+ body.expectedModifiedAt !== currentModifiedAt)
3584
+ ) {
3585
+ throw new DocUpdateRejected(
3586
+ jsonResponse(
3587
+ {
3588
+ error: {
3589
+ code: "CONFLICT",
3590
+ message: "Document changed on disk. Reload before saving.",
3591
+ },
3592
+ currentVersion: {
3593
+ sourceHash: currentSourceHash,
3594
+ modifiedAt: currentModifiedAt,
3595
+ },
3596
+ },
3597
+ 409
3598
+ )
3599
+ );
3600
+ }
3444
3601
  }
3445
3602
 
3446
- // Handle tag writeback for Markdown files
3447
- if (hasTags && normalizedTags) {
3603
+ let contentToWrite = hasContent ? body.content : undefined;
3604
+ let writeBack: DocUpdatePlan["writeBack"];
3605
+ if (normalizedTags) {
3448
3606
  if (capabilities.tagsWriteback) {
3449
- // Read current content if we're only updating tags
3450
- const source = contentToWrite ?? (await file.text());
3451
- contentToWrite = updateFrontmatterTags(source, normalizedTags);
3607
+ contentToWrite = updateFrontmatterTags(
3608
+ contentToWrite ?? currentText,
3609
+ normalizedTags
3610
+ );
3452
3611
  writeBack = "applied";
3453
3612
  } else {
3454
3613
  writeBack = "skipped_unsupported";
3455
3614
  }
3456
-
3457
- // Update tags in DB (user source since this is a user action)
3458
- const tagResult = await store.setDocTags(doc.id, normalizedTags, "user");
3459
- if (!tagResult.ok) {
3460
- return errorResponse("RUNTIME", tagResult.error.message, 500);
3461
- }
3462
3615
  }
3616
+ return {
3617
+ plan: {
3618
+ fullPath,
3619
+ collection: doc.collection,
3620
+ relPath: doc.relPath,
3621
+ baseTagState: tagStates?.base ?? null,
3622
+ resultTagState: tagStates?.result ?? null,
3623
+ baseUserTagState: tagStates?.baseUser ?? null,
3624
+ docid: doc.docid,
3625
+ gnoUri: doc.uri,
3626
+ baseHash: currentSourceHash,
3627
+ newHash:
3628
+ contentToWrite === undefined ? null : hashContent(contentToWrite),
3629
+ indexedSourceHash: doc.sourceHash,
3630
+ indexedModifiedAt: doc.sourceMtime,
3631
+ writeBack,
3632
+ },
3633
+ publish: async () => {
3634
+ if (contentToWrite === undefined) return;
3635
+ ctxHolder.watchService?.suppress(fullPath);
3636
+ await atomicWrite(fullPath, contentToWrite);
3637
+ },
3638
+ };
3639
+ };
3463
3640
 
3464
- let currentSourceHash = doc.sourceHash;
3465
- let currentModifiedAt = doc.sourceMtime;
3466
-
3467
- // Write file if we have content to write
3468
- if (contentToWrite !== undefined) {
3469
- ctxHolder.watchService?.suppress(fullPath);
3470
- await atomicWrite(fullPath, contentToWrite);
3471
- currentSourceHash = hashContent(contentToWrite);
3472
- const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
3473
- currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
3641
+ /** Committed boundary: file published, tags stored, new hash recorded. */
3642
+ const finish = async (plan: DocUpdatePlan): Promise<DocUpdateOutcome> => {
3643
+ if (normalizedTags) {
3644
+ const current = await store.getDocumentByUri(plan.gnoUri);
3645
+ if (!current.ok || !current.value) {
3646
+ throw new Error(`${plan.gnoUri} is no longer indexed`);
3647
+ }
3648
+ // User source since this is a user action.
3649
+ const tagResult = await store.setDocTags(
3650
+ current.value.id,
3651
+ normalizedTags,
3652
+ "user"
3653
+ );
3654
+ if (!tagResult.ok) throw new Error(tagResult.error.message);
3474
3655
  }
3475
-
3476
- // Build proper file:// URI using node:url
3477
3656
  const { pathToFileURL } = await import("node:url");
3478
- const fileUri = pathToFileURL(fullPath).href;
3657
+ return {
3658
+ body: {
3659
+ success: true,
3660
+ docId: plan.docid,
3661
+ uri: pathToFileURL(plan.fullPath).href,
3662
+ path: plan.fullPath,
3663
+ jobId: null,
3664
+ writeBack: plan.writeBack,
3665
+ version:
3666
+ plan.newHash === null
3667
+ ? {
3668
+ sourceHash: plan.indexedSourceHash,
3669
+ modifiedAt: plan.indexedModifiedAt,
3670
+ }
3671
+ : {
3672
+ sourceHash: plan.newHash,
3673
+ modifiedAt: await readModifiedAt(plan.fullPath),
3674
+ },
3675
+ },
3676
+ target: {
3677
+ gnoUri: plan.gnoUri,
3678
+ collection: plan.collection,
3679
+ relPath: plan.relPath,
3680
+ },
3681
+ contentWritten: plan.newHash !== null,
3682
+ };
3683
+ };
3479
3684
 
3480
- // Run sync via job system (non-blocking) only if content changed
3481
- // Note: embedding handled separately by embed-scheduler (not inline)
3482
- let jobId: string | null = null;
3483
- if (contentToWrite !== undefined) {
3484
- const jobResult = await startJob(
3485
- "sync",
3486
- async (): Promise<SyncResult> => {
3487
- const result = await syncResidentCollection(
3488
- ctxHolder,
3489
- collection,
3490
- store,
3491
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3492
- deps?.syncCollection
3493
- );
3494
- // Notify scheduler after sync completes
3495
- ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
3496
- ctxHolder.eventBus?.emit({
3497
- type: "document-changed",
3498
- uri: doc.uri,
3499
- collection: doc.collection,
3500
- relPath: doc.relPath,
3501
- origin: "save",
3502
- changedAt: new Date().toISOString(),
3503
- });
3504
- return {
3505
- collections: [result],
3506
- totalDurationMs: result.durationMs,
3507
- totalFilesProcessed: result.filesProcessed,
3508
- totalFilesAdded: result.filesAdded,
3509
- totalFilesUpdated: result.filesUpdated,
3510
- totalFilesErrored: result.filesErrored,
3511
- totalFilesSkipped: result.filesSkipped,
3512
- };
3685
+ const lockPath =
3686
+ deps?.lockPath ??
3687
+ writeLeasePath(getIndexDbPath(ctxHolder.current?.indexName));
3688
+ const lockWaitMs = deps?.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
3689
+ let outcome: DocUpdateOutcome;
3690
+ let request: RequestReceiptInfo | undefined;
3691
+ try {
3692
+ if (requestId === undefined) {
3693
+ outcome = await withWriteLock(
3694
+ lockPath,
3695
+ async () => {
3696
+ const prepared = await prepare();
3697
+ await prepared.publish();
3698
+ return finish(prepared.plan);
3513
3699
  },
3514
- ctxHolder.jobManager
3700
+ lockWaitMs
3515
3701
  );
3516
- jobId = jobResult.ok ? jobResult.jobId : null;
3702
+ } else {
3703
+ const requested = await runRequestedWrite<
3704
+ DocUpdatePlan,
3705
+ DocUpdateOutcome
3706
+ >({
3707
+ ...localRequestLedger(store.getDbPath()),
3708
+ requestId,
3709
+ operation: "document.update",
3710
+ digest: requestDigest("document.update", {
3711
+ target: body.uri ?? docId,
3712
+ content: body.content,
3713
+ tags: normalizedTags,
3714
+ expectedSourceHash: body.expectedSourceHash,
3715
+ expectedModifiedAt: body.expectedModifiedAt,
3716
+ }),
3717
+ lockPath,
3718
+ lockWaitMs,
3719
+ checkpoint: deps?.requestCheckpoint,
3720
+ prepare,
3721
+ inspect: async (plan) => {
3722
+ if (plan.newHash === null) {
3723
+ // Tags only: resume only if neither the file nor the tags moved
3724
+ // on since admission (or the tags already are this request's).
3725
+ const current = await store.getDocumentByUri(plan.gnoUri);
3726
+ const file = Bun.file(plan.fullPath);
3727
+ if (
3728
+ !current.ok ||
3729
+ !current.value ||
3730
+ !(await file.exists()) ||
3731
+ hashContent(await file.text()) !== plan.baseHash
3732
+ ) {
3733
+ return "unexpected";
3734
+ }
3735
+ const tags = await tagState(current.value.id);
3736
+ return tags === plan.baseTagState || tags === plan.resultTagState
3737
+ ? "published"
3738
+ : "unexpected";
3739
+ }
3740
+ const file = Bun.file(plan.fullPath);
3741
+ if (!(await file.exists())) return "unexpected";
3742
+ const onDisk = hashContent(await file.text());
3743
+ if (onDisk !== plan.newHash) {
3744
+ return onDisk === plan.baseHash ? "absent" : "unexpected";
3745
+ }
3746
+ if (plan.resultTagState === null) return "published";
3747
+ // Content plus tags: finish rewrites the user tags, so resume only
3748
+ // if no store-only tag write landed since admission. A re-sync of
3749
+ // this request's own file may move frontmatter tags; that is fine.
3750
+ const current = await store.getDocumentByUri(plan.gnoUri);
3751
+ if (!current.ok || !current.value) return "unexpected";
3752
+ const tags = await tagState(current.value.id);
3753
+ return tags === plan.baseTagState ||
3754
+ tags === plan.resultTagState ||
3755
+ (await userTagState(current.value.id)) === plan.baseUserTagState
3756
+ ? "published"
3757
+ : "unexpected";
3758
+ },
3759
+ finish,
3760
+ resultRef: ({ body, target }) => ({
3761
+ uri: target.gnoUri,
3762
+ docid: body.docId,
3763
+ sourceHash: body.version.sourceHash,
3764
+ }),
3765
+ });
3766
+ outcome = requested.result;
3767
+ request = requested.request;
3768
+ if (request.replayed) return jsonResponse({ ...outcome.body, request });
3769
+ }
3770
+ } catch (error) {
3771
+ if (error instanceof DocUpdateRejected) return error.response;
3772
+ const receiptError = requestErrorResponse(error);
3773
+ if (receiptError) return receiptError;
3774
+ const message = error instanceof Error ? error.message : String(error);
3775
+ if (isLeaseBusy(error)) {
3776
+ return errorResponse(MCP_ERRORS.LOCKED.code, message, 409);
3517
3777
  }
3518
-
3519
- return jsonResponse({
3520
- success: true,
3521
- docId: doc.docid,
3522
- uri: fileUri,
3523
- path: fullPath,
3524
- jobId,
3525
- writeBack,
3526
- version: {
3527
- sourceHash: currentSourceHash,
3528
- modifiedAt: currentModifiedAt,
3529
- },
3530
- });
3531
- } catch (e) {
3532
3778
  return errorResponse(
3533
3779
  "RUNTIME",
3534
- `Failed to update document: ${e instanceof Error ? e.message : String(e)}`,
3780
+ `Failed to update document: ${message}`,
3535
3781
  500
3536
3782
  );
3537
3783
  }
3784
+
3785
+ // Deferred work: lexical sync via the job system, embedding via the
3786
+ // scheduler. Its failure never undoes the committed save.
3787
+ let jobId: string | null = null;
3788
+ const { target } = outcome;
3789
+ const collection = ctxHolder.config.collections.find(
3790
+ (candidate) => candidate.name === target.collection
3791
+ );
3792
+ if (outcome.contentWritten && collection) {
3793
+ const jobResult = await startJob(
3794
+ "sync",
3795
+ async (): Promise<SyncResult> => {
3796
+ const result = await syncResidentCollection(
3797
+ ctxHolder,
3798
+ collection,
3799
+ store,
3800
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3801
+ deps?.syncCollection
3802
+ );
3803
+ // Notify scheduler after sync completes
3804
+ ctxHolder.scheduler?.notifySyncComplete([outcome.body.docId]);
3805
+ ctxHolder.eventBus?.emit({
3806
+ type: "document-changed",
3807
+ uri: target.gnoUri,
3808
+ collection: target.collection,
3809
+ relPath: target.relPath,
3810
+ origin: "save",
3811
+ changedAt: new Date().toISOString(),
3812
+ });
3813
+ return {
3814
+ collections: [result],
3815
+ totalDurationMs: result.durationMs,
3816
+ totalFilesProcessed: result.filesProcessed,
3817
+ totalFilesAdded: result.filesAdded,
3818
+ totalFilesUpdated: result.filesUpdated,
3819
+ totalFilesErrored: result.filesErrored,
3820
+ totalFilesSkipped: result.filesSkipped,
3821
+ };
3822
+ },
3823
+ ctxHolder.jobManager
3824
+ );
3825
+ jobId = jobResult.ok ? jobResult.jobId : null;
3826
+ }
3827
+
3828
+ return jsonResponse({
3829
+ ...outcome.body,
3830
+ jobId,
3831
+ ...(request ? { request } : {}),
3832
+ });
3538
3833
  }
3539
3834
 
3540
3835
  /**
@@ -3698,8 +3993,39 @@ export async function handleCreateCapture(
3698
3993
  );
3699
3994
  }
3700
3995
 
3996
+ const { requestId, ...captureInput } = body;
3997
+ if (requestId !== undefined) {
3998
+ try {
3999
+ const result = await executeRequestedResidentCapture(
4000
+ ctxHolder,
4001
+ store,
4002
+ { ...captureInput, collection: body.collection },
4003
+ validateRequestId(requestId),
4004
+ deps
4005
+ );
4006
+ return jsonResponse(result.body, result.status);
4007
+ } catch (error) {
4008
+ if (error instanceof ResidentCapturePlanError) {
4009
+ return errorResponse(
4010
+ error.shape.code,
4011
+ error.shape.message,
4012
+ error.shape.status
4013
+ );
4014
+ }
4015
+ const receiptError = requestErrorResponse(error);
4016
+ if (receiptError) return receiptError;
4017
+ const shape = classifyResidentCaptureError(error);
4018
+ return errorResponse(
4019
+ shape.code,
4020
+ shape.message,
4021
+ shape.status,
4022
+ shape.details
4023
+ );
4024
+ }
4025
+ }
4026
+
3701
4027
  const planned = await planResidentCapture(ctxHolder, store, {
3702
- ...body,
4028
+ ...captureInput,
3703
4029
  collection: body.collection,
3704
4030
  });
3705
4031
  if (!planned.ok) {
@@ -3765,6 +4091,8 @@ export interface MemoryRouteDeps {
3765
4091
  }
3766
4092
 
3767
4093
  function memoryErrorResponse(error: unknown, fallback: string): Response {
4094
+ const receiptError = requestErrorResponse(error);
4095
+ if (receiptError) return receiptError;
3768
4096
  if (error instanceof MemoryError) {
3769
4097
  return errorResponse(
3770
4098
  error.code,
@@ -3824,6 +4152,7 @@ function createMemoryService(
3824
4152
  collections: ctx.config.collections,
3825
4153
  lockPath: deps.lockPath ?? writeLeasePath(getIndexDbPath(ctx.indexName)),
3826
4154
  lockWaitMs: deps.lockWaitMs,
4155
+ requests: localRequestLedger(store.getDbPath()),
3827
4156
  embedPort: ctx.embedPort,
3828
4157
  vectorIndex: ctx.vectorIndex,
3829
4158
  });