@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,635 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '../../plugin-api/tool-helper';
3
+ import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
4
+ import { vfsBearer } from './vfs-auth';
5
+ import {
6
+ VfsClient,
7
+ type VfsBatchItemResult,
8
+ type VfsSearchHit,
9
+ type VfsTreeEntry,
10
+ } from './vfs-client';
11
+ import { readForAgent } from './vfs-content';
12
+ import {
13
+ VfsAuthError,
14
+ VfsHttpError,
15
+ isAlreadyExistsConflict,
16
+ isWriteConflict,
17
+ mapVfsError,
18
+ } from './vfs-errors';
19
+ import type { VfsConfig } from './vfs.plugin';
20
+
21
+ export interface CreateVfsToolsDeps {
22
+ cfg: VfsConfig;
23
+ /** Injected transport for tests. Defaults to the global `fetch`. */
24
+ fetchImpl?: typeof fetch;
25
+ /** Retry backoff forwarded to {@link VfsClient}. Tests pass `0`. */
26
+ retryDelayMs?: number;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Client-side validation (mirrors the VFS so a bad path never round-trips).
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export function validatePath(path: string): string | null {
34
+ if (path.length === 0) return 'path is required';
35
+ if (path.length > 1024) return 'path must be at most 1024 characters';
36
+ if (!path.startsWith('/')) return 'path must be absolute (start with "/")';
37
+ if (path === '/') return null;
38
+ if (path.includes('\0')) return 'path must not contain a null byte';
39
+ if (path.includes('//')) return 'path must not contain "//"';
40
+ if (path.endsWith('/')) return 'path must not end with "/"';
41
+ for (const seg of path.slice(1).split('/')) {
42
+ if (seg === '.' || seg === '..') {
43
+ return 'path must not contain "." or ".." segments';
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const EXT_MIME: Record<string, string> = {
50
+ md: 'text/markdown',
51
+ markdown: 'text/markdown',
52
+ txt: 'text/plain',
53
+ text: 'text/plain',
54
+ log: 'text/plain',
55
+ json: 'application/json',
56
+ csv: 'text/csv',
57
+ tsv: 'text/tab-separated-values',
58
+ html: 'text/html',
59
+ htm: 'text/html',
60
+ xml: 'application/xml',
61
+ yaml: 'application/yaml',
62
+ yml: 'application/yaml',
63
+ toml: 'application/toml',
64
+ css: 'text/css',
65
+ js: 'text/javascript',
66
+ ts: 'text/plain',
67
+ py: 'text/x-python',
68
+ sh: 'application/x-sh',
69
+ };
70
+
71
+ function guessMime(path: string): string {
72
+ const dot = path.lastIndexOf('.');
73
+ const ext = dot >= 0 ? path.slice(dot + 1).toLowerCase() : '';
74
+ return EXT_MIME[ext] ?? 'text/plain';
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Output formatting (compact strings — a tool message carries text only).
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function formatHits(hits: VfsSearchHit[], semantic: boolean): string {
82
+ const header = semantic
83
+ ? ''
84
+ : '(semantic search unavailable — showing lexical matches only)\n';
85
+ if (hits.length === 0) return `${header}No matching files.`;
86
+ const lines = hits.map((h, i) => {
87
+ const range =
88
+ h.lineStart !== undefined
89
+ ? ` (lines ${h.lineStart}${h.lineEnd !== undefined && h.lineEnd !== h.lineStart ? `-${h.lineEnd}` : ''})`
90
+ : '';
91
+ return `${i + 1}. ${h.path}${range}`;
92
+ });
93
+ return `${header}Found ${hits.length} file(s):\n${lines.join('\n')}\n\nUse vfs_read to see the lines before answering or editing.`;
94
+ }
95
+
96
+ function formatEntries(path: string, entries: VfsTreeEntry[]): string {
97
+ if (entries.length === 0) return `\`${path}\` is empty or does not exist.`;
98
+ const lines = entries.map(
99
+ (e) => `- ${e.path}${e.type === 'folder' ? '/' : ''}`,
100
+ );
101
+ return `Contents of \`${path}\`:\n${lines.join('\n')}`;
102
+ }
103
+
104
+ function summarizeBatch(
105
+ results: VfsBatchItemResult[],
106
+ verb: string,
107
+ total: number,
108
+ ): string {
109
+ if (results.length === 0) return `${verb} ${total} item(s).`;
110
+ const failed = results.filter((r) => !r.ok);
111
+ if (failed.length === 0) return `${verb} ${results.length} item(s).`;
112
+ const ok = results.length - failed.length;
113
+ const details = failed
114
+ .map(
115
+ (r) =>
116
+ `- ${r.path ?? r.id ?? '?'}: ${r.error ?? `status ${r.status ?? '?'}`}`,
117
+ )
118
+ .join('\n');
119
+ return `${verb} ${ok} of ${results.length}. Failed:\n${details}`;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Shared handler plumbing.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Mount prefix of the VFS worker's file API (`app.route('/api/fs', fsRoutes)`).
128
+ * `VFS_BASE_URL` is the worker host (used bare for did:web resolution, which
129
+ * reads only the origin); the file endpoints (`/tree`, `/files`, `/glob`, …)
130
+ * live under this prefix — mirroring how the store client appends
131
+ * `/api/delegations` to its host.
132
+ */
133
+ const VFS_API_BASE_PATH = '/api/fs';
134
+
135
+ export function buildClient(
136
+ ctx: RuntimeContext,
137
+ deps: CreateVfsToolsDeps,
138
+ ): VfsClient {
139
+ return new VfsClient({
140
+ baseUrl: `${deps.cfg.VFS_BASE_URL.replace(/\/+$/, '')}${VFS_API_BASE_PATH}`,
141
+ timeoutMs: deps.cfg.VFS_REQUEST_TIMEOUT_MS,
142
+ signal: ctx.abortSignal,
143
+ fetchImpl: deps.fetchImpl,
144
+ retryDelayMs: deps.retryDelayMs,
145
+ mint: (ability) => vfsBearer(ctx, deps.cfg, ability),
146
+ });
147
+ }
148
+
149
+ /**
150
+ * The oracle's own DID (from the `ORACLE_DID` env), surfaced to the user in the
151
+ * no-access / 403 message so they can paste it into the portal's "Recipient
152
+ * DID" field when granting the agent access. Absent → the message omits it.
153
+ */
154
+ function readOracleDid(ctx: RuntimeContext): string | undefined {
155
+ const parsed = z
156
+ .object({ ORACLE_DID: z.string().min(1) })
157
+ .safeParse(ctx.config);
158
+ return parsed.success ? parsed.data.ORACLE_DID : undefined;
159
+ }
160
+
161
+ /**
162
+ * Run a tool body, converting a {@link VfsHttpError} / {@link VfsAuthError}
163
+ * into the agent-facing string. Any other throw is logged and returned as a
164
+ * generic failure — a tool never throws out to the graph.
165
+ */
166
+ async function guard(
167
+ ctx: RuntimeContext,
168
+ info: { path?: string },
169
+ fn: () => Promise<string>,
170
+ ): Promise<string> {
171
+ try {
172
+ return await fn();
173
+ } catch (err) {
174
+ if (err instanceof VfsHttpError || err instanceof VfsAuthError) {
175
+ return mapVfsError(err, { ...info, oracleDid: readOracleDid(ctx) });
176
+ }
177
+ ctx.logger.warn(
178
+ `[vfs] unexpected error: ${err instanceof Error ? err.message : String(err)}`,
179
+ );
180
+ return 'Filesystem request failed.';
181
+ }
182
+ }
183
+
184
+ /** Retry a mutation once on a `409 modified concurrently`. */
185
+ async function withConflictRetry<T>(op: () => Promise<T>): Promise<T> {
186
+ try {
187
+ return await op();
188
+ } catch (err) {
189
+ if (isWriteConflict(err)) return op();
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ export function invalidArgs(err: z.ZodError): string {
195
+ return `Invalid arguments: ${err.issues
196
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
197
+ .join('; ')}`;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Schemas
202
+ // ---------------------------------------------------------------------------
203
+
204
+ const searchSchema = z.object({
205
+ q: z
206
+ .string()
207
+ .min(1)
208
+ .max(512)
209
+ .describe('What to look for, in natural language.'),
210
+ path: z
211
+ .string()
212
+ .optional()
213
+ .describe(
214
+ 'Folder to search under. Defaults to the whole filesystem ("/").',
215
+ ),
216
+ });
217
+
218
+ const grepSchema = z.object({
219
+ q: z
220
+ .string()
221
+ .min(1)
222
+ .max(512)
223
+ .describe('The exact word or identifier to find inside files.'),
224
+ path: z
225
+ .string()
226
+ .optional()
227
+ .describe('Folder to search under. Defaults to "/".'),
228
+ });
229
+
230
+ const globSchema = z.object({
231
+ pattern: z
232
+ .string()
233
+ .min(1)
234
+ .max(512)
235
+ .describe('A glob over paths, e.g. "/notes/*.md" or "**/*.pdf".'),
236
+ });
237
+
238
+ const listSchema = z.object({
239
+ path: z
240
+ .string()
241
+ .optional()
242
+ .describe('Folder to list. Defaults to the root ("/").'),
243
+ });
244
+
245
+ const readSchema = z.object({
246
+ path: z.string().describe('Absolute path of the file to read.'),
247
+ offset: z
248
+ .number()
249
+ .int()
250
+ .min(1)
251
+ .optional()
252
+ .describe(
253
+ 'First line to return (1-based) when paging a long text file. Defaults to 1.',
254
+ ),
255
+ limit: z
256
+ .number()
257
+ .int()
258
+ .min(1)
259
+ .optional()
260
+ .describe('Max lines to return (capped by the oracle).'),
261
+ });
262
+
263
+ const writeSchema = z.object({
264
+ path: z.string().describe('Absolute path of the file to create.'),
265
+ content: z.string().describe('The full text content of the file.'),
266
+ mimeType: z
267
+ .string()
268
+ .optional()
269
+ .describe('Content type. Inferred from the extension when omitted.'),
270
+ overwrite: z
271
+ .boolean()
272
+ .optional()
273
+ .describe(
274
+ 'Only set true when the user explicitly asked to replace an existing file.',
275
+ ),
276
+ });
277
+
278
+ const editSchema = z.object({
279
+ path: z.string().describe('Absolute path of the file to edit.'),
280
+ oldString: z
281
+ .string()
282
+ .min(1)
283
+ .describe(
284
+ 'Exact text to replace. Must match once unless replaceAll is set.',
285
+ ),
286
+ newString: z.string().describe('Replacement text.'),
287
+ replaceAll: z
288
+ .boolean()
289
+ .optional()
290
+ .describe('Replace every occurrence instead of requiring a unique match.'),
291
+ });
292
+
293
+ const moveSchema = z.object({
294
+ from: z.string().describe('Absolute source path.'),
295
+ to: z.string().describe('Absolute destination path.'),
296
+ });
297
+
298
+ const deleteSchema = z.object({
299
+ paths: z
300
+ .array(z.string())
301
+ .min(1)
302
+ .max(1000)
303
+ .describe('Absolute paths to move to trash.'),
304
+ });
305
+
306
+ const shareSchema = z.object({
307
+ path: z.string().describe('Absolute path of the file or folder to publish.'),
308
+ public: z
309
+ .boolean()
310
+ .optional()
311
+ .describe('true to publish (default), false to unpublish.'),
312
+ });
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Tool factory
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Build the ten VFS tools. Each handler validates args + path client-side,
320
+ * builds a {@link VfsClient} that resolves auth per call, and returns a
321
+ * compact string. Auth/HTTP failures come back as {@link mapVfsError} strings,
322
+ * never throws.
323
+ */
324
+ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
325
+ const client = (ctx: RuntimeContext) => buildClient(ctx, deps);
326
+
327
+ const searchTool = tool(
328
+ async (raw, ctx) => {
329
+ const parsed = searchSchema.safeParse(raw);
330
+ if (!parsed.success) return invalidArgs(parsed.error);
331
+ const path = parsed.data.path ?? '/';
332
+ const pathErr = validatePath(path);
333
+ if (pathErr) return `Invalid path: ${pathErr}`;
334
+ return guard(ctx, {}, async () => {
335
+ const { results, semantic } = await client(ctx).search(
336
+ parsed.data.q,
337
+ path,
338
+ );
339
+ return formatHits(results, semantic);
340
+ });
341
+ },
342
+ {
343
+ name: 'vfs_search',
344
+ description:
345
+ "Find files by meaning across the user's filesystem. Use for questions and paraphrases ('the doc about pricing'). Returns file paths + cited line ranges. Follow with `vfs_read` to see the actual lines before answering or editing. For writing, editing, organising, or sharing files, load the `vfs` capability first.",
346
+ schema: searchSchema,
347
+ // Recall must work with zero load steps — this tool and `vfs_read`
348
+ // stay visible while the rest of the plugin is on-demand.
349
+ visibility: 'always',
350
+ },
351
+ );
352
+
353
+ const grepTool = tool(
354
+ async (raw, ctx) => {
355
+ const parsed = grepSchema.safeParse(raw);
356
+ if (!parsed.success) return invalidArgs(parsed.error);
357
+ const path = parsed.data.path ?? '/';
358
+ const pathErr = validatePath(path);
359
+ if (pathErr) return `Invalid path: ${pathErr}`;
360
+ return guard(ctx, {}, async () => {
361
+ const hits = await client(ctx).grep(parsed.data.q, path);
362
+ return formatHits(hits, true);
363
+ });
364
+ },
365
+ {
366
+ name: 'vfs_grep',
367
+ description:
368
+ 'Find files containing an exact word or identifier. Use when you know a literal term; use `vfs_search` for concepts.',
369
+ schema: grepSchema,
370
+ },
371
+ );
372
+
373
+ const globTool = tool(
374
+ async (raw, ctx) => {
375
+ const parsed = globSchema.safeParse(raw);
376
+ if (!parsed.success) return invalidArgs(parsed.error);
377
+ return guard(ctx, {}, async () => {
378
+ const matches = await client(ctx).glob(parsed.data.pattern);
379
+ if (matches.length === 0) return 'No files match that pattern.';
380
+ return `${matches.length} match(es):\n${matches.map((m) => m.path).join('\n')}`;
381
+ });
382
+ },
383
+ {
384
+ name: 'vfs_glob',
385
+ description:
386
+ 'Find files by name or path pattern (glob), e.g. `/notes/*.md` or `**/*.pdf`. Use when you know part of the name or extension; use `vfs_search` for meaning and `vfs_grep` for exact text inside files.',
387
+ schema: globSchema,
388
+ },
389
+ );
390
+
391
+ const listTool = tool(
392
+ async (raw, ctx) => {
393
+ const parsed = listSchema.safeParse(raw);
394
+ if (!parsed.success) return invalidArgs(parsed.error);
395
+ const path = parsed.data.path ?? '/';
396
+ const pathErr = validatePath(path);
397
+ if (pathErr) return `Invalid path: ${pathErr}`;
398
+ return guard(ctx, {}, async () => {
399
+ const entries = await client(ctx).list(path);
400
+ return formatEntries(path, entries);
401
+ });
402
+ },
403
+ {
404
+ name: 'vfs_list',
405
+ description:
406
+ 'List the files and folders under a path. Use to explore what the user has before searching, or to confirm a path exists.',
407
+ schema: listSchema,
408
+ },
409
+ );
410
+
411
+ const readTool = tool(
412
+ async (raw, ctx) => {
413
+ const parsed = readSchema.safeParse(raw);
414
+ if (!parsed.success) return invalidArgs(parsed.error);
415
+ const pathErr = validatePath(parsed.data.path);
416
+ if (pathErr) return `Invalid path: ${pathErr}`;
417
+ const offset = parsed.data.offset ?? 1;
418
+ const limit = Math.min(
419
+ parsed.data.limit ?? deps.cfg.VFS_MAX_READ_LINES,
420
+ deps.cfg.VFS_MAX_READ_LINES,
421
+ 5000,
422
+ );
423
+ return guard(ctx, { path: parsed.data.path }, () =>
424
+ readForAgent(client(ctx), ctx, parsed.data.path, offset, limit),
425
+ );
426
+ },
427
+ {
428
+ name: 'vfs_read',
429
+ description:
430
+ "Read a file's contents by path. Text files come back a window of numbered lines at a time (default first 2000; page with `offset` when `hasMore`). Images and PDFs are read too — described/transcribed for you. Always read before editing or answering from a file; do not guess its contents. Reading an image costs an extra step, so read it only when you need to see it.",
431
+ schema: readSchema,
432
+ // See vfs_search — the read half of the always-available recall pair.
433
+ visibility: 'always',
434
+ },
435
+ );
436
+
437
+ const writeTool = tool(
438
+ async (raw, ctx) => {
439
+ const parsed = writeSchema.safeParse(raw);
440
+ if (!parsed.success) return invalidArgs(parsed.error);
441
+ const { path, content, overwrite } = parsed.data;
442
+ const pathErr = validatePath(path);
443
+ if (pathErr) return `Invalid path: ${pathErr}`;
444
+ const mime = parsed.data.mimeType ?? guessMime(path);
445
+ const c = client(ctx);
446
+ return guard(ctx, { path }, async () => {
447
+ try {
448
+ const created = await c.create(path, content, mime);
449
+ return `Created \`${created.path || path}\`.`;
450
+ } catch (err) {
451
+ if (!isAlreadyExistsConflict(err)) throw err;
452
+ if (!overwrite) {
453
+ return `A file already exists at \`${path}\`. Ask the user before overwriting it.`;
454
+ }
455
+ // Replace the existing file, retrying once if another writer raced us.
456
+ return withConflictRetry(async () => {
457
+ const stat = await c.statByPath(path);
458
+ if (!stat) {
459
+ throw new VfsHttpError({
460
+ status: 404,
461
+ message: `No such file at ${path}`,
462
+ raw: '',
463
+ });
464
+ }
465
+ await c.replace(stat.id, content, mime);
466
+ return `Replaced \`${path}\`.`;
467
+ });
468
+ }
469
+ });
470
+ },
471
+ {
472
+ name: 'vfs_write',
473
+ description:
474
+ "Create a new text file at a path (or, only when the user asked to replace it, overwrite an existing one). Announce what you're creating. If the path already exists and the user did not ask to overwrite, stop and ask.",
475
+ schema: writeSchema,
476
+ },
477
+ );
478
+
479
+ const editTool = tool(
480
+ async (raw, ctx) => {
481
+ const parsed = editSchema.safeParse(raw);
482
+ if (!parsed.success) return invalidArgs(parsed.error);
483
+ const { path, oldString, newString, replaceAll } = parsed.data;
484
+ const pathErr = validatePath(path);
485
+ if (pathErr) return `Invalid path: ${pathErr}`;
486
+ const c = client(ctx);
487
+ return guard(ctx, { path }, () =>
488
+ withConflictRetry(async () => {
489
+ const stat = await c.statByPath(path);
490
+ if (!stat) {
491
+ throw new VfsHttpError({
492
+ status: 404,
493
+ message: `No such file at ${path}`,
494
+ raw: '',
495
+ });
496
+ }
497
+ const result = await c.edit(
498
+ stat.id,
499
+ oldString,
500
+ newString,
501
+ replaceAll ?? false,
502
+ );
503
+ const n = result.replacements;
504
+ return n !== undefined && n > 1
505
+ ? `Edited \`${path}\` (${n} replacements).`
506
+ : `Edited \`${path}\`.`;
507
+ }),
508
+ );
509
+ },
510
+ {
511
+ name: 'vfs_edit',
512
+ description:
513
+ 'Change an exact string in a file. `oldString` must match once (or set `replaceAll`). Read the file first so `oldString` is exact. Prefer this over rewriting the whole file.',
514
+ schema: editSchema,
515
+ },
516
+ );
517
+
518
+ const moveTool = tool(
519
+ async (raw, ctx) => {
520
+ const parsed = moveSchema.safeParse(raw);
521
+ if (!parsed.success) return invalidArgs(parsed.error);
522
+ const { from, to } = parsed.data;
523
+ const fromErr = validatePath(from);
524
+ if (fromErr) return `Invalid "from" path: ${fromErr}`;
525
+ const toErr = validatePath(to);
526
+ if (toErr) return `Invalid "to" path: ${toErr}`;
527
+ const c = client(ctx);
528
+ return guard(ctx, { path: from }, () =>
529
+ withConflictRetry(async () => {
530
+ // The worker addresses moves by file id, not source path — resolve it.
531
+ const stat = await c.statByPath(from);
532
+ if (!stat) {
533
+ throw new VfsHttpError({
534
+ status: 404,
535
+ message: `No such file at ${from}`,
536
+ raw: '',
537
+ });
538
+ }
539
+ const results = await c.move([{ id: stat.id, destinationPath: to }]);
540
+ const failed = results.find((r) => !r.ok);
541
+ if (failed) {
542
+ return `Couldn't move \`${from}\` to \`${to}\`: ${failed.error ?? `status ${failed.status ?? '?'}`}.`;
543
+ }
544
+ return `Moved \`${from}\` to \`${to}\`.`;
545
+ }),
546
+ );
547
+ },
548
+ {
549
+ name: 'vfs_move',
550
+ description:
551
+ 'Move or rename a file or folder. Confirm destructive actions with the user first.',
552
+ schema: moveSchema,
553
+ },
554
+ );
555
+
556
+ const deleteTool = tool(
557
+ async (raw, ctx) => {
558
+ const parsed = deleteSchema.safeParse(raw);
559
+ if (!parsed.success) return invalidArgs(parsed.error);
560
+ for (const p of parsed.data.paths) {
561
+ const err = validatePath(p);
562
+ if (err) return `Invalid path "${p}": ${err}`;
563
+ }
564
+ const c = client(ctx);
565
+ return guard(ctx, {}, async () => {
566
+ const resolved = await Promise.all(
567
+ parsed.data.paths.map(async (p) => ({
568
+ path: p,
569
+ stat: await c.statByPath(p),
570
+ })),
571
+ );
572
+ const missing = resolved
573
+ .filter((x) => x.stat === null)
574
+ .map((x) => x.path);
575
+ const ids = resolved.flatMap((x) => (x.stat ? [x.stat.id] : []));
576
+ if (ids.length === 0) {
577
+ return `No such file(s): ${parsed.data.paths.join(', ')}.`;
578
+ }
579
+ const results = await c.trash(ids);
580
+ let summary = summarizeBatch(results, 'Moved to trash', ids.length);
581
+ if (missing.length > 0) {
582
+ summary += `\nNot found (skipped): ${missing.join(', ')}.`;
583
+ }
584
+ return summary;
585
+ });
586
+ },
587
+ {
588
+ name: 'vfs_delete',
589
+ description:
590
+ 'Move a file or folder to trash (recoverable). Confirm destructive actions with the user first. Deletes go to trash, not permanent.',
591
+ schema: deleteSchema,
592
+ },
593
+ );
594
+
595
+ const shareTool = tool(
596
+ async (raw, ctx) => {
597
+ const parsed = shareSchema.safeParse(raw);
598
+ if (!parsed.success) return invalidArgs(parsed.error);
599
+ const { path } = parsed.data;
600
+ const pub = parsed.data.public ?? true;
601
+ const pathErr = validatePath(path);
602
+ if (pathErr) return `Invalid path: ${pathErr}`;
603
+ const c = client(ctx);
604
+ return guard(ctx, { path }, async () => {
605
+ const stat = await c.statByPath(path);
606
+ const result = stat
607
+ ? await c.setFilePublic(stat.id, pub)
608
+ : await c.setFolderPublic(path, pub);
609
+ if (!pub) return `\`${path}\` is no longer public.`;
610
+ return result.publicUrl
611
+ ? `\`${path}\` is now public: ${result.publicUrl}`
612
+ : `\`${path}\` is now public.`;
613
+ });
614
+ },
615
+ {
616
+ name: 'vfs_share',
617
+ description:
618
+ 'Publish a file or folder so anyone with the link can download it, and return the link. Only when the user asks to share/make public — publishing is visible to anyone with the link.',
619
+ schema: shareSchema,
620
+ },
621
+ );
622
+
623
+ return [
624
+ searchTool,
625
+ grepTool,
626
+ globTool,
627
+ listTool,
628
+ readTool,
629
+ writeTool,
630
+ editTool,
631
+ moveTool,
632
+ deleteTool,
633
+ shareTool,
634
+ ];
635
+ }