@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { MessageContent } from '@langchain/core/messages';
|
|
3
|
+
import type { RuntimeContext } from '../../plugin-api/types';
|
|
4
|
+
import { bytesToBase64 } from '../base64';
|
|
5
|
+
import type { VfsClient, VfsFileStat } from './vfs-client';
|
|
6
|
+
import { VfsHttpError } from './vfs-errors';
|
|
7
|
+
|
|
8
|
+
/** Cap on the bytes we base64 into the vision model. */
|
|
9
|
+
const MAX_VISION_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/** MIME types that are text even though they don't start with `text/`. */
|
|
12
|
+
const TEXT_MIME_EXACT = new Set<string>([
|
|
13
|
+
'application/json',
|
|
14
|
+
'application/xml',
|
|
15
|
+
'application/xhtml+xml',
|
|
16
|
+
'application/javascript',
|
|
17
|
+
'application/typescript',
|
|
18
|
+
'application/yaml',
|
|
19
|
+
'application/x-yaml',
|
|
20
|
+
'application/toml',
|
|
21
|
+
'application/csv',
|
|
22
|
+
'application/x-ndjson',
|
|
23
|
+
'application/x-sh',
|
|
24
|
+
'image/svg+xml',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function isTextMime(mime: string): boolean {
|
|
28
|
+
const m = mime.toLowerCase();
|
|
29
|
+
if (m.startsWith('text/')) return true;
|
|
30
|
+
if (m.endsWith('+json') || m.endsWith('+xml')) return true;
|
|
31
|
+
return TEXT_MIME_EXACT.has(m);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isDocMime(mime: string): boolean {
|
|
35
|
+
const m = mime.toLowerCase();
|
|
36
|
+
return (
|
|
37
|
+
m === 'application/pdf' ||
|
|
38
|
+
m.startsWith('application/vnd.openxmlformats') ||
|
|
39
|
+
m.startsWith('application/vnd.oasis') ||
|
|
40
|
+
m === 'application/msword'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function basename(p: string): string {
|
|
45
|
+
const i = p.lastIndexOf('/');
|
|
46
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render a text window for the agent. The VFS already returns display-ready
|
|
51
|
+
* numbered text (cat -n style), so we pass it through and only append a
|
|
52
|
+
* continuation footer when there are more lines to page.
|
|
53
|
+
*/
|
|
54
|
+
function renderTextWindow(
|
|
55
|
+
text: string,
|
|
56
|
+
offset: number,
|
|
57
|
+
count: number,
|
|
58
|
+
hasMore: boolean,
|
|
59
|
+
totalLines: number,
|
|
60
|
+
): string {
|
|
61
|
+
if (text.trim().length === 0) return '(empty file)';
|
|
62
|
+
if (!hasMore) return text;
|
|
63
|
+
|
|
64
|
+
const nextOffset = offset + count;
|
|
65
|
+
return `${text}\n… more lines below (${totalLines} total). Call vfs_read again with offset=${nextOffset} to continue.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a non-text file for the agent. Fetches the bytes, then either returns a
|
|
70
|
+
* size stub (too large / unsupported binary) or routes the bytes through the
|
|
71
|
+
* vision model and returns its description/transcription. Never throws — a
|
|
72
|
+
* vision failure degrades to a metadata stub.
|
|
73
|
+
*/
|
|
74
|
+
async function renderBinary(
|
|
75
|
+
client: VfsClient,
|
|
76
|
+
rtCtx: RuntimeContext,
|
|
77
|
+
stat: VfsFileStat,
|
|
78
|
+
): Promise<string> {
|
|
79
|
+
const { bytes, mimeType, size } = await client.contentBytes(stat.id);
|
|
80
|
+
const name = basename(stat.path) || stat.id;
|
|
81
|
+
const mime = mimeType || stat.mimeType || 'application/octet-stream';
|
|
82
|
+
const publicSuffix = stat.publicUrl
|
|
83
|
+
? ` — public link: ${stat.publicUrl}`
|
|
84
|
+
: '';
|
|
85
|
+
|
|
86
|
+
if (size > MAX_VISION_BYTES) {
|
|
87
|
+
return `[binary file "${name}" — ${mime}, ${size} bytes — too large to render${publicSuffix}]`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isImage = mime.startsWith('image/');
|
|
91
|
+
const isDoc = isDocMime(mime);
|
|
92
|
+
if (!isImage && !isDoc) {
|
|
93
|
+
return `[binary file "${name}" — ${mime}, ${size} bytes — not rendered${publicSuffix}]`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const base64 = bytesToBase64(new Uint8Array(bytes));
|
|
97
|
+
const prompt = isImage
|
|
98
|
+
? 'Describe this image in detail and transcribe any text visible in it. Be factual and thorough.'
|
|
99
|
+
: 'Extract and transcribe the full text content of this document, preserving structure and headings where possible.';
|
|
100
|
+
// langchain 1.x standard "data content blocks". The @langchain/openai
|
|
101
|
+
// completions converter (the OpenRouter path) only recognises these when they
|
|
102
|
+
// carry `source_type: 'base64'` + snake_case `mime_type` — it emits
|
|
103
|
+
// `image_url: data:<mime_type>;base64,<data>` (and `file_data` for files).
|
|
104
|
+
// Omitting source_type / using camelCase makes the block unrecognised and the
|
|
105
|
+
// image never reaches the model.
|
|
106
|
+
const mediaBlock = isImage
|
|
107
|
+
? { type: 'image', source_type: 'base64', mime_type: mime, data: base64 }
|
|
108
|
+
: {
|
|
109
|
+
type: 'file',
|
|
110
|
+
source_type: 'base64',
|
|
111
|
+
mime_type: mime,
|
|
112
|
+
data: base64,
|
|
113
|
+
filename: name,
|
|
114
|
+
};
|
|
115
|
+
const content: MessageContent = [{ type: 'text', text: prompt }, mediaBlock];
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const model = rtCtx.llm.get('vision');
|
|
119
|
+
const result = await model.invoke([new HumanMessage({ content })], {
|
|
120
|
+
signal: rtCtx.abortSignal,
|
|
121
|
+
});
|
|
122
|
+
const text =
|
|
123
|
+
typeof result.content === 'string'
|
|
124
|
+
? result.content
|
|
125
|
+
: String(result.content);
|
|
126
|
+
if (text.trim().length > 0) return text;
|
|
127
|
+
return `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — no description returned${publicSuffix}]`;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
rtCtx.logger.warn(
|
|
130
|
+
`[vfs] vision render failed for ${name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
131
|
+
);
|
|
132
|
+
return `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — could not be rendered${publicSuffix}]`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a path to a file and return content the agent can read: numbered
|
|
138
|
+
* lines for text, a vision transcription for images/PDFs, or a metadata stub
|
|
139
|
+
* for other binaries. Throws a `404` {@link VfsHttpError} when the path
|
|
140
|
+
* doesn't resolve to a file.
|
|
141
|
+
*/
|
|
142
|
+
export async function readForAgent(
|
|
143
|
+
client: VfsClient,
|
|
144
|
+
rtCtx: RuntimeContext,
|
|
145
|
+
path: string,
|
|
146
|
+
offset: number,
|
|
147
|
+
limit: number,
|
|
148
|
+
): Promise<string> {
|
|
149
|
+
const stat = await client.statByPath(path);
|
|
150
|
+
if (!stat) {
|
|
151
|
+
throw new VfsHttpError({
|
|
152
|
+
status: 404,
|
|
153
|
+
message: `No such file at ${path}`,
|
|
154
|
+
raw: '',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const mime = stat.mimeType || '';
|
|
159
|
+
// Clearly-binary MIME → skip the line read entirely.
|
|
160
|
+
if (mime && !isTextMime(mime)) {
|
|
161
|
+
return renderBinary(client, rtCtx, stat);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Textual or unknown → attempt a windowed line read; fall back to the binary
|
|
165
|
+
// path if the VFS rejects it as not-text (415).
|
|
166
|
+
try {
|
|
167
|
+
const win = await client.readLines(stat.id, offset, limit);
|
|
168
|
+
return renderTextWindow(
|
|
169
|
+
win.text,
|
|
170
|
+
win.offset,
|
|
171
|
+
win.count,
|
|
172
|
+
win.hasMore,
|
|
173
|
+
win.totalLines,
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err instanceof VfsHttpError && err.status === 415) {
|
|
177
|
+
return renderBinary(client, rtCtx, stat);
|
|
178
|
+
}
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS error contract.
|
|
3
|
+
*
|
|
4
|
+
* Every transport failure is mapped to one of two typed errors:
|
|
5
|
+
* - {@link VfsHttpError} — a non-2xx HTTP response (status + parsed message).
|
|
6
|
+
* - {@link VfsAuthError} — a two-hop UCAN auth failure (no delegation, store
|
|
7
|
+
* unreachable, or mint failed) surfaced before any request leaves.
|
|
8
|
+
*
|
|
9
|
+
* {@link mapVfsError} turns either into a single agent-actionable string; the
|
|
10
|
+
* tools return that string (never a raw stack). The 409 family is
|
|
11
|
+
* disambiguated by message substring because the VFS reuses `409 Conflict`
|
|
12
|
+
* for five distinct situations.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Minimum UCAN ability required for a VFS operation. */
|
|
16
|
+
export type VfsAbility = 'fs/list' | 'fs/read' | 'fs/write' | 'fs/delete';
|
|
17
|
+
|
|
18
|
+
/** Why the two-hop auth resolution failed. */
|
|
19
|
+
export type VfsAuthErrorKind = 'no-delegation' | 'store-error' | 'mint-failed';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Step-by-step guidance shown when the user hasn't granted the oracle
|
|
23
|
+
* filesystem access. Walks them through the portal's Manage-access flow
|
|
24
|
+
* (domain → Library → Files → Access). When the oracle's own DID is known it is
|
|
25
|
+
* appended so the user can paste it straight into the "Recipient DID" field.
|
|
26
|
+
*/
|
|
27
|
+
export function noAccessMessage(oracleDid?: string): string {
|
|
28
|
+
const steps =
|
|
29
|
+
"I don't have access to your files yet. To grant me access, in the portal: open your domain → **Library** → **Files**, click **Access** (top-right), then in **Manage access** paste my agent DID as the Recipient DID, choose the rights (Read / Write / Delete), set the scope and how long it lasts, and click **Grant access**. It's delivered to my inbox and I pick it up automatically — then ask me again.";
|
|
30
|
+
return oracleDid ? `${steps}\n\nMy agent DID: ${oracleDid}` : steps;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The no-access guidance with no DID (back-compat + barrel export). */
|
|
34
|
+
export const NO_ACCESS_MESSAGE = noAccessMessage();
|
|
35
|
+
|
|
36
|
+
/** A non-2xx response from the VFS, with the parsed message + raw body. */
|
|
37
|
+
export class VfsHttpError extends Error {
|
|
38
|
+
readonly status: number;
|
|
39
|
+
|
|
40
|
+
readonly raw: string;
|
|
41
|
+
|
|
42
|
+
readonly code?: string;
|
|
43
|
+
|
|
44
|
+
constructor(params: {
|
|
45
|
+
status: number;
|
|
46
|
+
message: string;
|
|
47
|
+
raw: string;
|
|
48
|
+
code?: string;
|
|
49
|
+
}) {
|
|
50
|
+
super(params.message);
|
|
51
|
+
this.name = 'VfsHttpError';
|
|
52
|
+
this.status = params.status;
|
|
53
|
+
this.raw = params.raw;
|
|
54
|
+
if (params.code !== undefined) this.code = params.code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A UCAN auth failure raised before (or instead of) an HTTP round-trip. */
|
|
59
|
+
export class VfsAuthError extends Error {
|
|
60
|
+
readonly kind: VfsAuthErrorKind;
|
|
61
|
+
|
|
62
|
+
readonly detail?: string;
|
|
63
|
+
|
|
64
|
+
constructor(kind: VfsAuthErrorKind, detail?: string) {
|
|
65
|
+
super(detail && detail.length > 0 ? detail : kind);
|
|
66
|
+
this.name = 'VfsAuthError';
|
|
67
|
+
this.kind = kind;
|
|
68
|
+
if (detail !== undefined) this.detail = detail;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lowercased `<message> <raw>` used for substring classification. */
|
|
73
|
+
function haystack(err: VfsHttpError): string {
|
|
74
|
+
return `${err.message} ${err.raw}`.toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `true` when the error is a `409` whose body says the file was modified by
|
|
79
|
+
* another writer. This is the one mutation case tools retry once.
|
|
80
|
+
*/
|
|
81
|
+
export function isWriteConflict(err: unknown): err is VfsHttpError {
|
|
82
|
+
return (
|
|
83
|
+
err instanceof VfsHttpError &&
|
|
84
|
+
err.status === 409 &&
|
|
85
|
+
/modified concurrently/.test(haystack(err))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `true` when the error is a `409` from creating a file at an existing path. */
|
|
90
|
+
export function isAlreadyExistsConflict(err: unknown): err is VfsHttpError {
|
|
91
|
+
return (
|
|
92
|
+
err instanceof VfsHttpError &&
|
|
93
|
+
err.status === 409 &&
|
|
94
|
+
/already exists/.test(haystack(err))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Agent-facing message for a `409`, disambiguated by the body substring. */
|
|
99
|
+
function map409(err: VfsHttpError, path?: string): string {
|
|
100
|
+
const text = haystack(err);
|
|
101
|
+
if (/already exists/.test(text)) {
|
|
102
|
+
return path
|
|
103
|
+
? `A file already exists at \`${path}\`. Ask the user before overwriting it.`
|
|
104
|
+
: 'A file already exists at that path. Ask the user before overwriting it.';
|
|
105
|
+
}
|
|
106
|
+
if (/oldstring/.test(text)) {
|
|
107
|
+
return `Couldn't apply the edit: ${err.message}. Read the file first and add more surrounding context so the text matches exactly once, or set replaceAll.`;
|
|
108
|
+
}
|
|
109
|
+
if (/destination occupied|duplicate id/.test(text)) {
|
|
110
|
+
return `Couldn't move it — the destination is occupied: ${err.message}.`;
|
|
111
|
+
}
|
|
112
|
+
if (/version limit reached/.test(text)) {
|
|
113
|
+
return 'This file hit its 50-version limit. Delete old versions before saving again, or write the change as a new file.';
|
|
114
|
+
}
|
|
115
|
+
if (/modified concurrently/.test(text)) {
|
|
116
|
+
return 'The file was changed by someone else while I was writing. Please try again.';
|
|
117
|
+
}
|
|
118
|
+
return (
|
|
119
|
+
err.message || 'That change conflicts with the current state of the file.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Turn a {@link VfsHttpError} or {@link VfsAuthError} into a single
|
|
125
|
+
* agent-actionable sentence. `ctx.path` enriches the "already exists" message.
|
|
126
|
+
*/
|
|
127
|
+
export function mapVfsError(
|
|
128
|
+
err: unknown,
|
|
129
|
+
ctx: { path?: string; oracleDid?: string } = {},
|
|
130
|
+
): string {
|
|
131
|
+
if (err instanceof VfsAuthError) {
|
|
132
|
+
return err.kind === 'no-delegation'
|
|
133
|
+
? noAccessMessage(ctx.oracleDid)
|
|
134
|
+
: "Couldn't get filesystem access right now.";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (err instanceof VfsHttpError) {
|
|
138
|
+
switch (err.status) {
|
|
139
|
+
case 401:
|
|
140
|
+
return "Couldn't authenticate to your filesystem right now.";
|
|
141
|
+
case 403:
|
|
142
|
+
return `That's outside what you shared with me — it may be read-only, or a different folder than the one you granted. To give me more access, re-grant in the portal: your domain → Library → Files → Access → Manage access.${ctx.oracleDid ? ` My agent DID: ${ctx.oracleDid}.` : ''}`;
|
|
143
|
+
case 404:
|
|
144
|
+
return "No such file, or it's outside the folder you shared.";
|
|
145
|
+
case 409:
|
|
146
|
+
return map409(err, ctx.path);
|
|
147
|
+
case 413:
|
|
148
|
+
case 415:
|
|
149
|
+
return `${err.message || `The file could not be handled (HTTP ${err.status})`} — try a smaller scope, or replace the file instead.`;
|
|
150
|
+
case 429:
|
|
151
|
+
return 'The filesystem is busy right now — please try again in a moment.';
|
|
152
|
+
case 503:
|
|
153
|
+
return 'Filesystem temporarily unavailable.';
|
|
154
|
+
default:
|
|
155
|
+
if (err.status === 0 || err.status >= 500) {
|
|
156
|
+
return 'Filesystem request failed.';
|
|
157
|
+
}
|
|
158
|
+
return err.message || 'Filesystem request failed.';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return 'Filesystem request failed.';
|
|
163
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '../../plugin-api/tool-helper';
|
|
3
|
+
import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
|
|
4
|
+
import { bytesToBase64 } from '../base64';
|
|
5
|
+
import {
|
|
6
|
+
getSandboxBridge,
|
|
7
|
+
hasShellUnsafeChars,
|
|
8
|
+
inferMimeFromPath,
|
|
9
|
+
isUnderWorkspaceData,
|
|
10
|
+
readSandboxFile,
|
|
11
|
+
readSandboxResult,
|
|
12
|
+
writeSandboxFile,
|
|
13
|
+
type SandboxBridge,
|
|
14
|
+
} from '../sandbox/sandbox-bridge';
|
|
15
|
+
import type { SandboxMcpClientFactory } from '../sandbox/sandbox.plugin';
|
|
16
|
+
import type { VfsFileStat } from './vfs-client';
|
|
17
|
+
import { isTextMime } from './vfs-content';
|
|
18
|
+
import {
|
|
19
|
+
isAlreadyExistsConflict,
|
|
20
|
+
mapVfsError,
|
|
21
|
+
VfsAuthError,
|
|
22
|
+
VfsHttpError,
|
|
23
|
+
} from './vfs-errors';
|
|
24
|
+
import { buildClient, invalidArgs, validatePath } from './vfs-tools';
|
|
25
|
+
import type { VfsConfig } from './vfs.plugin';
|
|
26
|
+
|
|
27
|
+
export interface CreateVfsSandboxToolsDeps {
|
|
28
|
+
/** Validated VFS config (the subset of merged env the VFS plugin owns). */
|
|
29
|
+
vfsCfg: VfsConfig;
|
|
30
|
+
/** Sandbox MCP base URL — owned by the sandbox plugin, read as a sibling. */
|
|
31
|
+
sandboxMcpUrl: string;
|
|
32
|
+
/** Injected transport for the VFS client in tests. Defaults to global `fetch`. */
|
|
33
|
+
vfsFetchImpl?: typeof fetch;
|
|
34
|
+
/** Retry backoff forwarded to the VFS client. Tests pass `0`. */
|
|
35
|
+
vfsRetryDelayMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run a transfer body, mapping VFS auth/HTTP errors to their agent-facing
|
|
40
|
+
* string and any other throw to a short generic message, then close the
|
|
41
|
+
* sandbox bridge. A handler never throws out to the graph.
|
|
42
|
+
*/
|
|
43
|
+
async function runTransfer(
|
|
44
|
+
ctx: RuntimeContext,
|
|
45
|
+
bridge: SandboxBridge,
|
|
46
|
+
info: { path?: string },
|
|
47
|
+
fn: () => Promise<string>,
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
try {
|
|
50
|
+
return await fn();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof VfsHttpError || err instanceof VfsAuthError) {
|
|
53
|
+
return mapVfsError(err, info);
|
|
54
|
+
}
|
|
55
|
+
ctx.logger.warn(
|
|
56
|
+
`[vfs] sandbox transfer failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
);
|
|
58
|
+
return 'The file transfer failed.';
|
|
59
|
+
} finally {
|
|
60
|
+
await bridge.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sandboxToVfsSchema = z.object({
|
|
65
|
+
sandboxPath: z
|
|
66
|
+
.string()
|
|
67
|
+
.min(1)
|
|
68
|
+
.describe(
|
|
69
|
+
'Absolute path of the file inside the sandbox to save, e.g. /workspace/data/output/report.pdf or /workspace/output/export.csv.',
|
|
70
|
+
),
|
|
71
|
+
vfsPath: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.describe(
|
|
75
|
+
"Absolute destination path in the user's files, e.g. /reports/q3.pdf. The extension sets the stored file type.",
|
|
76
|
+
),
|
|
77
|
+
overwrite: z
|
|
78
|
+
.boolean()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(
|
|
81
|
+
'Replace an existing file at vfsPath instead of failing. Default false.',
|
|
82
|
+
),
|
|
83
|
+
deleteSource: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe(
|
|
87
|
+
'Delete the sandbox file after a successful save (move instead of copy). Default false.',
|
|
88
|
+
),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const vfsToSandboxSchema = z.object({
|
|
92
|
+
vfsPath: z
|
|
93
|
+
.string()
|
|
94
|
+
.min(1)
|
|
95
|
+
.describe(
|
|
96
|
+
"Absolute path of the user's file to bring in, e.g. /data/sales.csv.",
|
|
97
|
+
),
|
|
98
|
+
sandboxPath: z
|
|
99
|
+
.string()
|
|
100
|
+
.min(1)
|
|
101
|
+
.describe(
|
|
102
|
+
'Absolute destination in the sandbox. Must be under /workspace/data/, e.g. /workspace/data/input/sales.csv.',
|
|
103
|
+
),
|
|
104
|
+
deleteSource: z
|
|
105
|
+
.boolean()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe(
|
|
108
|
+
'Move the file to trash after copying it into the sandbox. Default false (copy).',
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the two sandbox↔VFS bridge tools. Each reads the bytes on one side and
|
|
114
|
+
* writes them on the other entirely server-side — the file content never
|
|
115
|
+
* passes through the LLM. The sandbox MCP client is built on demand inside the
|
|
116
|
+
* handler and always closed, so an unused bridge adds zero per-turn overhead.
|
|
117
|
+
*/
|
|
118
|
+
export function createVfsSandboxTools(
|
|
119
|
+
deps: CreateVfsSandboxToolsDeps,
|
|
120
|
+
mcpClientFactory?: SandboxMcpClientFactory,
|
|
121
|
+
): PluginTool[] {
|
|
122
|
+
const vfsClient = (ctx: RuntimeContext) =>
|
|
123
|
+
buildClient(ctx, {
|
|
124
|
+
cfg: deps.vfsCfg,
|
|
125
|
+
fetchImpl: deps.vfsFetchImpl,
|
|
126
|
+
retryDelayMs: deps.vfsRetryDelayMs,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const sandboxToVfs = tool(
|
|
130
|
+
async (raw, ctx) => {
|
|
131
|
+
const parsed = sandboxToVfsSchema.safeParse(raw);
|
|
132
|
+
if (!parsed.success) return invalidArgs(parsed.error);
|
|
133
|
+
const { sandboxPath, vfsPath } = parsed.data;
|
|
134
|
+
const overwrite = parsed.data.overwrite ?? false;
|
|
135
|
+
const deleteSource = parsed.data.deleteSource ?? false;
|
|
136
|
+
|
|
137
|
+
if (hasShellUnsafeChars(sandboxPath)) {
|
|
138
|
+
return `The sandbox path contains characters I can't safely handle (quotes or newlines). Rename it and try again.`;
|
|
139
|
+
}
|
|
140
|
+
const vfsErr = validatePath(vfsPath);
|
|
141
|
+
if (vfsErr) return `Invalid destination path: ${vfsErr}`;
|
|
142
|
+
|
|
143
|
+
const bridge = await getSandboxBridge(
|
|
144
|
+
ctx,
|
|
145
|
+
deps.sandboxMcpUrl,
|
|
146
|
+
mcpClientFactory,
|
|
147
|
+
);
|
|
148
|
+
if ('error' in bridge) return bridge.error;
|
|
149
|
+
|
|
150
|
+
return runTransfer(ctx, bridge, { path: vfsPath }, async () => {
|
|
151
|
+
const read = await readSandboxFile(bridge, sandboxPath);
|
|
152
|
+
if ('error' in read) return read.error;
|
|
153
|
+
|
|
154
|
+
const bytes = read.bytes;
|
|
155
|
+
const mime = inferMimeFromPath(vfsPath);
|
|
156
|
+
const client = vfsClient(ctx);
|
|
157
|
+
|
|
158
|
+
let created: VfsFileStat;
|
|
159
|
+
try {
|
|
160
|
+
created = await client.create(vfsPath, bytes, mime);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!isAlreadyExistsConflict(err)) throw err;
|
|
163
|
+
if (!overwrite) {
|
|
164
|
+
return `A file already exists at \`${vfsPath}\` (set overwrite to replace it).`;
|
|
165
|
+
}
|
|
166
|
+
const stat = await client.statByPath(vfsPath);
|
|
167
|
+
if (!stat) {
|
|
168
|
+
throw new VfsHttpError({
|
|
169
|
+
status: 404,
|
|
170
|
+
message: `No such file at ${vfsPath}`,
|
|
171
|
+
raw: '',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
created = await client.replace(stat.id, bytes, mime);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let deletedSource = false;
|
|
178
|
+
let note: string | undefined;
|
|
179
|
+
if (deleteSource) {
|
|
180
|
+
const rm = readSandboxResult(
|
|
181
|
+
await bridge.run.invoke({ code: `rm -f '${sandboxPath}'` }),
|
|
182
|
+
);
|
|
183
|
+
deletedSource = rm.ok;
|
|
184
|
+
if (!rm.ok) note = 'could not delete the sandbox source file';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return JSON.stringify({
|
|
188
|
+
ok: true,
|
|
189
|
+
vfsPath,
|
|
190
|
+
bytes: bytes.byteLength,
|
|
191
|
+
cid: created.id || undefined,
|
|
192
|
+
movedFromSandbox: sandboxPath,
|
|
193
|
+
deletedSource,
|
|
194
|
+
...(note ? { note } : {}),
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'sandbox_to_vfs',
|
|
200
|
+
description:
|
|
201
|
+
"Save a file the sandbox produced (a generated report, export, chart, dataset under /workspace/…) into the user's permanent files so it persists and is searchable. Copies by default; set deleteSource to move.",
|
|
202
|
+
schema: sandboxToVfsSchema,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const vfsToSandbox = tool(
|
|
207
|
+
async (raw, ctx) => {
|
|
208
|
+
const parsed = vfsToSandboxSchema.safeParse(raw);
|
|
209
|
+
if (!parsed.success) return invalidArgs(parsed.error);
|
|
210
|
+
const { vfsPath, sandboxPath } = parsed.data;
|
|
211
|
+
const deleteSource = parsed.data.deleteSource ?? false;
|
|
212
|
+
|
|
213
|
+
if (!isUnderWorkspaceData(sandboxPath)) {
|
|
214
|
+
return `The sandbox destination must be under /workspace/data/ (got \`${sandboxPath}\`). Pick something like /workspace/data/input/<name>.`;
|
|
215
|
+
}
|
|
216
|
+
const vfsErr = validatePath(vfsPath);
|
|
217
|
+
if (vfsErr) return `Invalid source path: ${vfsErr}`;
|
|
218
|
+
|
|
219
|
+
const bridge = await getSandboxBridge(
|
|
220
|
+
ctx,
|
|
221
|
+
deps.sandboxMcpUrl,
|
|
222
|
+
mcpClientFactory,
|
|
223
|
+
);
|
|
224
|
+
if ('error' in bridge) return bridge.error;
|
|
225
|
+
|
|
226
|
+
return runTransfer(ctx, bridge, { path: vfsPath }, async () => {
|
|
227
|
+
const client = vfsClient(ctx);
|
|
228
|
+
const stat = await client.statByPath(vfsPath);
|
|
229
|
+
if (!stat) return `No such file at \`${vfsPath}\`.`;
|
|
230
|
+
|
|
231
|
+
const { bytes, mimeType } = await client.contentBytes(stat.id);
|
|
232
|
+
const data = new Uint8Array(bytes);
|
|
233
|
+
const isText = isTextMime(mimeType || stat.mimeType || '');
|
|
234
|
+
|
|
235
|
+
const write = await writeSandboxFile(
|
|
236
|
+
bridge,
|
|
237
|
+
sandboxPath,
|
|
238
|
+
isText ? new TextDecoder().decode(data) : bytesToBase64(data),
|
|
239
|
+
isText ? 'utf8' : 'base64',
|
|
240
|
+
);
|
|
241
|
+
if ('error' in write) return write.error;
|
|
242
|
+
|
|
243
|
+
let deletedSource = false;
|
|
244
|
+
let note: string | undefined;
|
|
245
|
+
if (deleteSource) {
|
|
246
|
+
const results = await client.trash([stat.id]);
|
|
247
|
+
deletedSource = !results.some((r) => !r.ok);
|
|
248
|
+
note = deletedSource
|
|
249
|
+
? 'source moved to the files trash (recoverable)'
|
|
250
|
+
: 'could not move the source to trash';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return JSON.stringify({
|
|
254
|
+
ok: true,
|
|
255
|
+
sandboxPath,
|
|
256
|
+
bytes: data.byteLength,
|
|
257
|
+
movedFromVfs: vfsPath,
|
|
258
|
+
deletedSource,
|
|
259
|
+
...(note ? { note } : {}),
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'vfs_to_sandbox',
|
|
265
|
+
description:
|
|
266
|
+
"Bring one of the user's files into the sandbox (under /workspace/data/) so code in `sandbox_run` can process it. Copies by default; set deleteSource to move it out of the files (to trash).",
|
|
267
|
+
schema: vfsToSandboxSchema,
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return [sandboxToVfs, vfsToSandbox];
|
|
272
|
+
}
|