@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,655 @@
1
+ import {
2
+ VfsAuthError,
3
+ VfsHttpError,
4
+ type VfsAbility,
5
+ type VfsAuthErrorKind,
6
+ } from './vfs-errors';
7
+
8
+ /**
9
+ * Typed HTTP client for the IXO Virtual Filesystem worker (routes under
10
+ * `/api/fs/*`).
11
+ *
12
+ * Every method maps to exactly ONE worker endpoint and parses that endpoint's
13
+ * documented response shape (the worker's `src/schemas/fs.ts`): `/tree` →
14
+ * `{ nodes }`, `/glob` → `{ files }`, `/search` → `{ hits }`, `/grep` →
15
+ * `{ matches }`, `/files/:id/read` → `{ text, offset, count, … }`, batch ops →
16
+ * `{ results, succeeded, failed }`. No field-name guessing — if the worker
17
+ * contract changes, exactly one parser here changes with it.
18
+ *
19
+ * Per request the client mints a fresh single-use UCAN bearer, applies a
20
+ * per-request timeout layered on the caller's abort signal, and maps every
21
+ * non-2xx to a typed {@link VfsHttpError}. Retries match the worker's
22
+ * idempotency + replay rules:
23
+ * - idempotent GETs: one retry on 429 / 5xx / network error;
24
+ * - any request: one retry on 401 (re-mint a fresh bearer);
25
+ * - 409 write-conflict retries live in the tool layer, not here.
26
+ */
27
+
28
+ /** Mints a bearer for an ability. Returns an auth error instead of throwing. */
29
+ export type VfsMintFn = (
30
+ ability: VfsAbility,
31
+ ) => Promise<{ bearer: string } | { error: VfsAuthErrorKind; detail?: string }>;
32
+
33
+ export interface VfsClientOptions {
34
+ /** Worker file-API base, e.g. `https://devnet.vfs.ixo.earth/api/fs`. */
35
+ baseUrl: string;
36
+ mint: VfsMintFn;
37
+ timeoutMs: number;
38
+ signal?: AbortSignal;
39
+ fetchImpl?: typeof fetch;
40
+ /** Fixed backoff before the one permitted retry. Default 250ms. */
41
+ retryDelayMs?: number;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Result shapes (a faithful subset of the worker schemas — only the fields the
46
+ // oracle's tools consume).
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** A file's identity + display metadata (worker `FileMetadata`). */
50
+ export interface VfsFileStat {
51
+ id: string;
52
+ path: string;
53
+ name: string;
54
+ mimeType: string;
55
+ size: number;
56
+ /** Anyone-can-download link, present only when the file is public. */
57
+ publicUrl?: string;
58
+ }
59
+
60
+ /** One entry in a directory listing (worker `TreeNode`). */
61
+ export interface VfsTreeEntry {
62
+ path: string;
63
+ name: string;
64
+ type: 'file' | 'folder';
65
+ id?: string;
66
+ mimeType?: string;
67
+ size?: number;
68
+ }
69
+
70
+ /** One search/grep hit, normalised across `/search` and `/grep`. */
71
+ export interface VfsSearchHit {
72
+ path: string;
73
+ /** `/search` returns `fileId`; `/grep` returns `id`. */
74
+ id?: string;
75
+ /** 1-based cited line range (search hits) — pairs with {@link VfsClient.readLines}. */
76
+ lineStart?: number;
77
+ lineEnd?: number;
78
+ /** Highlighted snippet: `/search` `preview` or `/grep` `snippet`. */
79
+ snippet?: string;
80
+ score?: number;
81
+ }
82
+
83
+ export interface VfsSearchResult {
84
+ results: VfsSearchHit[];
85
+ /** `false` when the semantic engine was down and only lexical hits returned. */
86
+ semantic: boolean;
87
+ }
88
+
89
+ /** A glob match (worker `GlobResult.files[]`). */
90
+ export interface VfsGlobMatch {
91
+ path: string;
92
+ id?: string;
93
+ }
94
+
95
+ /** A window of a text file's contents (worker `ReadResult`). */
96
+ export interface VfsReadWindow {
97
+ /** Server-rendered numbered text (cat -n style), ready to display as-is. */
98
+ text: string;
99
+ /** 1-based line number of the first line in this window. */
100
+ offset: number;
101
+ /** Number of lines in this window. */
102
+ count: number;
103
+ hasMore: boolean;
104
+ totalLines: number;
105
+ }
106
+
107
+ export interface VfsContentBytes {
108
+ bytes: ArrayBuffer;
109
+ mimeType: string;
110
+ size: number;
111
+ }
112
+
113
+ /** One item's outcome in a batch move/delete (worker `BatchResult.results[]`). */
114
+ export interface VfsBatchItemResult {
115
+ id: string;
116
+ ok: boolean;
117
+ status: number;
118
+ path?: string;
119
+ error?: string;
120
+ }
121
+
122
+ export interface VfsPublicResult {
123
+ public: boolean;
124
+ publicUrl?: string;
125
+ }
126
+
127
+ export interface VfsEditResult {
128
+ replacements?: number;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Defensive JSON access (type-guard based — no assertions).
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function isRecord(v: unknown): v is Record<string, unknown> {
136
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
137
+ }
138
+
139
+ function str(v: unknown): string | undefined {
140
+ return typeof v === 'string' ? v : undefined;
141
+ }
142
+
143
+ function num(v: unknown): number | undefined {
144
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
145
+ }
146
+
147
+ function bool(v: unknown): boolean | undefined {
148
+ return typeof v === 'boolean' ? v : undefined;
149
+ }
150
+
151
+ /** The array under `key`, or `[]` when absent/not an array. */
152
+ function arrayAt(body: unknown, key: string): unknown[] {
153
+ if (isRecord(body) && Array.isArray(body[key])) return body[key];
154
+ return [];
155
+ }
156
+
157
+ function basename(p: string): string {
158
+ const i = p.lastIndexOf('/');
159
+ return i >= 0 ? p.slice(i + 1) : p;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Response parsers (each pinned to one worker schema).
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Worker `FileMetadata` → {@link VfsFileStat}. Returns null without an id. */
167
+ function parseFile(v: unknown, fallbackPath?: string): VfsFileStat | null {
168
+ if (!isRecord(v)) return null;
169
+ const id = str(v.id);
170
+ if (!id) return null;
171
+ const path = str(v.path) ?? fallbackPath ?? '';
172
+ return {
173
+ id,
174
+ path,
175
+ name: str(v.name) ?? basename(path),
176
+ mimeType: str(v.mimeType) ?? '',
177
+ size: num(v.size) ?? 0,
178
+ publicUrl: str(v.publicUrl),
179
+ };
180
+ }
181
+
182
+ /** Worker `TreeNode` → {@link VfsTreeEntry}. */
183
+ function parseTreeEntry(v: unknown): VfsTreeEntry | null {
184
+ if (!isRecord(v)) return null;
185
+ const path = str(v.path);
186
+ if (!path) return null;
187
+ return {
188
+ path,
189
+ name: str(v.name) ?? basename(path),
190
+ type: str(v.type) === 'folder' ? 'folder' : 'file',
191
+ id: str(v.id),
192
+ mimeType: str(v.mimeType),
193
+ size: num(v.size),
194
+ };
195
+ }
196
+
197
+ /** Worker `SearchHit` → {@link VfsSearchHit}. */
198
+ function parseSearchHit(v: unknown): VfsSearchHit | null {
199
+ if (!isRecord(v)) return null;
200
+ const path = str(v.path);
201
+ if (!path) return null;
202
+ return {
203
+ path,
204
+ id: str(v.fileId),
205
+ lineStart: num(v.lineStart),
206
+ lineEnd: num(v.lineEnd),
207
+ snippet: str(v.preview),
208
+ score: num(v.score),
209
+ };
210
+ }
211
+
212
+ /** Worker `GrepMatch` (FileMetadata + snippet) → {@link VfsSearchHit}. */
213
+ function parseGrepMatch(v: unknown): VfsSearchHit | null {
214
+ if (!isRecord(v)) return null;
215
+ const path = str(v.path);
216
+ if (!path) return null;
217
+ return { path, id: str(v.id), snippet: str(v.snippet) };
218
+ }
219
+
220
+ /** Worker `BatchResult.results[]` → {@link VfsBatchItemResult}[]. */
221
+ function parseBatchResults(body: unknown): VfsBatchItemResult[] {
222
+ return arrayAt(body, 'results').flatMap((v) => {
223
+ if (!isRecord(v)) return [];
224
+ return [
225
+ {
226
+ id: str(v.id) ?? '',
227
+ ok: bool(v.ok) ?? false,
228
+ status: num(v.status) ?? 0,
229
+ path: str(v.path),
230
+ error: str(v.error),
231
+ },
232
+ ];
233
+ });
234
+ }
235
+
236
+ function nonNull<T>(v: T | null): v is T {
237
+ return v !== null;
238
+ }
239
+
240
+ /** Exact-length `ArrayBuffer` copy of a view. */
241
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
242
+ const out = new ArrayBuffer(view.byteLength);
243
+ new Uint8Array(out).set(view);
244
+ return out;
245
+ }
246
+
247
+ interface RequestInitLite {
248
+ body?: string | Uint8Array;
249
+ contentType?: string;
250
+ accept?: string;
251
+ }
252
+
253
+ export class VfsClient {
254
+ private readonly baseUrl: string;
255
+
256
+ private readonly mint: VfsMintFn;
257
+
258
+ private readonly timeoutMs: number;
259
+
260
+ private readonly callerSignal?: AbortSignal;
261
+
262
+ private readonly fetchImpl: typeof fetch;
263
+
264
+ private readonly retryDelayMs: number;
265
+
266
+ constructor(opts: VfsClientOptions) {
267
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
268
+ this.mint = opts.mint;
269
+ this.timeoutMs = opts.timeoutMs;
270
+ this.callerSignal = opts.signal;
271
+ // Bind the global: `this.fetchImpl(...)` hands the client instance as
272
+ // `this`, and workerd's fetch throws "Illegal invocation" for any `this`
273
+ // other than the global scope.
274
+ this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
275
+ this.retryDelayMs = opts.retryDelayMs ?? 250;
276
+ }
277
+
278
+ // -------------------------------------------------------------------------
279
+ // Read / search
280
+ // -------------------------------------------------------------------------
281
+
282
+ /** Resolve a path to its file via `/glob` (exact-path match). */
283
+ async statByPath(path: string): Promise<VfsFileStat | null> {
284
+ const res = await this.get('fs/list', `/glob?pattern=${enc(path)}`);
285
+ const first = arrayAt(await this.json(res), 'files')[0];
286
+ return first === undefined ? null : parseFile(first, path);
287
+ }
288
+
289
+ /** List the direct children of a folder (`/tree`). */
290
+ async list(path: string): Promise<VfsTreeEntry[]> {
291
+ const res = await this.get('fs/list', `/tree?path=${enc(path)}`);
292
+ return arrayAt(await this.json(res), 'nodes')
293
+ .map(parseTreeEntry)
294
+ .filter(nonNull);
295
+ }
296
+
297
+ /** Hybrid lexical + semantic search over indexed content (`/search`). */
298
+ async search(q: string, path: string): Promise<VfsSearchResult> {
299
+ const res = await this.get(
300
+ 'fs/read',
301
+ `/search?q=${enc(q)}&path=${enc(path)}`,
302
+ );
303
+ const body = await this.json(res);
304
+ return {
305
+ results: arrayAt(body, 'hits').map(parseSearchHit).filter(nonNull),
306
+ semantic: (isRecord(body) && bool(body.semantic)) ?? true,
307
+ };
308
+ }
309
+
310
+ /** Literal term search inside files (`/grep`). */
311
+ async grep(q: string, path: string): Promise<VfsSearchHit[]> {
312
+ const res = await this.get(
313
+ 'fs/read',
314
+ `/grep?q=${enc(q)}&path=${enc(path)}`,
315
+ );
316
+ return arrayAt(await this.json(res), 'matches')
317
+ .map(parseGrepMatch)
318
+ .filter(nonNull);
319
+ }
320
+
321
+ /** Match files by path pattern (`/glob`). */
322
+ async glob(pattern: string): Promise<VfsGlobMatch[]> {
323
+ const res = await this.get('fs/list', `/glob?pattern=${enc(pattern)}`);
324
+ return arrayAt(await this.json(res), 'files').flatMap((v) => {
325
+ const f = parseFile(v);
326
+ return f ? [{ path: f.path, id: f.id }] : [];
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Read a window of a text file as server-numbered lines (`/files/:id/read`).
332
+ * `offset` is 1-based — the worker rejects `offset < 1` with a 400.
333
+ */
334
+ async readLines(
335
+ id: string,
336
+ offset: number,
337
+ limit: number,
338
+ ): Promise<VfsReadWindow> {
339
+ const res = await this.get(
340
+ 'fs/read',
341
+ `/files/${enc(id)}/read?offset=${offset}&limit=${limit}`,
342
+ );
343
+ const r = asRecord(await this.json(res));
344
+ return {
345
+ text: str(r.text) ?? '',
346
+ offset: num(r.offset) ?? offset,
347
+ count: num(r.count) ?? 0,
348
+ hasMore: bool(r.hasMore) ?? false,
349
+ totalLines: num(r.totalLines) ?? 0,
350
+ };
351
+ }
352
+
353
+ /** Download a file's raw bytes (`/files/:id/content`) — for binaries. */
354
+ async contentBytes(id: string): Promise<VfsContentBytes> {
355
+ const res = await this.get('fs/read', `/files/${enc(id)}/content`, {});
356
+ const bytes = await res.arrayBuffer();
357
+ const mimeType =
358
+ res.headers.get('content-type')?.split(';')[0]?.trim() ||
359
+ 'application/octet-stream';
360
+ return { bytes, mimeType, size: bytes.byteLength };
361
+ }
362
+
363
+ // -------------------------------------------------------------------------
364
+ // Write / organise
365
+ // -------------------------------------------------------------------------
366
+
367
+ /** Create a file at `path` (`POST /files?path=`). 409 if it already exists. */
368
+ async create(
369
+ path: string,
370
+ body: string | Uint8Array,
371
+ mime: string,
372
+ ): Promise<VfsFileStat> {
373
+ const res = await this.send(
374
+ 'fs/write',
375
+ 'POST',
376
+ `/files?path=${enc(path)}`,
377
+ {
378
+ body,
379
+ contentType: mime,
380
+ accept: 'application/json',
381
+ },
382
+ );
383
+ return parseFile(await this.json(res), path) ?? emptyStat(path);
384
+ }
385
+
386
+ /** Replace a file's whole content (`PUT /files/:id`). */
387
+ async replace(
388
+ id: string,
389
+ body: string | Uint8Array,
390
+ mime: string,
391
+ ): Promise<VfsFileStat> {
392
+ const res = await this.send('fs/write', 'PUT', `/files/${enc(id)}`, {
393
+ body,
394
+ contentType: mime,
395
+ accept: 'application/json',
396
+ });
397
+ return parseFile(await this.json(res)) ?? emptyStat('');
398
+ }
399
+
400
+ /** Exact-string edit (`PATCH /files/:id/edit`). */
401
+ async edit(
402
+ id: string,
403
+ oldString: string,
404
+ newString: string,
405
+ replaceAll: boolean,
406
+ ): Promise<VfsEditResult> {
407
+ const res = await this.send('fs/write', 'PATCH', `/files/${enc(id)}/edit`, {
408
+ body: JSON.stringify({ oldString, newString, replaceAll }),
409
+ contentType: 'application/json',
410
+ accept: 'application/json',
411
+ });
412
+ return { replacements: num(asRecord(await this.json(res)).replacements) };
413
+ }
414
+
415
+ /**
416
+ * Move/rename files (`POST /batch/move`). Each item is `{ id,
417
+ * destinationPath }` — resolve source paths to ids via {@link statByPath}
418
+ * first (the worker addresses moves by id, not source path).
419
+ */
420
+ async move(
421
+ items: Array<{ id: string; destinationPath: string }>,
422
+ ): Promise<VfsBatchItemResult[]> {
423
+ const res = await this.send('fs/write', 'POST', `/batch/move`, {
424
+ body: JSON.stringify({ items }),
425
+ contentType: 'application/json',
426
+ accept: 'application/json',
427
+ });
428
+ return parseBatchResults(await this.json(res));
429
+ }
430
+
431
+ /** Move files to trash by id (`POST /batch/delete`). */
432
+ async trash(ids: string[]): Promise<VfsBatchItemResult[]> {
433
+ const res = await this.send('fs/delete', 'POST', `/batch/delete`, {
434
+ body: JSON.stringify({ ids }),
435
+ contentType: 'application/json',
436
+ accept: 'application/json',
437
+ });
438
+ return parseBatchResults(await this.json(res));
439
+ }
440
+
441
+ /**
442
+ * Publish/unpublish a file (`PATCH /files/:id/public?public=`). The worker
443
+ * reads the flag from the query param (its preferred form).
444
+ */
445
+ async setFilePublic(id: string, pub: boolean): Promise<VfsPublicResult> {
446
+ const res = await this.send(
447
+ 'fs/write',
448
+ 'PATCH',
449
+ `/files/${enc(id)}/public?public=${pub}`,
450
+ { accept: 'application/json' },
451
+ );
452
+ const r = asRecord(await this.json(res));
453
+ return { public: bool(r.public) ?? pub, publicUrl: str(r.publicUrl) };
454
+ }
455
+
456
+ /**
457
+ * Publish/unpublish a folder (`PUT /folders/public?path=&public=`). Both the
458
+ * folder path and the flag are query params — the worker does not read them
459
+ * from the body.
460
+ */
461
+ async setFolderPublic(path: string, pub: boolean): Promise<VfsPublicResult> {
462
+ const res = await this.send(
463
+ 'fs/write',
464
+ 'PUT',
465
+ `/folders/public?path=${enc(path)}&public=${pub}`,
466
+ { accept: 'application/json' },
467
+ );
468
+ const r = asRecord(await this.json(res));
469
+ return { public: bool(r.public) ?? pub, publicUrl: str(r.publicUrl) };
470
+ }
471
+
472
+ // -------------------------------------------------------------------------
473
+ // Transport core
474
+ // -------------------------------------------------------------------------
475
+
476
+ /** Idempotent GET (retried once on 429/5xx/network). */
477
+ private get(
478
+ ability: VfsAbility,
479
+ pathAndQuery: string,
480
+ opts: RequestInitLite = { accept: 'application/json' },
481
+ ): Promise<Response> {
482
+ return this.request(ability, 'GET', pathAndQuery, opts, true);
483
+ }
484
+
485
+ /** Non-idempotent write (retried only once on 401, to re-mint). */
486
+ private send(
487
+ ability: VfsAbility,
488
+ method: string,
489
+ pathAndQuery: string,
490
+ opts: RequestInitLite,
491
+ ): Promise<Response> {
492
+ return this.request(ability, method, pathAndQuery, opts, false);
493
+ }
494
+
495
+ private async json(res: Response): Promise<unknown> {
496
+ try {
497
+ return await res.json();
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ private delay(): Promise<void> {
504
+ return new Promise((resolve) => {
505
+ setTimeout(resolve, this.retryDelayMs);
506
+ });
507
+ }
508
+
509
+ /**
510
+ * One fetch with a fresh bearer, an accept/content-type header set, and a
511
+ * combined caller-abort + timeout signal. Every non-2xx that isn't retried
512
+ * becomes a {@link VfsHttpError}; an unresolved auth mint becomes a
513
+ * {@link VfsAuthError}.
514
+ */
515
+ private async request(
516
+ ability: VfsAbility,
517
+ method: string,
518
+ pathAndQuery: string,
519
+ opts: RequestInitLite,
520
+ idempotent: boolean,
521
+ ): Promise<Response> {
522
+ const url = `${this.baseUrl}${pathAndQuery}`;
523
+ let didGetRetry = false;
524
+ let didAuthRetry = false;
525
+
526
+ for (;;) {
527
+ const minted = await this.mint(ability);
528
+ if ('error' in minted) {
529
+ throw new VfsAuthError(minted.error, minted.detail);
530
+ }
531
+
532
+ const headers: Record<string, string> = {
533
+ authorization: `Bearer ${minted.bearer}`,
534
+ 'x-auth-type': 'ucan',
535
+ };
536
+ if (opts.accept) headers.accept = opts.accept;
537
+ if (opts.body !== undefined) {
538
+ headers['content-type'] = opts.contentType ?? 'application/json';
539
+ }
540
+
541
+ const controller = new AbortController();
542
+ let timedOut = false;
543
+ const timer = setTimeout(() => {
544
+ timedOut = true;
545
+ controller.abort(new Error('vfs request timeout'));
546
+ }, this.timeoutMs);
547
+ const onCallerAbort = () => controller.abort(this.callerSignal?.reason);
548
+ if (this.callerSignal) {
549
+ if (this.callerSignal.aborted)
550
+ controller.abort(this.callerSignal.reason);
551
+ else
552
+ this.callerSignal.addEventListener('abort', onCallerAbort, {
553
+ once: true,
554
+ });
555
+ }
556
+
557
+ let res: Response;
558
+ try {
559
+ res = await this.fetchImpl(url, {
560
+ method,
561
+ headers,
562
+ // Binary bodies arrive as a `Uint8Array`; copy into a standalone
563
+ // `ArrayBuffer` so a view's extra backing bytes are never sent.
564
+ body:
565
+ opts.body instanceof Uint8Array
566
+ ? toArrayBuffer(opts.body)
567
+ : opts.body,
568
+ signal: controller.signal,
569
+ });
570
+ } catch (err) {
571
+ // A caller-initiated abort is terminal — surface it, don't retry.
572
+ if (this.callerSignal?.aborted && !timedOut) throw err;
573
+ if (idempotent && !didGetRetry) {
574
+ didGetRetry = true;
575
+ await this.delay();
576
+ continue;
577
+ }
578
+ throw new VfsHttpError({
579
+ status: 0,
580
+ message: 'Filesystem request failed.',
581
+ raw: err instanceof Error ? err.message : String(err),
582
+ });
583
+ } finally {
584
+ clearTimeout(timer);
585
+ this.callerSignal?.removeEventListener('abort', onCallerAbort);
586
+ }
587
+
588
+ if (res.ok) return res;
589
+
590
+ if (res.status === 401 && !didAuthRetry) {
591
+ didAuthRetry = true;
592
+ continue;
593
+ }
594
+ if (
595
+ (res.status === 429 || res.status >= 500) &&
596
+ idempotent &&
597
+ !didGetRetry
598
+ ) {
599
+ didGetRetry = true;
600
+ await this.delay();
601
+ continue;
602
+ }
603
+ throw await this.toHttpError(res);
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Parse a non-2xx body into a {@link VfsHttpError}. Handles the worker's
609
+ * `{ error, message, status }` shape, the zod-openapi `{ success:false,
610
+ * error }` shape, and a plain-text body.
611
+ */
612
+ private async toHttpError(res: Response): Promise<VfsHttpError> {
613
+ let raw = '';
614
+ try {
615
+ raw = await res.text();
616
+ } catch {
617
+ raw = '';
618
+ }
619
+
620
+ let message = '';
621
+ let code: string | undefined;
622
+ if (raw) {
623
+ try {
624
+ const parsed: unknown = JSON.parse(raw);
625
+ if (isRecord(parsed)) {
626
+ const errField = parsed.error;
627
+ // zod-openapi validation errors nest `{ error: { message } }`.
628
+ const nested = isRecord(errField) ? str(errField.message) : undefined;
629
+ message = str(parsed.message) ?? nested ?? str(errField) ?? '';
630
+ code = str(errField) ?? str(parsed.code);
631
+ }
632
+ } catch {
633
+ message = raw;
634
+ }
635
+ }
636
+ if (!message) message = `HTTP ${res.status}`;
637
+
638
+ return new VfsHttpError({ status: res.status, message, raw, code });
639
+ }
640
+ }
641
+
642
+ /** URL-encode a path/query value. */
643
+ function enc(v: string): string {
644
+ return encodeURIComponent(v);
645
+ }
646
+
647
+ /** Coerce an unknown JSON body to a record for field reads. */
648
+ function asRecord(v: unknown): Record<string, unknown> {
649
+ return isRecord(v) ? v : {};
650
+ }
651
+
652
+ /** A metadata stub for the rare case the worker returns an unparseable body. */
653
+ function emptyStat(path: string): VfsFileStat {
654
+ return { id: '', path, name: basename(path), mimeType: '', size: 0 };
655
+ }