zigmini 1.0.0-beta.2 → 1.1.0-beta.1

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 (141) hide show
  1. package/README.ko.md +36 -3
  2. package/README.md +36 -3
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +120 -11
  12. package/docs/manual.md +123 -12
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
  38. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
  39. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  40. package/package.json +5 -3
  41. package/src/agent/api.ts +345 -20
  42. package/src/agent/artifacts.ts +196 -0
  43. package/src/agent/aside-target-routes.ts +369 -0
  44. package/src/agent/aside-targets.ts +215 -0
  45. package/src/agent/collaboration-admission.ts +47 -0
  46. package/src/agent/collaboration-read.ts +117 -0
  47. package/src/agent/collaboration-session.ts +196 -0
  48. package/src/agent/collaboration-types.ts +258 -0
  49. package/src/agent/collaboration.ts +399 -0
  50. package/src/agent/definition.ts +65 -3
  51. package/src/agent/fields.ts +9 -3
  52. package/src/agent/file-core.ts +325 -0
  53. package/src/agent/files.ts +87 -8
  54. package/src/agent/list.ts +3 -3
  55. package/src/agent/managed-call.ts +144 -0
  56. package/src/agent/tool-catalog.ts +184 -0
  57. package/src/agent/tool-delivery.ts +359 -0
  58. package/src/agent/tool-permission-types.ts +81 -0
  59. package/src/agent/tool-permissions.ts +1366 -0
  60. package/src/attachments/api.ts +161 -0
  61. package/src/attachments/document-reader.ts +325 -0
  62. package/src/attachments/host-selector.ts +410 -0
  63. package/src/attachments/managed-binding.ts +83 -0
  64. package/src/attachments/prepare.ts +214 -0
  65. package/src/attachments/selector-port.ts +28 -0
  66. package/src/attachments/service.ts +729 -0
  67. package/src/attachments/store.ts +698 -0
  68. package/src/attachments/types.ts +123 -0
  69. package/src/cli/doctor.ts +30 -10
  70. package/src/decisions.ts +109 -10
  71. package/src/engine/apply-patch.ts +21 -0
  72. package/src/engine/compat.json +2 -1
  73. package/src/engine/install.ts +45 -4
  74. package/src/engine/sandbox-cell.ts +2 -0
  75. package/src/gc.ts +33 -225
  76. package/src/housekeeping/api.ts +163 -0
  77. package/src/housekeeping/audit.ts +61 -0
  78. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  79. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  80. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  81. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  82. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  83. package/src/housekeeping/collectors/db-backup.ts +25 -0
  84. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  85. package/src/housekeeping/collectors/docker-image.ts +135 -0
  86. package/src/housekeeping/collectors/omo-version.ts +32 -0
  87. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  88. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  89. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  90. package/src/housekeeping/collectors/shared.ts +149 -0
  91. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  92. package/src/housekeeping/collectors/update-file.ts +25 -0
  93. package/src/housekeeping/collectors/worktree.ts +193 -0
  94. package/src/housekeeping/engine.ts +125 -0
  95. package/src/housekeeping/reconcile/work.ts +130 -0
  96. package/src/housekeeping/registry.ts +63 -0
  97. package/src/housekeeping/trash.ts +59 -0
  98. package/src/housekeeping/types.ts +31 -0
  99. package/src/hub.ts +1095 -41
  100. package/src/main.ts +230 -31
  101. package/src/message-references.ts +14 -5
  102. package/src/profile-prompt.ts +13 -1
  103. package/src/profile.ts +13 -5
  104. package/src/protocol.ts +41 -6
  105. package/src/security-allow.ts +79 -2
  106. package/src/security-check.ts +1 -1
  107. package/src/store.ts +1946 -23
  108. package/src/test-crash.ts +20 -0
  109. package/src/usage.ts +18 -7
  110. package/src/work-watch.ts +55 -7
  111. package/src/work.ts +192 -3
  112. package/web/DESIGN.md +35 -1
  113. package/web/agents.d.ts +189 -12
  114. package/web/agents.js +898 -39
  115. package/web/app.js +2634 -210
  116. package/web/attachments.d.ts +91 -0
  117. package/web/attachments.js +422 -0
  118. package/web/collaboration.d.ts +87 -0
  119. package/web/collaboration.js +290 -0
  120. package/web/decisions.d.ts +39 -2
  121. package/web/decisions.js +354 -2
  122. package/web/gc.d.ts +26 -5
  123. package/web/gc.js +113 -13
  124. package/web/i18n/agents.d.ts +4 -0
  125. package/web/i18n/agents.js +200 -8
  126. package/web/i18n/attachments.js +78 -0
  127. package/web/i18n/decisions.d.ts +4 -0
  128. package/web/i18n/decisions.js +68 -0
  129. package/web/i18n/gc.d.ts +4 -0
  130. package/web/i18n/gc.js +184 -2
  131. package/web/i18n/main.js +80 -8
  132. package/web/i18n/tour.d.ts +4 -0
  133. package/web/i18n/tour.js +37 -0
  134. package/web/index.html +20 -3
  135. package/web/profile.d.ts +2 -2
  136. package/web/profile.js +16 -10
  137. package/web/style.css +342 -21
  138. package/web/tooltip.d.ts +2 -0
  139. package/web/tooltip.js +12 -0
  140. package/web/tour.d.ts +57 -0
  141. package/web/tour.js +450 -0
@@ -0,0 +1,729 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { VerifiedManagedCall } from "../agent/managed-call.ts";
6
+ import type { AttachmentCleanupDeps } from "../housekeeping/collectors/attachment-cleanup.ts";
7
+ import { moveToTrash, type Trash } from "../housekeeping/trash.ts";
8
+ import { PROTOCOL_VERSION, type MessageReference } from "../protocol.ts";
9
+ import { documentPrompt } from "../message-references.ts";
10
+ import type { SecurityReceipt } from "../security-allow.ts";
11
+ import type { SecurityOccurrence } from "../security-check.ts";
12
+ import type { Store } from "../store.ts";
13
+ import { NativeDocumentError, readNativeDocument, type NativeDocumentBuild, type NativeDocumentResult } from "./document-reader.ts";
14
+ import type { ManagedBindings } from "./managed-binding.ts";
15
+ import { DocumentPreparationError, prepareText, readPreparedText, type PreparedText } from "./prepare.ts";
16
+ import {
17
+ AttachmentStoreError, type AttachmentAccepted, type AttachmentAdmission, type AttachmentCAS, type AttachmentEncoding, type AttachmentFormat, type AttachmentGate,
18
+ type AttachmentJob, type AttachmentManifest, type AttachmentMetadata, type AttachmentPreparation, type AttachmentRecipient, type AttachmentScope,
19
+ type SelectedAttachmentExport,
20
+ } from "./types.ts";
21
+
22
+ export type SelectedFile = { readonly root: string; readonly segments: readonly string[]; readonly name: string; readonly format: AttachmentFormat;
23
+ readonly identity: { readonly device: string; readonly inode: string; readonly size: number; readonly mtimeNs: string; readonly ctimeNs: string; readonly nlink: number } };
24
+ export type PickerScopeRecord = AttachmentScope & { readonly principalRevision: number; readonly generation: string };
25
+ /** Darwin selector boundary. The native descriptor rules live behind it and are proved in test/native. */
26
+ export interface SelectorPort {
27
+ open(scope: PickerScopeRecord): Promise<{ pickerId: string; rootEntries: readonly { readonly name: string }[]; currentDirectoryToken: string; revision: number }>;
28
+ list(scope: PickerScopeRecord, id: string, input: unknown): Promise<unknown>;
29
+ resolve(scope: PickerScopeRecord, id: string, token: string): SelectedFile;
30
+ scopeOf(principal: string, id: string): PickerScopeRecord;
31
+ seal(id: string, source: SelectedFile, signal: AbortSignal, beforePublish: () => void): Promise<{ readonly bytes: number; readonly sha256: string }>;
32
+ }
33
+ export type DocumentRoute = "native-document" | "pages" | "ocr";
34
+ export class AttachmentApiError extends Error {
35
+ constructor(readonly code: string, readonly status: number, readonly detail?: Record<string, unknown>) { super(code); this.name = "AttachmentApiError"; }
36
+ }
37
+ interface DerivedPage { readonly page: number; readonly representation: "native-text" | "ocr-text" | "rendered-image" }
38
+ interface Derived { readonly prepared: PreparedText; readonly pages: readonly DerivedPage[] | null }
39
+ interface CursorState { readonly g: string; readonly a: string; readonly r: string; readonly v: "text" | "pages"; readonly o: number }
40
+ interface Options {
41
+ readonly store: Store; readonly runtimeHome: string; readonly selector: SelectorPort | null; readonly helper: NativeDocumentBuild | null;
42
+ readonly bindings: ManagedBindings; readonly emit: (principal: string, event: unknown) => void;
43
+ readonly agentExists: (id: string) => boolean;
44
+ readonly guard?: { check(texts: readonly string[]): readonly SecurityOccurrence[] | null };
45
+ /** Capability of the conversation's active model. A failed probe counts as text-only: OCR is always a safe representation. */
46
+ readonly documentRoute?: (convId: string) => DocumentRoute | Promise<DocumentRoute>;
47
+ readonly notice?: (convId: string, code: string) => void;
48
+ /** Observation point before the final revocation check. */
49
+ readonly beforeRelease?: () => void | Promise<void>;
50
+ readonly trash?: Trash;
51
+ readonly generation?: string;
52
+ /** Starts the whole-document preparation limit and returns its cancel. Tests drive expiry directly. */
53
+ readonly documentDeadline?: (expire: () => void, ms: number) => () => void;
54
+ readonly preparedTextBytes?: number;
55
+ }
56
+ export interface SendRequest {
57
+ readonly convId: string; readonly principal: string; readonly text: string; readonly images: readonly string[];
58
+ readonly references: readonly MessageReference[];
59
+ readonly src: unknown; readonly trust: unknown; readonly submissionId: string | undefined; readonly attachments: readonly string[];
60
+ }
61
+ export interface AdmissionPlan {
62
+ readonly retry: AttachmentAccepted | null; readonly findings: readonly SecurityOccurrence[] | null; readonly stableDigest: string;
63
+ readonly admission: AttachmentAdmission | null; readonly data: Readonly<Record<string, unknown>>; readonly body: string;
64
+ readonly references: SendRequest["references"];
65
+ }
66
+ export type CommittedAdmission = AttachmentAccepted & { readonly fresh: boolean; readonly prompt: string };
67
+ const sha = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
68
+ const VISIBLE = /[^\s\u00a0\u180e\u200b-\u200f\u2028-\u202f\u205f-\u206f\u3000\ufeff]/;
69
+ const NO_HELPER = sha("zigmini-no-native-document-helper");
70
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
71
+ const maxInFlight = 2;
72
+ interface OcrJob { readonly key: string; readonly abort: AbortController; waiters: number; readonly promise: Promise<Derived> }
73
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
74
+ return new Promise<T>((resolve, reject) => {
75
+ const cancel = () => reject(new NativeDocumentError("cancelled"));
76
+ if (signal.aborted) { cancel(); return; }
77
+ signal.addEventListener("abort", cancel, { once: true });
78
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", cancel));
79
+ });
80
+ }
81
+ const PDF_PREPARATION_MS = 600_000;
82
+ const PDF_PREPARED_TEXT_BYTES = 20 * 1024 * 1024;
83
+ const timerDeadline = (expire: () => void, ms: number) => { const timer = setTimeout(expire, ms); return () => clearTimeout(timer); };
84
+ /** What a revoke or cleanup cannot take back; the owner sees these with every receipt. */
85
+ export const REVOCATION_LIMITS = ["model_context_already_supplied", "downloaded_copies", "user_derived_outputs", "committed_collaboration_copies"] as const;
86
+ const object = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
87
+
88
+ export const inspectionDigest = (text: string, preparations: readonly Pick<AttachmentPreparation, "inspectedTextSha256">[]) =>
89
+ sha(JSON.stringify([text, ...preparations.map(p => p.inspectedTextSha256)]));
90
+
91
+ export class AttachmentService {
92
+ private readonly running = new Map<string, Promise<void>>();
93
+ private readonly aborts = new Map<string, AbortController>();
94
+ private readonly cleaning = new Map<string, Promise<void>>();
95
+ private readonly ocrJobs = new Map<string, OcrJob>();
96
+ private active = 0;
97
+ private readonly waiting: (() => void)[] = [];
98
+ readonly generation: string;
99
+ readonly gate: AttachmentGate;
100
+ private readonly preparedTextBytes: number;
101
+ constructor(private readonly o: Options) {
102
+ this.generation = o.generation ?? randomUUID();
103
+ this.preparedTextBytes = o.preparedTextBytes ?? PDF_PREPARED_TEXT_BYTES;
104
+ this.gate = { current: (tx, scope) => this.current(tx, scope), original: (_tx, id) => this.originalFacts(id),
105
+ inspect: (_tx, admission, preparations) => {
106
+ if (admission.inspectedTextSha256 !== inspectionDigest(admission.text, preparations)) throw new AttachmentStoreError("attachment_manifest_conflict");
107
+ return undefined;
108
+ } };
109
+ }
110
+ get bindings(): ManagedBindings { return this.o.bindings; }
111
+
112
+ /** Standalone lifecycle adapter: deleting an agent advances its epoch, so a restored agent never matches an older scope. */
113
+ private current(tx: Database, scope: AttachmentScope) {
114
+ const row = tx.query<{ agent_id: string | null; hidden: number }, [string]>("SELECT agent_id,hidden FROM conversations WHERE id=?").get(scope.convId);
115
+ if (!row || (row.agent_id && (row.hidden === 1 || !this.o.agentExists(row.agent_id)))) throw new AttachmentStoreError("attachment_scope_mismatch");
116
+ const recipient: AttachmentRecipient = row.agent_id ? { kind: "agent", id: row.agent_id, epoch: this.o.store.attachments.recipientEpoch(row.agent_id) } : { kind: "main", id: "main", epoch: 1 };
117
+ return { convEpoch: 1, recipient, ownerActive: this.ownerActive(scope.ownerPrincipal) };
118
+ }
119
+ ownerActive(principal: string): boolean {
120
+ if (principal.startsWith("web:")) return true;
121
+ const [, id, revision] = /^device:([^:]+):(.+)$/.exec(principal) ?? [];
122
+ if (!id || !revision) return false;
123
+ const device = this.o.store.getDevice(id);
124
+ return device?.status === "approved" && deviceRevision(device) === revision;
125
+ }
126
+ private originalPath(id: string): string { return join(this.o.runtimeHome, "attachments", id, "original"); }
127
+ private readOriginal(id: string): Uint8Array | null {
128
+ let fd: number;
129
+ try { fd = openSync(this.originalPath(id), constants.O_RDONLY | constants.O_NOFOLLOW); }
130
+ catch (error) { if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ELOOP")) return null; throw error; }
131
+ try {
132
+ const stat = fstatSync(fd);
133
+ if (!stat.isFile() || stat.nlink !== 1) return null;
134
+ return readFileSync(fd);
135
+ } finally { closeSync(fd); }
136
+ }
137
+ private originalFacts(id: string) {
138
+ const bytes = this.readOriginal(id);
139
+ return bytes && { bytes: bytes.length, sha256: sha(bytes) };
140
+ }
141
+ private scope(principal: string, convId: unknown, draftId: unknown): AttachmentScope {
142
+ if (typeof convId !== "string" || !convId || typeof draftId !== "string" || !UUID.test(draftId)) throw new AttachmentApiError("invalid_input", 400);
143
+ const conv = this.o.store.getConversation(convId);
144
+ if (!conv) throw new AttachmentApiError("attachment_scope_mismatch", 409);
145
+ return { ownerPrincipal: principal, draftId, convId, convEpoch: 1,
146
+ recipient: conv.agentId ? { kind: "agent", id: conv.agentId, epoch: this.o.store.attachments.recipientEpoch(conv.agentId) } : { kind: "main", id: "main", epoch: 1 } };
147
+ }
148
+ currentPicker = (s: AttachmentScope): boolean => {
149
+ const conv = this.o.store.getConversation(s.convId);
150
+ return !!conv && this.ownerActive(s.ownerPrincipal) && (conv.agentId ?? "main") === s.recipient.id && (!conv.agentId || this.o.agentExists(conv.agentId)) &&
151
+ s.recipient.epoch === this.lifecycleEpoch(s.convId);
152
+ };
153
+ lifecycleEpoch(convId: string): number {
154
+ const agentId = this.o.store.getConversation(convId)?.agentId;
155
+ return agentId ? this.o.store.attachments.recipientEpoch(agentId) : 1;
156
+ }
157
+ private assertOwner(principal: string): void {
158
+ if (!this.ownerActive(principal)) throw new AttachmentStoreError("attachment_owner_inactive", 403);
159
+ }
160
+ private get selector(): SelectorPort {
161
+ if (!this.o.selector) throw new AttachmentApiError("host_selector_unavailable", 503);
162
+ return this.o.selector;
163
+ }
164
+ async openPicker(principal: string, body: unknown) {
165
+ if (!object(body)) throw new AttachmentApiError("invalid_input", 400);
166
+ const scope: PickerScopeRecord = { ...this.scope(principal, body.convId, body.draftId), principalRevision: 1, generation: this.generation };
167
+ const opened = await this.selector.open(scope);
168
+ return { ...opened, rootEntries: opened.rootEntries.filter(entry => entry.name !== "Downloads") };
169
+ }
170
+ listPicker(principal: string, id: string, input: unknown) {
171
+ const scope = this.selector.scopeOf(principal, id);
172
+ this.assertOwner(principal);
173
+ return this.selector.list(scope, id, input);
174
+ }
175
+ private update(principal: string, id: string, conv: string): void {
176
+ const scope = this.o.store.attachments.ownedScope(id, principal);
177
+ const s = this.o.store.attachments.status(scope, id, this.gate);
178
+ this.o.emit(principal, { v: PROTOCOL_VERSION, id: randomUUID(), ts: new Date().toISOString(), conv, type: "attachment.updated",
179
+ data: { draftId: scope.draftId, attachmentId: id, revision: s.revision, state: s.state, preparation: s.preparation, readMode: s.readMode, revokedGrantIds: this.o.store.attachments.revokedGrantIds(id),
180
+ ...(s.error ? { error: s.error } : {}) } });
181
+ }
182
+ private safely(principal: string, id: string, conv: string): void {
183
+ try { this.update(principal, id, conv); }
184
+ catch (error) { if (!(error instanceof AttachmentStoreError)) throw error; }
185
+ }
186
+ select(principal: string, pickerId: string, body: unknown): { attachmentId: string; state: string; revision: number } {
187
+ if (!object(body) || typeof body.entryToken !== "string" || typeof body.operationId !== "string" || Object.keys(body).length !== 2) throw new AttachmentApiError("invalid_input", 400);
188
+ this.assertOwner(principal);
189
+ const scope = this.selector.scopeOf(principal, pickerId);
190
+ const source = this.selector.resolve(scope, pickerId, body.entryToken);
191
+ const jobGeneration = randomUUID();
192
+ const job = this.o.store.attachments.select({ scope, operationId: body.operationId, entryIdentity: { ...source.identity }, sourceIdentity: { ...source.identity, root: source.root, segments: [...source.segments] },
193
+ name: source.name, format: source.format, bytes: source.identity.size, jobGeneration }, this.gate);
194
+ if (job.jobGeneration === jobGeneration) this.track(job.attachmentId, principal, scope.convId, signal => this.seal(job, source, principal, scope.convId, signal));
195
+ return { attachmentId: job.attachmentId, state: job.state, revision: job.revision };
196
+ }
197
+ private track(id: string, principal: string, conv: string, work: (signal: AbortSignal) => Promise<void>): void {
198
+ const abort = new AbortController();
199
+ this.aborts.get(id)?.abort();
200
+ this.aborts.set(id, abort);
201
+ const run = (async () => {
202
+ try { await this.inProcessingSlot(() => work(abort.signal)); }
203
+ finally {
204
+ if (this.aborts.get(id) === abort) this.aborts.delete(id);
205
+ this.safely(principal, id, conv);
206
+ }
207
+ })();
208
+ this.running.set(id, run);
209
+ void run.finally(() => { if (this.running.get(id) === run) this.running.delete(id); });
210
+ }
211
+ /** C8: every preparation and OCR started by a read shares these two slots. */
212
+ private async inProcessingSlot<T>(work: () => Promise<T>): Promise<T> {
213
+ while (this.active >= maxInFlight) await new Promise<void>(resolve => this.waiting.push(resolve));
214
+ this.active += 1;
215
+ try { return await work(); }
216
+ finally { this.active -= 1; this.waiting.shift()?.(); }
217
+ }
218
+ /** One job per document. Later reads with the same key join it; any other work on the document finishes first. */
219
+ private async documentOcr(id: string, key: string, signal: AbortSignal, work: (signal: AbortSignal) => Promise<Derived>): Promise<Derived> {
220
+ for (;;) {
221
+ const current = this.ocrJobs.get(id);
222
+ if (current?.key === key) return this.joinOcr(current, signal);
223
+ const busy = this.running.get(id);
224
+ if (!busy) break;
225
+ await untilAborted(busy, signal);
226
+ }
227
+ if (signal.aborted) throw new NativeDocumentError("cancelled");
228
+ const abort = new AbortController();
229
+ const promise = this.inProcessingSlot(() => work(abort.signal)).finally(() => {
230
+ if (this.ocrJobs.get(id) === job) this.ocrJobs.delete(id);
231
+ if (this.running.get(id) === settled) this.running.delete(id);
232
+ });
233
+ const job: OcrJob = { key, abort, waiters: 0, promise };
234
+ const settled = promise.then(() => undefined, () => undefined);
235
+ this.ocrJobs.set(id, job);
236
+ this.running.set(id, settled);
237
+ return this.joinOcr(job, signal);
238
+ }
239
+ /** With no live grant left, nothing can receive this OCR. Another grant of the file keeps it running. */
240
+ private stopOcrIfRevoked(id: string): void {
241
+ if (this.o.store.attachments.info(id).state === "revoked") this.ocrJobs.get(id)?.abort.abort();
242
+ }
243
+ /** The job stops when its last reader has gone, so a shared job survives one reader's disconnect. */
244
+ private async joinOcr(job: OcrJob, signal: AbortSignal): Promise<Derived> {
245
+ job.waiters += 1;
246
+ try { return await untilAborted(job.promise, signal); }
247
+ finally { if (--job.waiters === 0) job.abort.abort(); }
248
+ }
249
+ /** Resolves when no worker for this attachment is left. */
250
+ async whenSettled(id: string): Promise<void> { for (let run = this.running.get(id); run; run = this.running.get(id)) await run; }
251
+ private fail(id: string, cas: AttachmentCAS, error: unknown): void {
252
+ const code = error instanceof DocumentPreparationError || error instanceof NativeDocumentError || (error instanceof Error && "code" in error && typeof error.code === "string")
253
+ ? String((error as { code: unknown }).code).toLowerCase() : "preparation_failed";
254
+ try { this.o.store.attachments.failJob(id, cas, /^[a-z][a-z0-9_]{0,79}$/.test(code) ? code : "preparation_failed"); }
255
+ catch (failure) { if (!(failure instanceof AttachmentStoreError)) throw failure; }
256
+ }
257
+ private async seal(job: AttachmentJob, source: SelectedFile, principal: string, conv: string, signal: AbortSignal): Promise<void> {
258
+ const attachments = this.o.store.attachments, cas = { expectedRevision: job.revision, jobGeneration: job.jobGeneration };
259
+ let sealed: { bytes: number; sha256: string };
260
+ try {
261
+ sealed = await this.selector.seal(job.attachmentId, source, signal, () => {
262
+ if (attachments.info(job.attachmentId).state !== "snapshotting") throw new AttachmentApiError("picker_closed", 409);
263
+ });
264
+ } catch (error) { this.fail(job.attachmentId, cas, error); return; }
265
+ let prepared: AttachmentJob;
266
+ try {
267
+ const after = attachments.seal(job.attachmentId, cas, sealed, this.gate);
268
+ this.safely(principal, job.attachmentId, conv);
269
+ prepared = attachments.prepare(attachments.ownedScope(job.attachmentId, principal), job.attachmentId,
270
+ { expectedRevision: after.revision, operationId: randomUUID(), jobGeneration: randomUUID() }, this.gate);
271
+ } catch (error) {
272
+ if (!(error instanceof AttachmentStoreError)) throw error;
273
+ this.fail(job.attachmentId, cas, error);
274
+ return;
275
+ }
276
+ await this.prepareBody(prepared, undefined, conv, signal);
277
+ }
278
+ prepare(principal: string, id: string, body: unknown): AttachmentJob {
279
+ if (!object(body) || typeof body.operationId !== "string" || typeof body.expectedRevision !== "number" ||
280
+ (body.encoding !== undefined && typeof body.encoding !== "string")) throw new AttachmentApiError("invalid_input", 400);
281
+ const attachments = this.o.store.attachments, scope = attachments.ownedScope(id, principal), jobGeneration = randomUUID();
282
+ const encoding = body.encoding as AttachmentEncoding | undefined;
283
+ const job = attachments.prepare(scope, id, { expectedRevision: body.expectedRevision, operationId: body.operationId, jobGeneration, ...(encoding ? { encoding } : {}) }, this.gate);
284
+ if (job.jobGeneration === jobGeneration) this.track(id, principal, scope.convId, signal => this.prepareBody(job, encoding, scope.convId, signal));
285
+ return job;
286
+ }
287
+ private async route(convId: string): Promise<DocumentRoute> {
288
+ if (!this.o.documentRoute) return "pages";
289
+ try { return await this.o.documentRoute(convId); }
290
+ catch { return "ocr"; }
291
+ }
292
+ /** C4: the model selected now decides. A PDF prepared for vision is prepared again before its OCR text is inspected. */
293
+ private async prepareForRoute(id: string, principal: string, route: DocumentRoute): Promise<void> {
294
+ const attachments = this.o.store.attachments;
295
+ let current: ReturnType<typeof attachments.manifest>;
296
+ try { current = attachments.manifest(id, principal); }
297
+ catch (error) { if (error instanceof AttachmentStoreError) return; throw error; }
298
+ const info = attachments.info(id);
299
+ if (info.state !== "ready" || info.format !== "pdf" || current.preparation.readMode === route) return;
300
+ const job = attachments.prepare(current.scope, id, { expectedRevision: current.manifest.expectedRevision, operationId: randomUUID(), jobGeneration: randomUUID() }, this.gate);
301
+ this.track(id, principal, current.scope.convId, signal => this.prepareBody(job, undefined, current.scope.convId, signal));
302
+ await this.whenSettled(id);
303
+ }
304
+ private build() {
305
+ return { helperSourceSha256: this.o.helper?.sourceSha256 ?? NO_HELPER, helperBuildSha256: this.o.helper?.buildSha256 ?? NO_HELPER };
306
+ }
307
+ private async prepareBody(job: AttachmentJob, encoding: AttachmentEncoding | undefined, conv: string, signal: AbortSignal): Promise<void> {
308
+ const id = job.attachmentId, attachments = this.o.store.attachments, cas = { expectedRevision: job.revision, jobGeneration: job.jobGeneration };
309
+ try {
310
+ const original = this.readOriginal(id);
311
+ const info = attachments.info(id);
312
+ if (!original || sha(original) !== info.sha256) throw new AttachmentStoreError("attachment_original_unavailable", 410);
313
+ const isCurrent = () => { try { attachments.preparationWork(id, cas); return true; } catch { return false; } };
314
+ const route = await this.route(conv), textOnly = route === "ocr";
315
+ const derived = info.format === "pdf" ? await this.preparePdf(original, textOnly, signal, isCurrent) : { prepared: await prepareText(original, { format: info.format, ...(encoding ? { encoding } : {}), ...this.build() },
316
+ this.o.helper ? { build: this.o.helper, context: { signal, isCurrent } } : undefined), pages: null };
317
+ this.writeDerived(id, job.preparationRevision, derived);
318
+ attachments.publish(id, cas, { originalSha256: derived.prepared.originalSha256, preparationRevision: job.preparationRevision, representationSha256: derived.prepared.representationSha256,
319
+ inspectedTextSha256: derived.prepared.representationSha256, readMode: info.format === "pdf" ? route : "text",
320
+ ...(derived.pages ? { pageCount: derived.pages.length } : {}) });
321
+ } catch (error) { this.fail(id, cas, error); }
322
+ }
323
+ private async native(original: Uint8Array, operation: "document.inspect" | "document.text" | "document.pages" | "document.ocr", options: Record<string, unknown>, signal: AbortSignal, isCurrent: () => boolean, deadline?: AbortSignal): Promise<NativeDocumentResult> {
324
+ if (!this.o.helper) throw new NativeDocumentError("native_reader_unavailable");
325
+ return readNativeDocument(this.o.helper, { original, operation, options: { format: "pdf", ...options } }, { signal, isCurrent, ...(deadline ? { deadline } : {}) });
326
+ }
327
+ /** Per-page native text and OCR. A page without visible text is never reported as read: it stays rendered-image. */
328
+ private async pdfDocument(original: Uint8Array, ocrPages: (page: number, nativeText: string) => boolean, signal: AbortSignal, isCurrent: () => boolean): Promise<Derived> {
329
+ // One limit covers every helper this document starts; each helper alone is far below it.
330
+ const deadline = new AbortController();
331
+ const cancelDeadline = (this.o.documentDeadline ?? timerDeadline)(() => deadline.abort(), PDF_PREPARATION_MS);
332
+ try { return await this.readPdfPages(original, ocrPages, signal, isCurrent, deadline.signal); }
333
+ finally { cancelDeadline(); }
334
+ }
335
+ private async readPdfPages(original: Uint8Array, ocrPages: (page: number, nativeText: string) => boolean, signal: AbortSignal, isCurrent: () => boolean, deadline: AbortSignal): Promise<Derived> {
336
+ const inspected = await this.native(original, "document.inspect", {}, signal, isCurrent, deadline);
337
+ const total = inspected.coverage.totalPages;
338
+ const pageText = async (operation: "document.text" | "document.ocr", page: number): Promise<string> => {
339
+ let text = "";
340
+ let cursor: NativeDocumentResult["nextCursor"];
341
+ do {
342
+ const part = await this.native(original, operation, { firstPage: page, pageLimit: 1, ...(cursor ? { cursor } : {}) }, signal, isCurrent, deadline);
343
+ text += part.content.flatMap(item => item.type === "text" ? [item.text] : []).join("");
344
+ cursor = part.nextCursor;
345
+ } while (cursor);
346
+ return text;
347
+ };
348
+ const pages: { page: number; representation: DerivedPage["representation"] }[] = [], blocks: string[] = [];
349
+ let bytes = 0;
350
+ for (let page = 1; page <= total; page++) {
351
+ let text = await pageText("document.text", page);
352
+ let representation: DerivedPage["representation"] = VISIBLE.test(text) ? "native-text" : "rendered-image";
353
+ if (ocrPages(page, text)) {
354
+ const ocr = await pageText("document.ocr", page);
355
+ if (VISIBLE.test(ocr)) { text = ocr; representation = "ocr-text"; }
356
+ }
357
+ pages.push({ page, representation });
358
+ const block = `[page ${page}: ${representation}]\n${representation === "rendered-image" ? "[no extractable text; use view=pages]" : text}`;
359
+ bytes += Buffer.byteLength(block) + (blocks.length > 0 ? 2 : 0);
360
+ if (bytes > this.preparedTextBytes) throw new NativeDocumentError("processing_limit", "output_bytes");
361
+ blocks.push(block);
362
+ }
363
+ const text = blocks.join("\n\n");
364
+ const originalSha256 = sha(original), representationSha256 = sha(text), build = this.build();
365
+ const optionsSha256 = sha(JSON.stringify({ version: 1, format: "pdf", ocrPages: pages.filter(p => p.representation === "ocr-text").map(p => p.page) }));
366
+ const prepared: PreparedText = { format: "txt", encoding: "utf-8", originalSha256, representationSha256, optionsSha256, ...build,
367
+ representationKey: sha(JSON.stringify([originalSha256, build.helperSourceSha256, build.helperBuildSha256, optionsSha256])),
368
+ text, bytes, scalars: [...text].length };
369
+ return { prepared, pages: pages.map(({ page, representation }) => ({ page, representation })) };
370
+ }
371
+ private preparePdf(original: Uint8Array, textOnly: boolean, signal: AbortSignal, isCurrent: () => boolean): Promise<Derived> {
372
+ return this.pdfDocument(original, () => textOnly, signal, isCurrent);
373
+ }
374
+ private derivedPath(id: string, name: string): string { return join(this.o.runtimeHome, "attachments", id, "derived", name); }
375
+ private writeDerived(id: string, revision: number, derived: Derived): void {
376
+ mkdirSync(join(this.o.runtimeHome, "attachments", id, "derived"), { recursive: true, mode: 0o700 });
377
+ writeFileSync(this.derivedPath(id, `${revision}.json`), JSON.stringify(derived), { mode: 0o600 });
378
+ }
379
+ private readDerived(id: string, revision: number): Derived | null {
380
+ try { return JSON.parse(readFileSync(this.derivedPath(id, `${revision}.json`), "utf8")); }
381
+ catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; throw error; }
382
+ }
383
+ private lateOcrFile(id: string, revision: number): string { return this.derivedPath(id, `${revision}.ocr.json`); }
384
+ /** Late OCR keeps its own digest beside the approved representation, which it never overwrites. */
385
+ private readLateOcr(id: string, revision: number, originalSha256: string): Derived | null {
386
+ const ledger = this.o.store.attachments.lateOcrDigest(id, revision);
387
+ if (ledger === null) return null;
388
+ let derived: Derived;
389
+ try { derived = JSON.parse(readFileSync(this.lateOcrFile(id, revision), "utf8")); }
390
+ catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; throw error; }
391
+ if (derived.prepared.originalSha256 !== originalSha256 || sha(derived.prepared.text) !== ledger || derived.prepared.representationSha256 !== ledger) throw new AttachmentApiError("representation_mismatch", 409);
392
+ return derived;
393
+ }
394
+ status(principal: string, id: string) { return this.o.store.attachments.status(this.o.store.attachments.ownedScope(id, principal), id, this.gate); }
395
+ discard(principal: string, id: string, body: unknown): AttachmentJob {
396
+ if (!object(body) || typeof body.expectedRevision !== "number") throw new AttachmentApiError("invalid_input", 400);
397
+ const attachments = this.o.store.attachments, scope = attachments.ownedScope(id, principal);
398
+ const job = attachments.discard(scope, id, body.expectedRevision, this.gate);
399
+ this.aborts.get(id)?.abort();
400
+ this.update(principal, id, scope.convId);
401
+ return job;
402
+ }
403
+ revoke(principal: string, id: string, body: unknown) {
404
+ if (!object(body) || typeof body.grantId !== "string" || typeof body.expectedRevision !== "number") throw new AttachmentApiError("invalid_input", 400);
405
+ const attachments = this.o.store.attachments, scope = attachments.ownedScope(id, principal);
406
+ const receipt = attachments.revoke(scope, body.grantId, body.expectedRevision, this.gate);
407
+ this.stopOcrIfRevoked(id);
408
+ this.update(principal, id, scope.convId);
409
+ return { ...receipt, limits: REVOCATION_LIMITS };
410
+ }
411
+ private attachmentDir(id: string): string { return join(this.o.runtimeHome, "attachments", id); }
412
+ /** The tombstone is durable before the move, so a failed or interrupted move is retried and bytes are never removed any other way. */
413
+ private moveOut(id: string): Promise<void> {
414
+ const active = this.cleaning.get(id);
415
+ if (active) return active;
416
+ const move = (async () => {
417
+ const path = this.attachmentDir(id);
418
+ if (lstatSync(path, { throwIfNoEntry: false })) {
419
+ const moved = await (this.o.trash ?? moveToTrash)(path, "attachment");
420
+ if (!moved.ok || moved.method !== "trash") throw new AttachmentApiError("attachment_trash_failed", 503);
421
+ }
422
+ this.o.store.attachments.markTrashed(id);
423
+ })().finally(() => this.cleaning.delete(id));
424
+ this.cleaning.set(id, move);
425
+ return move;
426
+ }
427
+ private assertIdle(id: string): void {
428
+ if (this.running.has(id)) throw new AttachmentStoreError("attachment_busy");
429
+ }
430
+ private async cleanupReceipt(id: string) {
431
+ if (!this.o.store.attachments.tombstoneOf(id)?.trashed) await this.moveOut(id);
432
+ return { id, tombstone: this.o.store.attachments.tombstoneOf(id), limits: REVOCATION_LIMITS };
433
+ }
434
+ async cleanup(principal: string, id: string, body: unknown) {
435
+ if (!object(body) || (body.reason !== undefined && typeof body.reason !== "string")) throw new AttachmentApiError("invalid_input", 400);
436
+ const attachments = this.o.store.attachments, scope = attachments.ownedScope(id, principal);
437
+ this.assertOwner(principal);
438
+ if (!attachments.tombstoneOf(id)) {
439
+ this.assertIdle(id);
440
+ attachments.tombstone(scope, id, attachments.status(scope, id, this.gate).revision, body.reason ?? "owner_cleanup", this.gate);
441
+ }
442
+ return this.cleanupReceipt(id);
443
+ }
444
+ /** Housekeeping category: an operator-selected action, never a scheduled one. */
445
+ readonly housekeeping: AttachmentCleanupDeps = {
446
+ list: () => this.o.store.attachments.cleanupRows().map(row => ({ attachmentId: row.id, target: this.attachmentDir(row.id), state: row.state, bytes: row.bytes,
447
+ busy: { ...row.busy, preparing: this.running.has(row.id) ? 1 : 0 } })),
448
+ cleanup: async id => {
449
+ if (!this.o.store.attachments.tombstoneOf(id)) {
450
+ this.assertIdle(id);
451
+ this.o.store.attachments.operatorTombstone(id, "operator_cleanup");
452
+ }
453
+ await this.cleanupReceipt(id);
454
+ },
455
+ };
456
+ /** The caller has closed the exact session first; this never touches another conversation's activation. */
457
+ retireRecipient(convId: string, recipientId: string, hide: () => void): void {
458
+ for (const id of this.o.store.attachments.retireRecipient(convId, recipientId, hide)) this.aborts.get(id)?.abort();
459
+ for (const id of [...this.ocrJobs.keys()]) this.stopOcrIfRevoked(id);
460
+ this.o.bindings.retire(convId);
461
+ }
462
+ download(principal: string, id: string): { readonly bytes: Uint8Array; readonly name: string; readonly format: AttachmentFormat } {
463
+ const attachments = this.o.store.attachments, scope = attachments.ownedScope(id, principal);
464
+ attachments.status(scope, id, this.gate);
465
+ const info = attachments.info(id);
466
+ if (info.state === "revoked") throw new AttachmentStoreError("attachment_revoked", 410);
467
+ if (info.state === "discarded" || !info.sha256 || info.state === "snapshotting") throw new AttachmentStoreError("attachment_not_ready");
468
+ const bytes = this.readOriginal(id);
469
+ if (!bytes || sha(bytes) !== info.sha256) throw new AttachmentStoreError("attachment_original_unavailable", 410);
470
+ return { bytes, name: info.name, format: info.format };
471
+ }
472
+ /** Resolve, inspect and fix the exact manifest. Nothing is stored and no bytes reach the model here. */
473
+ async plan(request: SendRequest): Promise<AdmissionPlan> {
474
+ const attachments = this.o.store.attachments;
475
+ this.assertOwner(request.principal);
476
+ if (request.submissionId === undefined || !UUID.test(request.submissionId)) throw new AttachmentStoreError("invalid_attachment_input", 400);
477
+ const stableDigest = sha(JSON.stringify([request.principal, request.convId, request.text, request.images, request.attachments, request.references.map(r => r.seq)]));
478
+ const retry = attachments.retryReceipt(request.convId, request.submissionId, stableDigest);
479
+ const body = request.text.trim();
480
+ const empty = { stableDigest, admission: null, data: {}, body, references: request.references };
481
+ if (retry) return { retry, findings: null, ...empty };
482
+ const route = await this.route(request.convId);
483
+ for (const id of request.attachments) await this.prepareForRoute(id, request.principal, route);
484
+ const referenced = request.references.flatMap(reference => (reference.attachments ?? []).map(item => item.id));
485
+ const ids = [...new Set([...request.attachments, ...referenced])];
486
+ if (ids.length === 0 || ids.length + request.images.length > 4) throw new AttachmentStoreError(ids.length === 0 ? "invalid_attachment_input" : "too_many_attachments", ids.length === 0 ? 400 : 409);
487
+ const rows = ids.map(id => {
488
+ try { return attachments.manifest(id, request.principal); }
489
+ catch (error) {
490
+ if (referenced.includes(id) && !request.attachments.includes(id) && error instanceof AttachmentStoreError) throw new AttachmentStoreError("attachment_reference_unavailable", 410);
491
+ throw error;
492
+ }
493
+ });
494
+ const first = rows[0];
495
+ if (!first || rows.some(row => row.scope.convId !== request.convId)) throw new AttachmentStoreError("attachment_scope_mismatch");
496
+ const direct = request.attachments.length ? rows.filter(row => request.attachments.includes(row.manifest.attachmentId)) : rows;
497
+ const draftId = direct[0]?.scope.draftId ?? first.scope.draftId;
498
+ if (direct.some(row => row.scope.draftId !== draftId)) throw new AttachmentStoreError("attachment_scope_mismatch");
499
+ const texts = rows.map(row => {
500
+ const derived = this.readDerived(row.manifest.attachmentId, row.manifest.preparationRevision);
501
+ if (!derived || sha(derived.prepared.text) !== row.preparation.inspectedTextSha256) throw new AttachmentStoreError("attachment_manifest_conflict");
502
+ return derived.prepared.text;
503
+ });
504
+ const manifests: AttachmentManifest[] = rows.map(row => row.manifest);
505
+ const text = body || `[Attached: ${rows.map(row => row.name).join(", ")}]`;
506
+ const data = { text, src: request.src, trust: request.trust, ...(body ? {} : { documentFallback: true }),
507
+ ...(request.images.length ? { images: [...request.images] } : {}), ...(request.references.length ? { references: request.references } : {}),
508
+ ...(request.attachments.length ? { attachments: [...request.attachments] } : {}) };
509
+ const base = { ...first.scope, draftId, ownerPrincipal: request.principal, submissionId: request.submissionId, text, attachments: manifests,
510
+ inspectedTextSha256: inspectionDigest(text, rows.map(row => row.preparation)) };
511
+ const { attachmentRequestDigest } = await import("./store.ts");
512
+ const admission: AttachmentAdmission = { ...base, requestDigest: attachmentRequestDigest(base, data) };
513
+ return { retry: null, findings: this.o.guard?.check([body, ...texts]) ?? null, stableDigest, admission, data, body, references: request.references };
514
+ }
515
+ commit(plan: AdmissionPlan, receipt?: (ref: SecurityReceipt) => void): CommittedAdmission {
516
+ const prompt = (attachments: readonly AttachmentMetadata[]) => documentPrompt(plan.body, attachments);
517
+ if (plan.retry) return { ...plan.retry, fresh: false, prompt: prompt(plan.retry.attachments) };
518
+ if (!plan.admission) throw new AttachmentStoreError("invalid_attachment_input", 400);
519
+ const accepted = this.o.store.attachments.admitOnce(plan.admission, plan.data, this.gate, plan.stableDigest, receipt);
520
+ return { ...accepted, fresh: true, prompt: prompt(accepted.attachments) };
521
+ }
522
+ async send(request: SendRequest): Promise<CommittedAdmission> { return this.commit(await this.plan(request)); }
523
+
524
+ private encodeCursor(state: CursorState): string { return Buffer.from(JSON.stringify(state)).toString("base64url"); }
525
+ private decodeCursor(raw: unknown, expected: Omit<CursorState, "o">): number {
526
+ if (raw === undefined) return 0;
527
+ if (typeof raw !== "string" || raw.length > 512) throw new AttachmentApiError("invalid_cursor", 400);
528
+ let state: unknown;
529
+ try { state = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); }
530
+ catch { throw new AttachmentApiError("invalid_cursor", 400); }
531
+ if (!object(state) || state.g !== expected.g || state.a !== expected.a || state.r !== expected.r || state.v !== expected.v ||
532
+ typeof state.o !== "number" || !Number.isSafeInteger(state.o) || state.o <= 0) throw new AttachmentApiError("invalid_cursor", 400);
533
+ return state.o;
534
+ }
535
+ /** Shared parser entry. It issues no grant and resolves no path; the gateway assembles resourceRef and receipt. */
536
+ async readAuthorizedDocument(input: { readonly resourceRef: { readonly kind: "owner_attachment"; readonly attachmentId: string } | { readonly kind: "collaboration_artifact"; readonly artifactId: string; readonly requestId: string };
537
+ readonly authorizationReceipt: SelectedAttachmentExport; readonly view: "text" | "pages"; readonly cursor?: unknown; readonly limit?: unknown }): Promise<Record<string, unknown>> {
538
+ if (input.resourceRef.kind !== "owner_attachment") throw new AttachmentApiError("collaboration_artifact_unavailable", 501);
539
+ const { attachmentId } = input.resourceRef, receipt = input.authorizationReceipt;
540
+ if (receipt.attachmentId !== attachmentId) throw new AttachmentStoreError("attachment_scope_mismatch");
541
+ const attachments = this.o.store.attachments, manifest = attachments.preparationOf(attachmentId), approved = this.readDerived(attachmentId, manifest.preparationRevision);
542
+ if (!approved || sha(approved.prepared.text) !== manifest.representationSha256) throw new AttachmentApiError("representation_mismatch", 409);
543
+ const late = input.view === "text" ? this.readLateOcr(attachmentId, manifest.preparationRevision, receipt.originalSha256) : null;
544
+ const derived = late ?? approved, representationSha256 = derived.prepared.representationSha256;
545
+ const expected = { g: receipt.sourceGrantId, a: attachmentId, r: representationSha256 };
546
+ const base = { attachmentId, originalSha256: receipt.originalSha256, representationSha256, truncated: false as const };
547
+ if (input.view === "pages") {
548
+ if (receipt.format !== "pdf" || !derived.pages) throw new AttachmentApiError("view_unavailable", 422, { supportedViews: ["text"] });
549
+ const start = this.decodeCursor(input.cursor, { ...expected, v: "pages" }) || 1;
550
+ const count = input.limit === undefined ? 2 : Number(input.limit);
551
+ if (!Number.isSafeInteger(count) || count < 1 || count > 2 || start > derived.pages.length) throw new AttachmentApiError("invalid_read_input", 400);
552
+ const original = this.readOriginal(attachmentId);
553
+ if (!original || sha(original) !== receipt.originalSha256) throw new AttachmentStoreError("attachment_original_unavailable", 410);
554
+ const signal = new AbortController().signal;
555
+ const result = await this.native(original, "document.pages", { firstPage: start, pageLimit: count }, signal, () => true);
556
+ const images = result.content.filter(item => item.type === "image").length, requested = Math.min(count, derived.pages.length - start + 1);
557
+ if (result.originalSha256 !== receipt.originalSha256 || images < requested || result.coverage.pages.length !== requested) throw new AttachmentApiError("representation_incomplete", 409);
558
+ const next = start + requested;
559
+ return { ...base, view: "pages", coverage: { totalPages: derived.pages.length, pages: result.coverage.pages.map(p => ({ page: p.page, representation: "rendered-image" })) }, content: result.content,
560
+ ...(next <= derived.pages.length ? { nextCursor: this.encodeCursor({ ...expected, v: "pages", o: next }) } : {}) };
561
+ }
562
+ const limit = input.limit === undefined ? 2000 : Number(input.limit);
563
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2000) throw new AttachmentApiError("invalid_read_input", 400);
564
+ const offset = this.decodeCursor(input.cursor, { ...expected, v: "text" });
565
+ const p = derived.prepared;
566
+ if (offset >= p.scalars && offset > 0) throw new AttachmentApiError("invalid_cursor", 400);
567
+ const part = readPreparedText(p, { ...(offset ? { cursor: { originalSha256: p.originalSha256, representationSha256: p.representationSha256, representationKey: p.representationKey, offset } } : {}), lineLimit: limit });
568
+ const coverage = derived.pages ? { totalPages: derived.pages.length, pages: derived.pages } : { totalPages: 1, pages: [{ page: 1, representation: "native-text" }] };
569
+ return { ...base, view: "text", coverage, content: [{ type: "text", text: part.text }],
570
+ ...(part.nextCursor ? { nextCursor: this.encodeCursor({ ...expected, v: "text", o: part.nextCursor.offset }) } : {}) };
571
+ }
572
+ /** Later text OCR is inspected on its own; an earlier approval never covers it. */
573
+ private async lateOcr(attachmentId: string, receipt: SelectedAttachmentExport, derived: Derived, conv: string, signal: AbortSignal): Promise<void> {
574
+ const revision = this.o.store.attachments.preparationOf(attachmentId).preparationRevision;
575
+ if (!this.o.helper || !derived.pages?.some(page => page.representation === "rendered-image") ||
576
+ this.readLateOcr(attachmentId, revision, receipt.originalSha256)) return;
577
+ const original = this.readOriginal(attachmentId);
578
+ if (!original) return;
579
+ const rendered = new Set(derived.pages.filter(page => page.representation === "rendered-image").map(page => page.page));
580
+ await this.documentOcr(attachmentId, `late:${revision}`, signal, async jobSignal => {
581
+ const updated = await this.pdfDocument(original, page => rendered.has(page), jobSignal, () => true);
582
+ if (updated.prepared.originalSha256 !== receipt.originalSha256) throw new AttachmentApiError("representation_mismatch", 409);
583
+ if (this.o.guard?.check([updated.prepared.text])) {
584
+ this.o.notice?.(conv, "attachment_text_held");
585
+ throw new AttachmentApiError("attachment_text_held", 409);
586
+ }
587
+ const manifest = this.o.store.attachments.preparationOf(attachmentId);
588
+ writeFileSync(this.lateOcrFile(attachmentId, manifest.preparationRevision), JSON.stringify(updated), { mode: 0o600 });
589
+ this.o.store.attachments.recordLateOcr(attachmentId, manifest.preparationRevision, updated.prepared.representationSha256);
590
+ return updated;
591
+ });
592
+ }
593
+ /** Managed tool entry: authenticated identity selects the recipient, and revocation decides at the release instant. */
594
+ async managedRead(call: VerifiedManagedCall, params: unknown, signal: AbortSignal = new AbortController().signal): Promise<Record<string, unknown>> {
595
+ if (!object(params) || Object.keys(params).some(key => !["attachmentId", "view", "cursor", "limit"].includes(key)) ||
596
+ typeof params.attachmentId !== "string" || (params.view !== "text" && params.view !== "pages")) throw new AttachmentApiError("invalid_read_input", 400);
597
+ if (call.operation !== "attachment.read") throw new AttachmentApiError("invalid_read_input", 400);
598
+ const attachments = this.o.store.attachments, attachmentId = params.attachmentId;
599
+ const recipient = { kind: call.role, id: call.agentId ?? "main" };
600
+ const receipt = attachments.activeGrant(attachmentId, call.convId, recipient, this.gate);
601
+ if (receipt.sourceConvEpoch !== call.convEpoch || receipt.sourceRecipientEpoch !== call.lifecycleEpoch) throw new AttachmentStoreError("attachment_scope_mismatch");
602
+ const lease = attachments.acquireRead(receipt, this.gate);
603
+ try {
604
+ const manifest = attachments.preparationOf(attachmentId);
605
+ if (params.view === "text") {
606
+ const derived = this.readDerived(attachmentId, manifest.preparationRevision);
607
+ if (derived) await this.lateOcr(attachmentId, receipt, derived, call.convId, signal);
608
+ }
609
+ const result = await this.readAuthorizedDocument({ resourceRef: { kind: "owner_attachment", attachmentId }, authorizationReceipt: receipt, view: params.view, cursor: params.cursor, limit: params.limit });
610
+ await this.o.beforeRelease?.();
611
+ if (!this.o.bindings.isCurrent(call)) throw new AttachmentApiError("stale_activation", 409);
612
+ attachments.releaseRead(lease, this.gate);
613
+ return result;
614
+ } catch (error) { attachments.abandonRead(lease); throw error; }
615
+ }
616
+ /** Engine ingress for one admitted native PDF. It checks the digest, stores only a reference and never forwards these bytes itself. */
617
+ dispatchDocuments(convId: string, attachments: readonly AttachmentMetadata[]) {
618
+ const documents = attachments.map(item => {
619
+ const receipt = this.o.store.attachments.grantReceipt(item.grantId, item.id, convId, this.recipientOf(convId), this.gate);
620
+ return { type: "document" as const, mimeType: "application/pdf" as const, name: item.name, data: Buffer.from(this.sealed(receipt)).toString("base64"),
621
+ provenance: { attachmentId: item.id, grantId: item.grantId, originalSha256: receipt.originalSha256 } };
622
+ });
623
+ return { documents, attachments: [...attachments] };
624
+ }
625
+ private recipientOf(convId: string): { readonly kind: "main" | "agent"; readonly id: string } {
626
+ const agentId = this.o.store.getConversation(convId)?.agentId;
627
+ return agentId ? { kind: "agent", id: agentId } : { kind: "main", id: "main" };
628
+ }
629
+ private sealed(receipt: SelectedAttachmentExport): Uint8Array {
630
+ const bytes = this.readOriginal(receipt.attachmentId);
631
+ if (!bytes || sha(bytes) !== receipt.originalSha256) throw new AttachmentStoreError("attachment_original_unavailable", 410);
632
+ return bytes;
633
+ }
634
+ /** One delivery in the engine's envelope. Called only after the grant was confirmed for this provider request. */
635
+ private async representation(receipt: SelectedAttachmentExport, mode: DocumentRoute, signal: AbortSignal): Promise<Record<string, unknown>> {
636
+ const id = receipt.attachmentId, manifest = this.o.store.attachments.preparationOf(id), derived = this.readDerived(id, manifest.preparationRevision);
637
+ if (!derived?.pages || sha(derived.prepared.text) !== manifest.representationSha256) throw new AttachmentApiError("representation_mismatch", 409);
638
+ const totalPages = derived.pages.length, bytes = this.sealed(receipt);
639
+ let content: readonly unknown[], pages: readonly { page: number; representation: string }[], nextCursor: string | undefined, estimatedTokens: number;
640
+ if (mode === "native-document") {
641
+ content = [{ type: "document", mimeType: "application/pdf", name: receipt.name, data: Buffer.from(bytes).toString("base64"),
642
+ provenance: { attachmentId: id, grantId: receipt.sourceGrantId, originalSha256: receipt.originalSha256 } }];
643
+ pages = derived.pages.map(page => ({ page: page.page, representation: "rendered-image" }));
644
+ estimatedTokens = Math.max(1000, totalPages * 3000);
645
+ } else if (mode === "pages") {
646
+ const count = Math.min(2, totalPages);
647
+ const result = await this.native(bytes, "document.pages", { firstPage: 1, pageLimit: count }, signal, () => true);
648
+ if (result.originalSha256 !== receipt.originalSha256 || result.content.filter(item => item.type === "image").length !== count) throw new AttachmentApiError("representation_incomplete", 409);
649
+ // The engine verifies the digest over its own parsed key order.
650
+ content = result.content.map(item => item.type === "image" ? { type: "image", mimeType: item.mimeType, data: item.data } : { type: "text", text: item.text });
651
+ pages = result.coverage.pages.map(page => ({ page: page.page, representation: "rendered-image" }));
652
+ if (count < totalPages) nextCursor = this.encodeCursor({ g: receipt.sourceGrantId, a: id, r: manifest.representationSha256, v: "pages", o: count + 1 });
653
+ estimatedTokens = count * 3000;
654
+ } else {
655
+ const ocr = manifest.readMode === "ocr" ? derived : await this.documentOcr(id, `full:${manifest.preparationRevision}`, signal, jobSignal => this.pdfDocument(bytes, () => true, jobSignal, () => true));
656
+ if (manifest.readMode !== "ocr" && this.o.guard?.check([ocr.prepared.text])) throw new AttachmentApiError("attachment_text_held", 409);
657
+ if (ocr.pages?.some(page => page.representation !== "ocr-text")) throw new AttachmentApiError("representation_incomplete", 409);
658
+ const part = readPreparedText(ocr.prepared, {});
659
+ content = [{ type: "text", text: part.text }];
660
+ pages = ocr.pages ?? [];
661
+ if (part.nextCursor) nextCursor = this.encodeCursor({ g: receipt.sourceGrantId, a: id, r: ocr.prepared.representationSha256, v: "text", o: part.nextCursor.offset });
662
+ estimatedTokens = Math.ceil(Buffer.byteLength(part.text) / 3);
663
+ }
664
+ const coverage = { totalPages, pages };
665
+ const cursor = nextCursor === undefined ? {} : { nextCursor };
666
+ return { status: "ready", originalSha256: receipt.originalSha256, representationSha256: manifest.representationSha256,
667
+ payloadSha256: sha(JSON.stringify({ content, coverage, ...cursor })), grantRevision: receipt.sourceGrantRevision, estimatedTokens, content, coverage, ...cursor };
668
+ }
669
+ /** Engine resolver entry: signed session, exact live grant, epochs and digest, decided again at release. */
670
+ async managedDocument(call: VerifiedManagedCall, params: unknown, signal: AbortSignal = new AbortController().signal): Promise<Record<string, unknown>> {
671
+ if (!object(params) || Object.keys(params).some(key => !["attachmentId", "grantId", "originalSha256", "mode"].includes(key)) || call.operation !== "attachment.document" ||
672
+ typeof params.attachmentId !== "string" || typeof params.grantId !== "string" || typeof params.originalSha256 !== "string" ||
673
+ (params.mode !== "native-document" && params.mode !== "pages" && params.mode !== "ocr")) throw new AttachmentApiError("invalid_read_input", 400);
674
+ const attachments = this.o.store.attachments, unavailable = { status: "attachment_unavailable" };
675
+ const label = (error: unknown): Record<string, unknown> => {
676
+ if (error instanceof AttachmentStoreError) return error.code === "attachment_revoked" ? { status: "attachment_revoked" } : unavailable;
677
+ if (error instanceof AttachmentApiError) return error.code === "attachment_text_held" ? { status: "attachment_text_held" } : unavailable;
678
+ if (error instanceof NativeDocumentError) return unavailable;
679
+ throw error;
680
+ };
681
+ let lease: string;
682
+ let receipt: SelectedAttachmentExport;
683
+ try {
684
+ receipt = attachments.grantReceipt(params.grantId, params.attachmentId, call.convId, { kind: call.role, id: call.agentId ?? "main" }, this.gate);
685
+ if (receipt.originalSha256 !== params.originalSha256 || receipt.sourceConvEpoch !== call.convEpoch || receipt.sourceRecipientEpoch !== call.lifecycleEpoch) return unavailable;
686
+ lease = attachments.acquireRead(receipt, this.gate);
687
+ } catch (error) { return label(error); }
688
+ try {
689
+ const result = await this.representation(receipt, params.mode, signal);
690
+ await this.o.beforeRelease?.();
691
+ if (!this.o.bindings.isCurrent(call)) { attachments.abandonRead(lease); return unavailable; }
692
+ attachments.releaseRead(lease, this.gate);
693
+ return result;
694
+ } catch (error) { attachments.abandonRead(lease); return label(error); }
695
+ }
696
+ /** C9-2 standalone provider. 292 owns the request transaction, target policy and recipient grant. */
697
+ async exportSelectedAttachment(input: { readonly source: { readonly convId: string; readonly recipient: { readonly kind: "main" | "agent"; readonly id: string } }; readonly attachmentId: string; readonly targetAgentId: string; readonly operationKey: string }) {
698
+ const attachments = this.o.store.attachments;
699
+ if (typeof input.operationKey !== "string" || !input.operationKey || typeof input.targetAgentId !== "string" || !input.targetAgentId) throw new AttachmentStoreError("invalid_attachment_input", 400);
700
+ const eligible = attachments.activeGrant(input.attachmentId, input.source.convId, input.source.recipient, this.gate);
701
+ const receipt = attachments.recordExport(input.operationKey, sha(JSON.stringify([input.source, input.attachmentId, input.targetAgentId, eligible.originalSha256])), input.attachmentId, eligible);
702
+ const bytes = this.readOriginal(input.attachmentId);
703
+ if (!bytes || sha(bytes) !== receipt.originalSha256) throw new AttachmentStoreError("attachment_original_unavailable", 410);
704
+ return { bytes, sha256: receipt.originalSha256, format: receipt.format, name: receipt.name, receipt };
705
+ }
706
+ assertSelectedExportEligible(tx: Database, receipt: SelectedAttachmentExport): undefined {
707
+ return this.o.store.attachments.assertSelectedExportEligible(tx, receipt, this.gate);
708
+ }
709
+ /** Startup (C5): dead readers are gone, workers resume only from sealed bytes, and usable records are compared with their bytes. */
710
+ recover(): void {
711
+ const attachments = this.o.store.attachments;
712
+ attachments.clearReadLeases();
713
+ for (const row of attachments.unsettled()) {
714
+ if (attachments.recover(row.id, row.revision, randomUUID(), this.gate).state !== "sealed") continue;
715
+ const job = attachments.resumePreparation(row.id, randomUUID());
716
+ this.track(row.id, job.principal, job.convId, async signal => {
717
+ await Promise.resolve();
718
+ await this.prepareBody(job, job.encoding ?? undefined, job.convId, signal);
719
+ });
720
+ }
721
+ for (const row of attachments.integrityRows()) {
722
+ const original = this.originalFacts(row.id);
723
+ if (original?.bytes !== row.bytes || original.sha256 !== row.sha256) attachments.markUnavailable(row.id);
724
+ }
725
+ }
726
+ }
727
+
728
+ export const deviceRevision = (device: { readonly secretHash: string; readonly createdAt: string }): string =>
729
+ sha(JSON.stringify([device.secretHash, device.createdAt])).slice(0, 32);