@zocomputer/agent-sdk 0.5.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/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- package/src/workspace.ts +41 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Generic park-delivery: queue items for a session and release them as one
|
|
2
|
+
// batch exactly when the session is parked (`session.waiting`) and reachable
|
|
3
|
+
// (a continuation token has been seen). Two producers ride this core today —
|
|
4
|
+
// read-image redelivery (./redeliver.ts, items observed off stream events)
|
|
5
|
+
// and background-task notifications (./watch-output.ts matches posted through
|
|
6
|
+
// the bridge below) — and the effectful hook (./hooks.ts) performs the sends.
|
|
7
|
+
//
|
|
8
|
+
// eve hooks are observe-only for model context, so "delivery" means starting
|
|
9
|
+
// the session's NEXT turn, exactly like a user hitting send. Two paths emit a
|
|
10
|
+
// request:
|
|
11
|
+
// - `observe` — a `session.waiting` event arrives with items pending.
|
|
12
|
+
// - `enqueue` — an item arrives while the session is ALREADY parked (a
|
|
13
|
+
// background task matching its watcher mid-park; no further stream events
|
|
14
|
+
// will fire until something starts a turn, so the enqueue itself must
|
|
15
|
+
// trigger the send).
|
|
16
|
+
|
|
17
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recover the client-facing continuation token from a hook's runtime one.
|
|
23
|
+
* eve namespaces the runtime token as `<namespace>:<client token>` (its
|
|
24
|
+
* `namespaceContinuationToken` treats everything before the FIRST colon as the
|
|
25
|
+
* namespace; the default HTTP channel's client token is itself `eve:<uuid>`,
|
|
26
|
+
* so the runtime form is `eve:eve:<uuid>`). The continue route wants the
|
|
27
|
+
* client token — sending the namespaced form silently creates a NEW session.
|
|
28
|
+
*/
|
|
29
|
+
export function clientContinuationToken(runtimeToken: string): string {
|
|
30
|
+
const sep = runtimeToken.indexOf(":");
|
|
31
|
+
if (sep <= 0) return runtimeToken;
|
|
32
|
+
const rest = runtimeToken.slice(sep + 1);
|
|
33
|
+
// A client token itself carries a scheme prefix (`eve:<uuid>`). If the
|
|
34
|
+
// remainder has no colon, the input was already client-facing — keep it.
|
|
35
|
+
return rest.includes(":") ? rest : runtimeToken;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParkDeliveryItem<T> {
|
|
39
|
+
/** Dedupe key: an item delivers at most once per session per process. */
|
|
40
|
+
readonly key: string;
|
|
41
|
+
readonly payload: T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ParkDeliveryRequest<T> {
|
|
45
|
+
readonly sessionId: string;
|
|
46
|
+
readonly continuationToken: string;
|
|
47
|
+
readonly items: readonly ParkDeliveryItem<T>[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-process delivery state across sessions. Feed it every stream event via
|
|
52
|
+
* `observe`; queue items with `enqueue`. Either returns a request exactly when
|
|
53
|
+
* a parked, reachable session has items to deliver. The caller performs the
|
|
54
|
+
* send and reports back with `settle` — on failure the items re-queue.
|
|
55
|
+
*
|
|
56
|
+
* Dedup is by item key for the session's lifetime in this process, so an item
|
|
57
|
+
* never delivers twice even if a failed send races a user message.
|
|
58
|
+
*/
|
|
59
|
+
export function createParkDeliveryState<T>() {
|
|
60
|
+
interface SessionState {
|
|
61
|
+
pending: Map<string, ParkDeliveryItem<T>>;
|
|
62
|
+
delivered: Set<string>;
|
|
63
|
+
continuationToken?: string;
|
|
64
|
+
parked: boolean;
|
|
65
|
+
delivering: boolean;
|
|
66
|
+
}
|
|
67
|
+
const sessions = new Map<string, SessionState>();
|
|
68
|
+
|
|
69
|
+
function session(id: string): SessionState {
|
|
70
|
+
let state = sessions.get(id);
|
|
71
|
+
if (!state) {
|
|
72
|
+
state = { pending: new Map(), delivered: new Set(), parked: false, delivering: false };
|
|
73
|
+
sessions.set(id, state);
|
|
74
|
+
}
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drain(id: string, state: SessionState): ParkDeliveryRequest<T> | null {
|
|
79
|
+
if (state.pending.size === 0 || !state.continuationToken || state.delivering) return null;
|
|
80
|
+
// Move pending → delivered optimistically; `settle(ok: false)` re-queues.
|
|
81
|
+
const items = [...state.pending.values()];
|
|
82
|
+
state.pending.clear();
|
|
83
|
+
for (const item of items) state.delivered.add(item.key);
|
|
84
|
+
state.delivering = true;
|
|
85
|
+
return { sessionId: id, continuationToken: state.continuationToken, items };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Queue several items at once — all of them enter pending before any drain,
|
|
90
|
+
* so a batch enqueued into an already-parked session goes out as ONE
|
|
91
|
+
* delivery. Enqueuing the same batch item-by-item would let the first
|
|
92
|
+
* item's immediate flush strand the rest into a second turn.
|
|
93
|
+
*/
|
|
94
|
+
function enqueueAll(
|
|
95
|
+
sessionId: string,
|
|
96
|
+
items: readonly ParkDeliveryItem<T>[],
|
|
97
|
+
): ParkDeliveryRequest<T> | null {
|
|
98
|
+
const state = session(sessionId);
|
|
99
|
+
let queued = false;
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
if (state.delivered.has(item.key) || state.pending.has(item.key)) continue;
|
|
102
|
+
state.pending.set(item.key, item);
|
|
103
|
+
queued = true;
|
|
104
|
+
}
|
|
105
|
+
if (!queued || !state.parked) return null;
|
|
106
|
+
return drain(sessionId, state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
/**
|
|
111
|
+
* Consume one stream event. `continuationToken` is the hook's runtime
|
|
112
|
+
* (namespaced) token when known — latest wins; it's translated to the
|
|
113
|
+
* client-facing token the continue route accepts.
|
|
114
|
+
*/
|
|
115
|
+
observe(
|
|
116
|
+
event: unknown,
|
|
117
|
+
meta: { readonly sessionId: string; readonly continuationToken?: string | undefined },
|
|
118
|
+
): ParkDeliveryRequest<T> | null {
|
|
119
|
+
const state = session(meta.sessionId);
|
|
120
|
+
if (meta.continuationToken) {
|
|
121
|
+
state.continuationToken = clientContinuationToken(meta.continuationToken);
|
|
122
|
+
}
|
|
123
|
+
if (!isRecord(event)) return null;
|
|
124
|
+
if (event.type === "session.completed" || event.type === "session.failed") {
|
|
125
|
+
sessions.delete(meta.sessionId);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
// Any non-waiting event means the session is active again; only a
|
|
129
|
+
// `session.waiting` (the park) makes it deliverable.
|
|
130
|
+
if (event.type !== "session.waiting") {
|
|
131
|
+
state.parked = false;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
state.parked = true;
|
|
135
|
+
return drain(meta.sessionId, state);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Queue an item for a session. Returns a request immediately when the
|
|
140
|
+
* session is currently parked and reachable — the caller must perform
|
|
141
|
+
* that send (no later stream event will fire to flush it). An item whose
|
|
142
|
+
* key was already delivered (or is already pending) is dropped.
|
|
143
|
+
*/
|
|
144
|
+
enqueue(sessionId: string, item: ParkDeliveryItem<T>): ParkDeliveryRequest<T> | null {
|
|
145
|
+
return enqueueAll(sessionId, [item]);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
enqueueAll,
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Report the send outcome. A failed send re-queues for the next park.
|
|
152
|
+
* Returns a new request when items queued during the just-finished delivery
|
|
153
|
+
* need immediate dispatch (the session is still parked).
|
|
154
|
+
*/
|
|
155
|
+
settle(request: ParkDeliveryRequest<T>, ok: boolean): ParkDeliveryRequest<T> | null {
|
|
156
|
+
const state = session(request.sessionId);
|
|
157
|
+
state.delivering = false;
|
|
158
|
+
if (ok) {
|
|
159
|
+
// Items may have queued while we were delivering; drain them now if
|
|
160
|
+
// the session is still parked (no stream event will fire for them).
|
|
161
|
+
if (state.parked) return drain(request.sessionId, state);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
for (const item of request.items) {
|
|
165
|
+
state.delivered.delete(item.key);
|
|
166
|
+
state.pending.set(item.key, item);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type ParkDeliveryState<T> = ReturnType<typeof createParkDeliveryState<T>>;
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Notification bridge: how tool code (bash watchers, run_async completion
|
|
177
|
+
// notices) reaches the hook's delivery state from another module graph.
|
|
178
|
+
// Process-global and deduped on globalThis for the same reason as the task
|
|
179
|
+
// registry (see ./async-tasks.ts): eve's mid-session rebuilds duplicate module
|
|
180
|
+
// graphs, and a notification posted into one copy must reach the hook created
|
|
181
|
+
// in another. Posts made before any hook exists queue (bounded) and flush when
|
|
182
|
+
// the hook registers its handler.
|
|
183
|
+
|
|
184
|
+
/** A background notification for the model, delivered as its next user turn. */
|
|
185
|
+
export interface ParkNotification {
|
|
186
|
+
/** Dedupe key (e.g. `task_3#2`); one delivery per key per session. */
|
|
187
|
+
readonly key: string;
|
|
188
|
+
/** The message text, complete and self-describing. */
|
|
189
|
+
readonly text: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface NotificationBridge {
|
|
193
|
+
queued: Map<string, ParkNotification[]>;
|
|
194
|
+
handler: ((sessionId: string, notification: ParkNotification) => void) | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const BRIDGE_KEY = Symbol.for("zocomputer.agent-sdk.park-notification-bridge");
|
|
198
|
+
// A session with no hook wired keeps at most this many queued notifications.
|
|
199
|
+
const MAX_QUEUED_PER_SESSION = 20;
|
|
200
|
+
|
|
201
|
+
function bridge(): NotificationBridge {
|
|
202
|
+
const holder = globalThis as { [BRIDGE_KEY]?: NotificationBridge };
|
|
203
|
+
holder[BRIDGE_KEY] ??= { queued: new Map(), handler: null };
|
|
204
|
+
return holder[BRIDGE_KEY];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Test-only: drop the process-global bridge (handler + queued posts). */
|
|
208
|
+
export function __resetParkNotificationBridgeForTests(): void {
|
|
209
|
+
const holder = globalThis as { [BRIDGE_KEY]?: NotificationBridge };
|
|
210
|
+
delete holder[BRIDGE_KEY];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Post a notification for a session. Delivered by the park-delivery hook when
|
|
215
|
+
* the session parks (immediately, if it's parked right now). Without a hook
|
|
216
|
+
* the post queues — bounded per session — and flushes when one registers.
|
|
217
|
+
*/
|
|
218
|
+
export function postParkNotification(
|
|
219
|
+
sessionId: string,
|
|
220
|
+
notification: ParkNotification,
|
|
221
|
+
): void {
|
|
222
|
+
const b = bridge();
|
|
223
|
+
if (b.handler) {
|
|
224
|
+
b.handler(sessionId, notification);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const queue = b.queued.get(sessionId) ?? [];
|
|
228
|
+
if (queue.length >= MAX_QUEUED_PER_SESSION) return;
|
|
229
|
+
queue.push(notification);
|
|
230
|
+
b.queued.set(sessionId, queue);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Register the hook-side consumer (latest registration wins — a rebuilt hook
|
|
235
|
+
* replaces its stale copy). Flushes any posts queued before registration.
|
|
236
|
+
*/
|
|
237
|
+
export function setParkNotificationHandler(
|
|
238
|
+
handler: (sessionId: string, notification: ParkNotification) => void,
|
|
239
|
+
): void {
|
|
240
|
+
const b = bridge();
|
|
241
|
+
b.handler = handler;
|
|
242
|
+
const queued = [...b.queued.entries()];
|
|
243
|
+
b.queued.clear();
|
|
244
|
+
for (const [sessionId, notifications] of queued) {
|
|
245
|
+
for (const notification of notifications) handler(sessionId, notification);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Per-path serialization for the mutating file tools (`edit`, `write`).
|
|
2
|
+
//
|
|
3
|
+
// eve runs a step's tool calls concurrently (`Promise.all`), so a model that
|
|
4
|
+
// batches two `edit` calls against the same file races them: both read the
|
|
5
|
+
// file, both compute their replacement from the same original text, and the
|
|
6
|
+
// second write silently discards the first edit — while both results report
|
|
7
|
+
// success. Serializing the whole read-modify-write critical section per
|
|
8
|
+
// absolute path makes batched same-file edits apply in call order instead:
|
|
9
|
+
// the second edit reads the first one's output, and if the first edit removed
|
|
10
|
+
// the second's `old_string`, the second fails loudly ("not found") instead of
|
|
11
|
+
// silently clobbering.
|
|
12
|
+
//
|
|
13
|
+
// The lock chain lives on `globalThis` (via `Symbol.for`) for the same reason
|
|
14
|
+
// as the task registry (see `async-tasks.ts`): eve's mid-session rebuild forks
|
|
15
|
+
// module-level state across module-graph copies, and two lock maps would
|
|
16
|
+
// serialize nothing.
|
|
17
|
+
|
|
18
|
+
const LOCKS_KEY = Symbol.for("zocomputer.agent-sdk.path-locks");
|
|
19
|
+
|
|
20
|
+
function lockChains(): Map<string, Promise<void>> {
|
|
21
|
+
const holder = globalThis as { [LOCKS_KEY]?: Map<string, Promise<void>> };
|
|
22
|
+
holder[LOCKS_KEY] ??= new Map();
|
|
23
|
+
return holder[LOCKS_KEY];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run `fn` exclusively among all `withPathLock` calls for the same `path` in
|
|
28
|
+
* this process. Callers queue FIFO; a thrown error releases the lock and
|
|
29
|
+
* propagates without breaking the chain for later callers.
|
|
30
|
+
*/
|
|
31
|
+
export async function withPathLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|
32
|
+
const chains = lockChains();
|
|
33
|
+
const prev = chains.get(path) ?? Promise.resolve();
|
|
34
|
+
let release!: () => void;
|
|
35
|
+
const gate = new Promise<void>((resolve) => {
|
|
36
|
+
release = resolve;
|
|
37
|
+
});
|
|
38
|
+
// The stored chain resolves only when our critical section finishes, so the
|
|
39
|
+
// next caller's `await prev` blocks until then. `gate` always resolves (in
|
|
40
|
+
// the finally), so the chain never rejects and can't poison later callers.
|
|
41
|
+
const chained = prev.then(() => gate);
|
|
42
|
+
chains.set(path, chained);
|
|
43
|
+
await prev;
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
} finally {
|
|
47
|
+
release();
|
|
48
|
+
// Drop the entry once no later caller has chained onto us, so the map
|
|
49
|
+
// doesn't grow with every path ever touched in a long session.
|
|
50
|
+
if (chains.get(path) === chained) chains.delete(path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { imageSize } from "image-size";
|
|
2
|
+
import {
|
|
3
|
+
type AudioFormat,
|
|
4
|
+
detectFileKind,
|
|
5
|
+
type ImageFormat,
|
|
6
|
+
type SheetFormat,
|
|
7
|
+
type TextEncoding,
|
|
8
|
+
type VideoFormat,
|
|
9
|
+
} from "./file-kind";
|
|
10
|
+
import { createStatCache, type StatIdentity } from "./extract/cache";
|
|
11
|
+
import { extractDocx } from "./extract/docx";
|
|
12
|
+
import { extractPdf } from "./extract/pdf";
|
|
13
|
+
import { extractSheets, type SheetMeta } from "./extract/sheet";
|
|
14
|
+
|
|
15
|
+
// Content routing for the `read` tool: sniff the bytes, then hand back either
|
|
16
|
+
// the text to window (native or extracted from PDF/DOCX/spreadsheets),
|
|
17
|
+
// image/video/audio metadata, or a thrown error for binaries with no text
|
|
18
|
+
// rendering. Kept free of the workspace module so tests can feed fixture
|
|
19
|
+
// paths directly.
|
|
20
|
+
|
|
21
|
+
export type FileContent =
|
|
22
|
+
| { readonly kind: "text"; readonly text: string }
|
|
23
|
+
| { readonly kind: "pdf"; readonly text: string; readonly pages: number }
|
|
24
|
+
| { readonly kind: "docx"; readonly text: string }
|
|
25
|
+
| {
|
|
26
|
+
readonly kind: "sheet";
|
|
27
|
+
readonly format: SheetFormat;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
readonly sheets: readonly SheetMeta[];
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: "image";
|
|
33
|
+
readonly format: ImageFormat;
|
|
34
|
+
readonly width: number | null;
|
|
35
|
+
readonly height: number | null;
|
|
36
|
+
}
|
|
37
|
+
| { readonly kind: "video"; readonly format: VideoFormat }
|
|
38
|
+
| { readonly kind: "audio"; readonly format: AudioFormat };
|
|
39
|
+
|
|
40
|
+
type ExtractedDoc = Exclude<FileContent, { kind: "text" } | { kind: "image" }>;
|
|
41
|
+
type ExtractableKind =
|
|
42
|
+
| { readonly kind: "pdf" }
|
|
43
|
+
| { readonly kind: "docx" }
|
|
44
|
+
| { readonly kind: "sheet"; readonly format: SheetFormat };
|
|
45
|
+
|
|
46
|
+
// Extraction is pure parsing, so cache by path + stat identity: re-reading a
|
|
47
|
+
// PDF to page through it (offset/limit) shouldn't re-run PDFium every call.
|
|
48
|
+
// Failures throw out of `compute` and are never cached.
|
|
49
|
+
const EXTRACTION_CACHE_LIMIT = 20;
|
|
50
|
+
const extractionCache = createStatCache<ExtractedDoc>(EXTRACTION_CACHE_LIMIT);
|
|
51
|
+
|
|
52
|
+
function decodeText(buffer: Buffer, encoding: TextEncoding): string {
|
|
53
|
+
switch (encoding) {
|
|
54
|
+
case "utf8":
|
|
55
|
+
return buffer.toString("utf8");
|
|
56
|
+
case "utf16le":
|
|
57
|
+
// Strip the BOM the detector matched on; it isn't content.
|
|
58
|
+
return buffer.toString("utf16le").replace(/^\uFEFF/, "");
|
|
59
|
+
case "utf16be": {
|
|
60
|
+
// Node has no utf16be decoder; byte-swap a copy and decode as LE.
|
|
61
|
+
const swapped = Buffer.from(buffer).swap16();
|
|
62
|
+
return swapped.toString("utf16le").replace(/^\uFEFF/, "");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function extractDocument(
|
|
68
|
+
detected: ExtractableKind,
|
|
69
|
+
buffer: Buffer,
|
|
70
|
+
path: string,
|
|
71
|
+
): Promise<ExtractedDoc> {
|
|
72
|
+
switch (detected.kind) {
|
|
73
|
+
case "pdf": {
|
|
74
|
+
const result = await extractPdf(buffer);
|
|
75
|
+
if (!result.ok) throw new Error(`Could not extract text from PDF ${path}: ${result.reason}`);
|
|
76
|
+
return { kind: "pdf", text: result.text, pages: result.pages };
|
|
77
|
+
}
|
|
78
|
+
case "docx": {
|
|
79
|
+
const result = await extractDocx(buffer);
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
throw new Error(`Could not extract text from DOCX ${path}: ${result.reason}`);
|
|
82
|
+
}
|
|
83
|
+
return { kind: "docx", text: result.text };
|
|
84
|
+
}
|
|
85
|
+
case "sheet": {
|
|
86
|
+
const result = extractSheets(buffer);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
throw new Error(`Could not extract data from spreadsheet ${path}: ${result.reason}`);
|
|
89
|
+
}
|
|
90
|
+
return { kind: "sheet", format: detected.format, text: result.text, sheets: result.sheets };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Route a file's bytes to renderable content. `path` labels error messages,
|
|
97
|
+
* disambiguates container formats by extension, and keys the extraction cache.
|
|
98
|
+
* Throws for binaries with no text rendering (the tool-error path).
|
|
99
|
+
*/
|
|
100
|
+
export async function loadFileContent(
|
|
101
|
+
buffer: Buffer,
|
|
102
|
+
path: string,
|
|
103
|
+
id: StatIdentity,
|
|
104
|
+
): Promise<FileContent> {
|
|
105
|
+
const detected = detectFileKind(buffer, path);
|
|
106
|
+
switch (detected.kind) {
|
|
107
|
+
case "text":
|
|
108
|
+
return { kind: "text", text: decodeText(buffer, detected.encoding) };
|
|
109
|
+
case "pdf":
|
|
110
|
+
case "docx":
|
|
111
|
+
case "sheet":
|
|
112
|
+
return extractionCache.get(path, id, () => extractDocument(detected, buffer, path));
|
|
113
|
+
case "image": {
|
|
114
|
+
// Magic bytes matched but the body may still be truncated/corrupt —
|
|
115
|
+
// dimensions are best-effort, the metadata result is useful either way.
|
|
116
|
+
let size: { width: number; height: number } | null = null;
|
|
117
|
+
try {
|
|
118
|
+
size = imageSize(buffer);
|
|
119
|
+
} catch {
|
|
120
|
+
size = null;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
kind: "image",
|
|
124
|
+
format: detected.format,
|
|
125
|
+
width: size?.width ?? null,
|
|
126
|
+
height: size?.height ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// No probing for duration/dimensions — container parsing isn't worth a
|
|
130
|
+
// dep; the metadata result carries format + size and the bytes ride the
|
|
131
|
+
// attachment contract when the consumer opted in.
|
|
132
|
+
case "video":
|
|
133
|
+
return { kind: "video", format: detected.format };
|
|
134
|
+
case "audio":
|
|
135
|
+
return { kind: "audio", format: detected.format };
|
|
136
|
+
case "binary":
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${path} is ${detected.description} — read returns text only. ` +
|
|
139
|
+
"Use bash (unzip -l, strings, xxd) to inspect it if needed.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/read-text.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// Search tools skip anything bigger — no source file we'd grep comes close,
|
|
4
|
+
// and the cap keeps a stray artifact from stalling a search.
|
|
5
|
+
export const MAX_SEARCH_FILE_BYTES = 1_500_000;
|
|
6
|
+
|
|
7
|
+
// A NUL byte in the first 8 KB marks a file binary — the same heuristic git
|
|
8
|
+
// and ripgrep use.
|
|
9
|
+
const BINARY_SNIFF_BYTES = 8_192;
|
|
10
|
+
|
|
11
|
+
export type SearchRead =
|
|
12
|
+
| { kind: "text"; content: string }
|
|
13
|
+
| { kind: "too-large"; bytes: number }
|
|
14
|
+
| { kind: "binary" }
|
|
15
|
+
| { kind: "unreadable" };
|
|
16
|
+
|
|
17
|
+
// Bounded read for search: stat before read so an oversized file is skipped
|
|
18
|
+
// without touching its bytes, and sniff raw bytes for NUL before decoding.
|
|
19
|
+
export function readTextForSearch(
|
|
20
|
+
abs: string,
|
|
21
|
+
maxBytes: number = MAX_SEARCH_FILE_BYTES,
|
|
22
|
+
): SearchRead {
|
|
23
|
+
let size: number;
|
|
24
|
+
try {
|
|
25
|
+
const stats = statSync(abs);
|
|
26
|
+
if (!stats.isFile()) return { kind: "unreadable" };
|
|
27
|
+
size = stats.size;
|
|
28
|
+
} catch {
|
|
29
|
+
return { kind: "unreadable" };
|
|
30
|
+
}
|
|
31
|
+
if (size > maxBytes) return { kind: "too-large", bytes: size };
|
|
32
|
+
let buf: Buffer;
|
|
33
|
+
try {
|
|
34
|
+
buf = readFileSync(abs);
|
|
35
|
+
} catch {
|
|
36
|
+
return { kind: "unreadable" };
|
|
37
|
+
}
|
|
38
|
+
if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { kind: "binary" };
|
|
39
|
+
return { kind: "text", content: buf.toString("utf8") };
|
|
40
|
+
}
|
package/src/redeliver.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Media redelivery: decide, from the runtime event stream, when a read
|
|
2
|
+
// image/video/audio file should be re-sent to the session as a real user
|
|
3
|
+
// message part.
|
|
4
|
+
//
|
|
5
|
+
// eve tool results are text/json, so `read` smuggles media bytes out on its raw
|
|
6
|
+
// result under CHAT_ATTACHMENT_FIELD (model-hidden via toModelOutput — see
|
|
7
|
+
// ./attachments.ts). Hooks receive that raw output on `action.result`, and
|
|
8
|
+
// `session.waiting` marks the session parked and deliverable. This module is
|
|
9
|
+
// the media-flavored client of the generic park-delivery core
|
|
10
|
+
// (./park-delivery.ts): extraction + message shape live here, the
|
|
11
|
+
// queue/park/settle state machine lives there, and the effectful hook
|
|
12
|
+
// (./hooks.ts) performs the actual send.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
readChatAttachment,
|
|
16
|
+
type ChatAttachment,
|
|
17
|
+
} from "./attachments";
|
|
18
|
+
import {
|
|
19
|
+
clientContinuationToken,
|
|
20
|
+
createParkDeliveryState,
|
|
21
|
+
} from "./park-delivery";
|
|
22
|
+
|
|
23
|
+
export { clientContinuationToken };
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PendingRedelivery {
|
|
30
|
+
readonly toolCallId: string;
|
|
31
|
+
readonly attachment: ChatAttachment;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract a read-media attachment from an `action.result` stream event, if the
|
|
36
|
+
* completed tool's raw output carries one. Structural (no eve types) and
|
|
37
|
+
* tool-name-agnostic: any tool that returns a `CHAT_ATTACHMENT_FIELD` payload
|
|
38
|
+
* participates.
|
|
39
|
+
*/
|
|
40
|
+
export function redeliveryFromEvent(event: unknown): PendingRedelivery | null {
|
|
41
|
+
if (!isRecord(event) || event.type !== "action.result") return null;
|
|
42
|
+
if (!isRecord(event.data)) return null;
|
|
43
|
+
const result = event.data.result;
|
|
44
|
+
if (!isRecord(result) || result.kind !== "tool-result") return null;
|
|
45
|
+
if (typeof result.callId !== "string" || result.callId.length === 0) return null;
|
|
46
|
+
const attachment = readChatAttachment(result.output);
|
|
47
|
+
if (!attachment) return null;
|
|
48
|
+
return { toolCallId: result.callId, attachment };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The message parts a redelivery turn sends (AI SDK `UserContent`-shaped). */
|
|
52
|
+
export type RedeliveryMessagePart =
|
|
53
|
+
| { readonly type: "text"; readonly text: string }
|
|
54
|
+
| {
|
|
55
|
+
readonly type: "file";
|
|
56
|
+
readonly data: string;
|
|
57
|
+
readonly mediaType: string;
|
|
58
|
+
readonly filename: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the user turn that carries the pending media. A short text part names
|
|
63
|
+
* the files (so transcripts show what arrived); the file parts carry the
|
|
64
|
+
* bytes the model was promised by read's "queued" note.
|
|
65
|
+
*/
|
|
66
|
+
export function buildRedeliveryMessage(
|
|
67
|
+
pending: readonly PendingRedelivery[],
|
|
68
|
+
): RedeliveryMessagePart[] {
|
|
69
|
+
const names = pending.map((p) => p.attachment.filename).join(", ");
|
|
70
|
+
return [
|
|
71
|
+
{ type: "text", text: `Attached: ${names} (auto-attached from read).` },
|
|
72
|
+
...pending.map((p) => ({
|
|
73
|
+
type: "file" as const,
|
|
74
|
+
data: p.attachment.dataUrl,
|
|
75
|
+
mediaType: p.attachment.mediaType,
|
|
76
|
+
filename: p.attachment.filename,
|
|
77
|
+
})),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RedeliveryRequest {
|
|
82
|
+
readonly sessionId: string;
|
|
83
|
+
readonly continuationToken: string;
|
|
84
|
+
readonly pending: readonly PendingRedelivery[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Per-process redelivery state across sessions: the generic park-delivery
|
|
89
|
+
* state machine specialized to read-media attachments. Feed it every stream
|
|
90
|
+
* event via `observe`; it returns a `RedeliveryRequest` exactly when a parked
|
|
91
|
+
* session has media to deliver. The caller performs the send and reports back
|
|
92
|
+
* with `settle` — on failure the media re-queue for the session's next park.
|
|
93
|
+
*
|
|
94
|
+
* Dedup is by tool call id for the session's lifetime in this process, so an
|
|
95
|
+
* attachment never delivers twice even if a failed send races a user message.
|
|
96
|
+
*/
|
|
97
|
+
export function createRedeliveryState() {
|
|
98
|
+
const core = createParkDeliveryState<ChatAttachment>();
|
|
99
|
+
|
|
100
|
+
function toRequest(
|
|
101
|
+
request: ReturnType<typeof core.observe>,
|
|
102
|
+
): RedeliveryRequest | null {
|
|
103
|
+
if (!request) return null;
|
|
104
|
+
return {
|
|
105
|
+
sessionId: request.sessionId,
|
|
106
|
+
continuationToken: request.continuationToken,
|
|
107
|
+
pending: request.items.map((item) => ({
|
|
108
|
+
toolCallId: item.key,
|
|
109
|
+
attachment: item.payload,
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
/**
|
|
116
|
+
* Consume one stream event. `continuationToken` is the hook's runtime
|
|
117
|
+
* (namespaced) token when known — latest wins; it's translated to the
|
|
118
|
+
* client-facing token the continue route accepts.
|
|
119
|
+
*/
|
|
120
|
+
observe(
|
|
121
|
+
event: unknown,
|
|
122
|
+
meta: { readonly sessionId: string; readonly continuationToken?: string },
|
|
123
|
+
): RedeliveryRequest | null {
|
|
124
|
+
// Observe first: an attachment always arrives on an action.result (a
|
|
125
|
+
// non-waiting event), so enqueue never fires an immediate delivery here
|
|
126
|
+
// — media release on the park, exactly as before the extraction.
|
|
127
|
+
const request = toRequest(core.observe(event, meta));
|
|
128
|
+
const found = redeliveryFromEvent(event);
|
|
129
|
+
if (found) {
|
|
130
|
+
core.enqueue(meta.sessionId, {
|
|
131
|
+
key: found.toolCallId,
|
|
132
|
+
payload: found.attachment,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return request;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** Report the send outcome; a failed send re-queues for the next park. */
|
|
139
|
+
settle(request: RedeliveryRequest, ok: boolean): void {
|
|
140
|
+
core.settle(
|
|
141
|
+
{
|
|
142
|
+
sessionId: request.sessionId,
|
|
143
|
+
continuationToken: request.continuationToken,
|
|
144
|
+
items: request.pending.map((p) => ({
|
|
145
|
+
key: p.toolCallId,
|
|
146
|
+
payload: p.attachment,
|
|
147
|
+
})),
|
|
148
|
+
},
|
|
149
|
+
ok,
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type RedeliveryState = ReturnType<typeof createRedeliveryState>;
|