@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,238 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Directory-conventions riders for the `read` tool: the first time a session
|
|
5
|
+
// reads a file under a directory that carries its own conventions file
|
|
6
|
+
// (AGENTS.md by default), the read result carries that file's content along —
|
|
7
|
+
// once per directory per session. This makes convention delivery structural
|
|
8
|
+
// instead of hoping the model remembers to read nested AGENTS.md files
|
|
9
|
+
// (Cursor's harness does exactly this; see
|
|
10
|
+
// journal/ben/rib/2026-07-02-learning-from-cursor.md).
|
|
11
|
+
//
|
|
12
|
+
// The root conventions file is deliberately excluded: agents inject it as a
|
|
13
|
+
// system-prompt section (createRepoConventionsInstruction), and re-delivering
|
|
14
|
+
// the largest file in the chain on the first read would double-pay for it.
|
|
15
|
+
//
|
|
16
|
+
// Riders are tool-RESULT content, appended to the transcript — they never
|
|
17
|
+
// touch tool descriptions or instructions, so this is prompt-cache-safe by
|
|
18
|
+
// construction.
|
|
19
|
+
|
|
20
|
+
/** One conventions rider attached to a read result. */
|
|
21
|
+
export type DirConventionsRider =
|
|
22
|
+
| { readonly path: string; readonly content: string }
|
|
23
|
+
| { readonly path: string; readonly note: string };
|
|
24
|
+
|
|
25
|
+
export interface DirConventionsOptions {
|
|
26
|
+
/** Workspace root; the chain walks from here (exclusive) down to the file. */
|
|
27
|
+
workspaceRoot: string;
|
|
28
|
+
/** Conventions filename to look for in each directory. Default "AGENTS.md". */
|
|
29
|
+
fileName?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Per-file content cap in bytes; an oversized conventions file becomes a
|
|
32
|
+
* pointer note instead of inline content. Default 16 KB.
|
|
33
|
+
*/
|
|
34
|
+
maxBytesPerFile?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Max conventions files inlined per read; on a first read deep in a tree,
|
|
37
|
+
* the directories nearest the file get content and the rest get pointer
|
|
38
|
+
* notes. Default 4.
|
|
39
|
+
*/
|
|
40
|
+
maxFilesPerRead?: number;
|
|
41
|
+
/**
|
|
42
|
+
* File loader, injectable for tests. Returns the file's content or null
|
|
43
|
+
* when it doesn't exist (sync or async — a sandbox-backed read is async).
|
|
44
|
+
* Defaults to a UTF-8 fs read. Callers with a per-call I/O backend (the
|
|
45
|
+
* read tool resolving a sandbox session) pass a loader to `collect`
|
|
46
|
+
* instead, which overrides this one.
|
|
47
|
+
*/
|
|
48
|
+
loadFile?: (absPath: string) => string | null | Promise<string | null>;
|
|
49
|
+
/**
|
|
50
|
+
* Max sessions tracked per workspace; least-recently-used sessions evict
|
|
51
|
+
* first (an evicted session that reappears re-delivers). Default 100.
|
|
52
|
+
*/
|
|
53
|
+
maxSessions?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DirConventionsTracker {
|
|
57
|
+
/**
|
|
58
|
+
* Riders for a read of `relPath` (workspace-relative), marking every
|
|
59
|
+
* directory on its chain as delivered for `sessionId`. No session id (a
|
|
60
|
+
* caller outside an eve session) → no riders, no tracking. `loadFile`
|
|
61
|
+
* overrides the tracker's own loader for this call — the read tool passes
|
|
62
|
+
* its per-call workspace IO here so riders come off the same backend
|
|
63
|
+
* (local disk or sandbox) as the read itself.
|
|
64
|
+
*/
|
|
65
|
+
collect(
|
|
66
|
+
sessionId: string | undefined,
|
|
67
|
+
relPath: string,
|
|
68
|
+
loadFile?: (absPath: string) => string | null | Promise<string | null>,
|
|
69
|
+
): Promise<DirConventionsRider[]>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_MAX_BYTES_PER_FILE = 16 * 1024;
|
|
73
|
+
const DEFAULT_MAX_FILES_PER_READ = 4;
|
|
74
|
+
// Bound per-tracker memory over a long-lived process; least-recently-used
|
|
75
|
+
// sessions drop first (worst case: an evicted session re-delivers).
|
|
76
|
+
const DEFAULT_MAX_SESSIONS = 100;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a workspace-relative path to forward slashes. Riders and chain
|
|
80
|
+
* entries always use `/` regardless of platform, so equality checks and
|
|
81
|
+
* reported paths can't diverge between a `/`-style relPath and a
|
|
82
|
+
* `\`-style `path.join` product on Windows.
|
|
83
|
+
*/
|
|
84
|
+
function normalizeRel(relPath: string): string {
|
|
85
|
+
return relPath
|
|
86
|
+
.split(/[\\/]+/)
|
|
87
|
+
.filter((s) => s.length > 0)
|
|
88
|
+
.join("/");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The directory chain a read of `relPath` passes through, shallow → deep,
|
|
93
|
+
* excluding the workspace root itself. Accepts `/` or `\` separators and
|
|
94
|
+
* returns `/`-joined entries. Pure; exported for tests.
|
|
95
|
+
*/
|
|
96
|
+
export function dirChain(relPath: string): string[] {
|
|
97
|
+
const segments = normalizeRel(relPath).split("/").filter((s) => s.length > 0);
|
|
98
|
+
const dirs: string[] = [];
|
|
99
|
+
// The last segment is the file itself.
|
|
100
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
101
|
+
dirs.push(segments.slice(0, i + 1).join("/"));
|
|
102
|
+
}
|
|
103
|
+
return dirs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function defaultLoadFile(absPath: string): string | null {
|
|
107
|
+
try {
|
|
108
|
+
return readFileSync(absPath, "utf8");
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Trackers are deduped per workspaceRoot+fileName on globalThis: eve's dev
|
|
115
|
+
// runtime rebuilds authored artifacts mid-session and a rebuilt module graph
|
|
116
|
+
// would otherwise hold a fresh (empty) delivered-set and re-deliver every
|
|
117
|
+
// conventions file. Same hazard and same fix as the task registry
|
|
118
|
+
// (see ./async-tasks.ts); Symbol.for is shared across module copies.
|
|
119
|
+
const TRACKER_CACHE_KEY = Symbol.for("zocomputer.agent-sdk.dir-conventions");
|
|
120
|
+
|
|
121
|
+
interface TrackerState {
|
|
122
|
+
/** sessionId → directories already considered (delivered or absent). */
|
|
123
|
+
sessions: Map<string, Set<string>>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trackerStateCache(): Map<string, TrackerState> {
|
|
127
|
+
const holder = globalThis as { [TRACKER_CACHE_KEY]?: Map<string, TrackerState> };
|
|
128
|
+
holder[TRACKER_CACHE_KEY] ??= new Map();
|
|
129
|
+
return holder[TRACKER_CACHE_KEY];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Test-only: drop the per-process tracker state dedupe. */
|
|
133
|
+
export function __resetDirConventionsCacheForTests(): void {
|
|
134
|
+
trackerStateCache().clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createDirConventionsTracker(
|
|
138
|
+
options: DirConventionsOptions,
|
|
139
|
+
): DirConventionsTracker {
|
|
140
|
+
const {
|
|
141
|
+
workspaceRoot,
|
|
142
|
+
fileName = "AGENTS.md",
|
|
143
|
+
maxBytesPerFile = DEFAULT_MAX_BYTES_PER_FILE,
|
|
144
|
+
maxFilesPerRead = DEFAULT_MAX_FILES_PER_READ,
|
|
145
|
+
loadFile = defaultLoadFile,
|
|
146
|
+
maxSessions = DEFAULT_MAX_SESSIONS,
|
|
147
|
+
} = options;
|
|
148
|
+
|
|
149
|
+
// State (not the tracker) is what dedupes: two tracker instances over the
|
|
150
|
+
// same root/fileName share one delivered-set even across module copies.
|
|
151
|
+
const cache = trackerStateCache();
|
|
152
|
+
const cacheKey = `${workspaceRoot}\u0000${fileName}`;
|
|
153
|
+
let state = cache.get(cacheKey);
|
|
154
|
+
if (!state) {
|
|
155
|
+
state = { sessions: new Map() };
|
|
156
|
+
cache.set(cacheKey, state);
|
|
157
|
+
}
|
|
158
|
+
const sessions = state.sessions;
|
|
159
|
+
|
|
160
|
+
function deliveredSet(sessionId: string): Set<string> {
|
|
161
|
+
const existing = sessions.get(sessionId);
|
|
162
|
+
// LRU: re-insert on every access so an active session is never the
|
|
163
|
+
// eviction victim — only sessions idle past 100 newer ones drop.
|
|
164
|
+
if (existing) {
|
|
165
|
+
sessions.delete(sessionId);
|
|
166
|
+
sessions.set(sessionId, existing);
|
|
167
|
+
return existing;
|
|
168
|
+
}
|
|
169
|
+
const set = new Set<string>();
|
|
170
|
+
sessions.set(sessionId, set);
|
|
171
|
+
// Maps iterate in insertion order, so the first key is least recently used.
|
|
172
|
+
while (sessions.size > maxSessions) {
|
|
173
|
+
const oldest = sessions.keys().next().value;
|
|
174
|
+
if (oldest === undefined) break;
|
|
175
|
+
sessions.delete(oldest);
|
|
176
|
+
}
|
|
177
|
+
return set;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
async collect(sessionId, relPath, loadOverride) {
|
|
182
|
+
if (!sessionId) return [];
|
|
183
|
+
const load = loadOverride ?? loadFile;
|
|
184
|
+
const delivered = deliveredSet(sessionId);
|
|
185
|
+
const normalizedRel = normalizeRel(relPath);
|
|
186
|
+
|
|
187
|
+
// Directories with an undelivered conventions file, shallow → deep.
|
|
188
|
+
// Rider paths stay `/`-joined on every platform; only the filesystem
|
|
189
|
+
// read converts to a native path (join handles `/` everywhere).
|
|
190
|
+
const found: { dir: string; path: string; content: string }[] = [];
|
|
191
|
+
for (const dir of dirChain(normalizedRel)) {
|
|
192
|
+
if (delivered.has(dir)) continue;
|
|
193
|
+
const riderRel = `${dir}/${fileName}`;
|
|
194
|
+
// Reading the conventions file itself delivers it — no rider needed.
|
|
195
|
+
if (riderRel === normalizedRel) {
|
|
196
|
+
delivered.add(dir);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Reserve the slot BEFORE the async load: a turn's tool calls run
|
|
200
|
+
// concurrently, and two reads first entering the same directory must
|
|
201
|
+
// not both deliver its conventions.
|
|
202
|
+
delivered.add(dir);
|
|
203
|
+
let loaded: string | null;
|
|
204
|
+
try {
|
|
205
|
+
loaded = await load(join(workspaceRoot, riderRel));
|
|
206
|
+
} catch {
|
|
207
|
+
// A throwing loader is a transient failure (a sandbox hop, a
|
|
208
|
+
// permissions blip), not a missing file: release the slot so a
|
|
209
|
+
// later read under this directory retries the delivery. (A
|
|
210
|
+
// concurrent read that skipped meanwhile also retries later —
|
|
211
|
+
// at-most-once holds either way.)
|
|
212
|
+
delivered.delete(dir);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Absent/empty after a successful load is a settled answer (nothing
|
|
216
|
+
// to deliver): the reservation stands and the dir never re-probes.
|
|
217
|
+
const content = loaded?.trim() ?? "";
|
|
218
|
+
if (content.length === 0) continue;
|
|
219
|
+
found.push({ dir, path: riderRel, content });
|
|
220
|
+
}
|
|
221
|
+
if (found.length === 0) return [];
|
|
222
|
+
|
|
223
|
+
// The directories nearest the file get inline content; shallower
|
|
224
|
+
// overflow (rare: >maxFilesPerRead conventions files on one chain)
|
|
225
|
+
// becomes pointer notes so the result stays bounded.
|
|
226
|
+
const inlineFrom = Math.max(0, found.length - maxFilesPerRead);
|
|
227
|
+
return found.map(({ path, content }, index) => {
|
|
228
|
+
if (index < inlineFrom || Buffer.byteLength(content, "utf8") > maxBytesPerFile) {
|
|
229
|
+
return {
|
|
230
|
+
path,
|
|
231
|
+
note: `This directory has its own conventions — read ${path} before working here.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { path, content };
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Stat-validated memo for document extraction. Paging through a long PDF
|
|
2
|
+
// re-runs `read_file` with a new offset each call; without this, every page
|
|
3
|
+
// of the window re-extracts the whole document. Keyed by path, validated by
|
|
4
|
+
// mtime+size, insertion-ordered LRU. Failures are not cached: `compute`
|
|
5
|
+
// throwing stores nothing, so a corrupt file is re-tried after it's fixed.
|
|
6
|
+
|
|
7
|
+
export interface StatIdentity {
|
|
8
|
+
readonly mtimeMs: number;
|
|
9
|
+
readonly size: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StatCache<T> {
|
|
13
|
+
get(key: string, id: StatIdentity, compute: () => Promise<T>): Promise<T>;
|
|
14
|
+
/** Current entry count, for tests. */
|
|
15
|
+
size(): number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createStatCache<T>(limit: number): StatCache<T> {
|
|
19
|
+
const entries = new Map<string, { id: StatIdentity; value: T }>();
|
|
20
|
+
return {
|
|
21
|
+
async get(key, id, compute) {
|
|
22
|
+
const hit = entries.get(key);
|
|
23
|
+
if (hit !== undefined && hit.id.mtimeMs === id.mtimeMs && hit.id.size === id.size) {
|
|
24
|
+
// Refresh recency: Map iteration order is insertion order.
|
|
25
|
+
entries.delete(key);
|
|
26
|
+
entries.set(key, hit);
|
|
27
|
+
return hit.value;
|
|
28
|
+
}
|
|
29
|
+
const value = await compute();
|
|
30
|
+
entries.delete(key);
|
|
31
|
+
entries.set(key, { id, value });
|
|
32
|
+
if (entries.size > limit) {
|
|
33
|
+
const oldest = entries.keys().next();
|
|
34
|
+
if (!oldest.done) entries.delete(oldest.value);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
},
|
|
38
|
+
size: () => entries.size,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import mammoth from "mammoth";
|
|
2
|
+
|
|
3
|
+
// DOCX → plain text via mammoth. Raw text (not HTML/markdown): the model
|
|
4
|
+
// wants the words, and `read_file`'s line-numbered view supplies structure.
|
|
5
|
+
|
|
6
|
+
export type DocxExtraction =
|
|
7
|
+
| { readonly ok: true; readonly text: string }
|
|
8
|
+
| { readonly ok: false; readonly reason: string };
|
|
9
|
+
|
|
10
|
+
export async function extractDocx(buffer: Buffer): Promise<DocxExtraction> {
|
|
11
|
+
try {
|
|
12
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
13
|
+
// mammoth ends every paragraph with "\n\n"; collapse the trailing run.
|
|
14
|
+
return { ok: true, text: result.value.replace(/\n+$/, "\n") };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { openPdf } from "clawpdf";
|
|
2
|
+
|
|
3
|
+
// PDF → text via clawpdf (PDFium compiled to WASM; no native addons). Pages
|
|
4
|
+
// are joined under explicit markers so the model can cite page numbers and
|
|
5
|
+
// `read`'s offset/limit pagination has stable anchors.
|
|
6
|
+
|
|
7
|
+
export type PdfExtraction =
|
|
8
|
+
| { readonly ok: true; readonly text: string; readonly pages: number }
|
|
9
|
+
| { readonly ok: false; readonly reason: string };
|
|
10
|
+
|
|
11
|
+
// Shown in place of a page with no text layer. eve tool results are
|
|
12
|
+
// text/json only, so the agent can't fall back to rendering the page for the
|
|
13
|
+
// model the way OpenClaw does — say so instead of showing silent emptiness.
|
|
14
|
+
export const PDF_EMPTY_PAGE_NOTE =
|
|
15
|
+
"[no text on this page — likely scanned or image-only; rendered pages cannot be attached]";
|
|
16
|
+
|
|
17
|
+
// Extraction page cap. `read`'s view budget paginates the *text*; this bounds
|
|
18
|
+
// the extraction work itself, so a 10,000-page PDF doesn't churn PDFium for
|
|
19
|
+
// pages nobody asked for. `pages` still reports the true total.
|
|
20
|
+
export const PDF_PAGE_CAP = 200;
|
|
21
|
+
|
|
22
|
+
export async function extractPdf(
|
|
23
|
+
bytes: Uint8Array,
|
|
24
|
+
options: { readonly pageCap?: number } = {},
|
|
25
|
+
): Promise<PdfExtraction> {
|
|
26
|
+
const pageCap = options.pageCap ?? PDF_PAGE_CAP;
|
|
27
|
+
let pdf: Awaited<ReturnType<typeof openPdf>>;
|
|
28
|
+
try {
|
|
29
|
+
pdf = await openPdf(bytes);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const pages = pdf.pageCount;
|
|
35
|
+
const extracted = Math.min(pages, pageCap);
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
for (let n = 1; n <= extracted; n++) {
|
|
38
|
+
// PDFium emits \r\n line separators; normalize to the repo's \n.
|
|
39
|
+
const text = pdf.page(n).text().replaceAll("\r\n", "\n").trim();
|
|
40
|
+
parts.push(`=== page ${n} of ${pages} ===`);
|
|
41
|
+
parts.push(text.length > 0 ? text : PDF_EMPTY_PAGE_NOTE);
|
|
42
|
+
}
|
|
43
|
+
if (extracted < pages) {
|
|
44
|
+
parts.push(
|
|
45
|
+
`[extraction stopped at the page cap — ${extracted} of ${pages} pages extracted]`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, text: parts.join("\n"), pages };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
51
|
+
} finally {
|
|
52
|
+
await pdf[Symbol.asyncDispose]();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { read, utils } from "xlsx";
|
|
2
|
+
|
|
3
|
+
// Spreadsheet → per-sheet TSV under explicit sheet markers. SheetJS parses the
|
|
4
|
+
// whole family from bytes (xlsx/xlsm zip, legacy xls CFB, OpenDocument ods) —
|
|
5
|
+
// detection picks the family, this module never needs to know which. Cells
|
|
6
|
+
// read as computed values (SheetJS's default), the right answer for an agent
|
|
7
|
+
// reading data. TSV over CSV: tabs survive the line-numbered view better than
|
|
8
|
+
// quoted commas.
|
|
9
|
+
|
|
10
|
+
export interface SheetMeta {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly rows: number;
|
|
13
|
+
readonly cols: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SheetExtraction =
|
|
17
|
+
| { readonly ok: true; readonly text: string; readonly sheets: readonly SheetMeta[] }
|
|
18
|
+
| { readonly ok: false; readonly reason: string };
|
|
19
|
+
|
|
20
|
+
// Per-sheet row cap. The 50 KB view budget usually bites first; this keeps a
|
|
21
|
+
// pathological million-row sheet from being TSV-serialized at all.
|
|
22
|
+
export const SHEET_ROW_CAP = 5_000;
|
|
23
|
+
|
|
24
|
+
export function extractSheets(buffer: Buffer, rowCap: number = SHEET_ROW_CAP): SheetExtraction {
|
|
25
|
+
let workbook: ReturnType<typeof read>;
|
|
26
|
+
try {
|
|
27
|
+
workbook = read(buffer, { type: "buffer", dense: true, sheetRows: rowCap + 1 });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
30
|
+
}
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
const sheets: SheetMeta[] = [];
|
|
33
|
+
for (const name of workbook.SheetNames) {
|
|
34
|
+
const sheet = workbook.Sheets[name];
|
|
35
|
+
if (sheet === undefined) continue;
|
|
36
|
+
const ref = sheet["!fullref"] ?? sheet["!ref"];
|
|
37
|
+
const range = typeof ref === "string" ? utils.decode_range(ref) : null;
|
|
38
|
+
const rows = range === null ? 0 : range.e.r - range.s.r + 1;
|
|
39
|
+
const cols = range === null ? 0 : range.e.c - range.s.c + 1;
|
|
40
|
+
sheets.push({ name, rows, cols });
|
|
41
|
+
parts.push(`=== sheet "${name}" (${rows} rows x ${cols} cols) ===`);
|
|
42
|
+
if (rows === 0) {
|
|
43
|
+
parts.push("[empty sheet]");
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const tsv = utils.sheet_to_csv(sheet, { FS: "\t", blankrows: false }).replace(/\n+$/, "");
|
|
47
|
+
const lines = tsv.split("\n");
|
|
48
|
+
if (lines.length > rowCap) {
|
|
49
|
+
parts.push(lines.slice(0, rowCap).join("\n"));
|
|
50
|
+
parts.push(`[sheet truncated at ${rowCap} rows]`);
|
|
51
|
+
} else {
|
|
52
|
+
parts.push(tsv);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, text: parts.join("\n"), sheets };
|
|
56
|
+
}
|
package/src/file-kind.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
|
|
3
|
+
// Content-first filetype detection for the `read` tool. Magic bytes decide the
|
|
4
|
+
// family — an extension can lie, bytes can't — and the extension only
|
|
5
|
+
// disambiguates within a container family (docx/xlsx/pptx share the same zip
|
|
6
|
+
// magic; xls/doc/ppt share the same CFB magic; telling them apart from content
|
|
7
|
+
// would mean parsing container entries).
|
|
8
|
+
|
|
9
|
+
export type ImageFormat = "png" | "jpeg" | "gif" | "webp";
|
|
10
|
+
|
|
11
|
+
/** The MIME type for a detected image format (for data URLs / file parts). */
|
|
12
|
+
export function imageMediaType(format: ImageFormat): string {
|
|
13
|
+
return `image/${format}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Video containers `read` detects. Delivery to the model is provider-gated
|
|
17
|
+
* (see the attach options on the read/webfetch factories). */
|
|
18
|
+
export type VideoFormat = "mp4" | "mov" | "webm" | "mkv" | "avi";
|
|
19
|
+
|
|
20
|
+
/** The MIME type for a detected video format (for data URLs / file parts). */
|
|
21
|
+
export function videoMediaType(format: VideoFormat): string {
|
|
22
|
+
switch (format) {
|
|
23
|
+
case "mp4":
|
|
24
|
+
return "video/mp4";
|
|
25
|
+
case "mov":
|
|
26
|
+
return "video/quicktime";
|
|
27
|
+
case "webm":
|
|
28
|
+
return "video/webm";
|
|
29
|
+
case "mkv":
|
|
30
|
+
return "video/x-matroska";
|
|
31
|
+
case "avi":
|
|
32
|
+
return "video/x-msvideo";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Audio formats `read` detects. Same provider-gated delivery as video. */
|
|
37
|
+
export type AudioFormat = "mp3" | "wav" | "ogg" | "flac" | "m4a";
|
|
38
|
+
|
|
39
|
+
/** The MIME type for a detected audio format (for data URLs / file parts). */
|
|
40
|
+
export function audioMediaType(format: AudioFormat): string {
|
|
41
|
+
switch (format) {
|
|
42
|
+
case "mp3":
|
|
43
|
+
return "audio/mpeg";
|
|
44
|
+
case "wav":
|
|
45
|
+
return "audio/wav";
|
|
46
|
+
case "ogg":
|
|
47
|
+
return "audio/ogg";
|
|
48
|
+
case "flac":
|
|
49
|
+
return "audio/flac";
|
|
50
|
+
case "m4a":
|
|
51
|
+
return "audio/mp4";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Spreadsheet formats SheetJS parses; all route to the sheet extractor. */
|
|
56
|
+
export type SheetFormat = "xlsx" | "xlsm" | "xls" | "ods";
|
|
57
|
+
|
|
58
|
+
/** Text encodings `read` decodes. UTF-16 is BOM-detected (a NUL-sniff alone
|
|
59
|
+
* would misclassify UTF-16 text — common in Windows-exported CSVs — as binary). */
|
|
60
|
+
export type TextEncoding = "utf8" | "utf16le" | "utf16be";
|
|
61
|
+
|
|
62
|
+
export type FileKind =
|
|
63
|
+
| { readonly kind: "text"; readonly encoding: TextEncoding }
|
|
64
|
+
| { readonly kind: "pdf" }
|
|
65
|
+
| { readonly kind: "docx" }
|
|
66
|
+
| { readonly kind: "sheet"; readonly format: SheetFormat }
|
|
67
|
+
| { readonly kind: "image"; readonly format: ImageFormat }
|
|
68
|
+
| { readonly kind: "video"; readonly format: VideoFormat }
|
|
69
|
+
| { readonly kind: "audio"; readonly format: AudioFormat }
|
|
70
|
+
| { readonly kind: "binary"; readonly description: string };
|
|
71
|
+
|
|
72
|
+
// A NUL byte in the first 8 KB marks a file binary — the same heuristic git
|
|
73
|
+
// and ripgrep use (and read-text.ts uses for search).
|
|
74
|
+
const BINARY_SNIFF_BYTES = 8_192;
|
|
75
|
+
|
|
76
|
+
function startsWith(buf: Buffer, bytes: readonly number[], at = 0): boolean {
|
|
77
|
+
if (buf.length < at + bytes.length) return false;
|
|
78
|
+
for (const [i, b] of bytes.entries()) if (buf[at + i] !== b) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
|
|
83
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
84
|
+
const JPEG_MAGIC = [0xff, 0xd8, 0xff];
|
|
85
|
+
const GIF_MAGIC = [0x47, 0x49, 0x46, 0x38]; // GIF8
|
|
86
|
+
const RIFF_MAGIC = [0x52, 0x49, 0x46, 0x46]; // RIFF
|
|
87
|
+
const WEBP_TAG = [0x57, 0x45, 0x42, 0x50]; // WEBP at offset 8
|
|
88
|
+
const AVI_TAG = [0x41, 0x56, 0x49, 0x20]; // "AVI " at offset 8
|
|
89
|
+
const WAVE_TAG = [0x57, 0x41, 0x56, 0x45]; // WAVE at offset 8
|
|
90
|
+
const FTYP_TAG = [0x66, 0x74, 0x79, 0x70]; // ftyp at offset 4 (ISO BMFF: mp4/mov/m4a/heic)
|
|
91
|
+
const EBML_MAGIC = [0x1a, 0x45, 0xdf, 0xa3]; // Matroska/WebM
|
|
92
|
+
const ID3_MAGIC = [0x49, 0x44, 0x33]; // ID3 (mp3 tag header)
|
|
93
|
+
const OGG_MAGIC = [0x4f, 0x67, 0x67, 0x53]; // OggS
|
|
94
|
+
const FLAC_MAGIC = [0x66, 0x4c, 0x61, 0x43]; // fLaC
|
|
95
|
+
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // PK\x03\x04
|
|
96
|
+
const CFB_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; // legacy Office container
|
|
97
|
+
const UTF16LE_BOM = [0xff, 0xfe];
|
|
98
|
+
const UTF16BE_BOM = [0xfe, 0xff];
|
|
99
|
+
|
|
100
|
+
// ISO BMFF `ftyp` major brands that are still-image containers (HEIF/AVIF),
|
|
101
|
+
// not video — models take png/jpeg/gif/webp, so these get an honest rejection
|
|
102
|
+
// steering toward conversion instead of misclassifying as mp4.
|
|
103
|
+
const BMFF_IMAGE_BRANDS = new Set([
|
|
104
|
+
"avif",
|
|
105
|
+
"avis",
|
|
106
|
+
"heic",
|
|
107
|
+
"heix",
|
|
108
|
+
"heim",
|
|
109
|
+
"heis",
|
|
110
|
+
"hevc",
|
|
111
|
+
"hevx",
|
|
112
|
+
"mif1",
|
|
113
|
+
"msf1",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
// `M4A `/`M4B `/`M4P ` mark audio-only MP4 containers.
|
|
117
|
+
const BMFF_AUDIO_BRANDS = new Set(["M4A ", "M4B ", "M4P "]);
|
|
118
|
+
|
|
119
|
+
// Audio-only MP4 extensions, the tiebreak when the brands are generic.
|
|
120
|
+
const BMFF_AUDIO_EXTENSIONS = new Set([".m4a", ".m4b", ".m4p"]);
|
|
121
|
+
|
|
122
|
+
// Corrupt ftyp sizes shouldn't send the brand scan across the whole file;
|
|
123
|
+
// real ftyp boxes are a few dozen bytes.
|
|
124
|
+
const BMFF_FTYP_SCAN_CAP = 256;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every brand in the `ftyp` box: the major brand (bytes 8–12) plus the
|
|
128
|
+
* compatible-brands list (bytes 16 to the box's size, 4 bytes each). Audio
|
|
129
|
+
* files often carry a generic major brand (`isom`, `mp42`) with `M4A ` only
|
|
130
|
+
* in the compatible list, so classification must read all of them.
|
|
131
|
+
*/
|
|
132
|
+
function bmffBrands(buf: Buffer): string[] {
|
|
133
|
+
const boxSize = buf.length >= 4 ? buf.readUInt32BE(0) : 0;
|
|
134
|
+
const end = Math.min(boxSize, buf.length, BMFF_FTYP_SCAN_CAP);
|
|
135
|
+
const brands = [buf.subarray(8, 12).toString("latin1")];
|
|
136
|
+
for (let off = 16; off + 4 <= end; off += 4) {
|
|
137
|
+
brands.push(buf.subarray(off, off + 4).toString("latin1"));
|
|
138
|
+
}
|
|
139
|
+
return brands;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Classify an ISO BMFF (`ftyp`) container by its brands. */
|
|
143
|
+
function bmffKind(buf: Buffer, path: string): FileKind {
|
|
144
|
+
const brands = bmffBrands(buf);
|
|
145
|
+
const major = brands[0] ?? "";
|
|
146
|
+
// Like audio below, image brands can hide behind a generic major brand
|
|
147
|
+
// (HEIC with major `mif1` carries `heic` in the compatible list — and
|
|
148
|
+
// `mif1` itself is generic enough that some encoders bury both).
|
|
149
|
+
const imageBrand = brands.find((brand) =>
|
|
150
|
+
BMFF_IMAGE_BRANDS.has(brand.toLowerCase()),
|
|
151
|
+
);
|
|
152
|
+
if (imageBrand !== undefined) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "binary",
|
|
155
|
+
description: `an HEIF/AVIF image (brand "${imageBrand.trim()}") with no supported renderer — convert it to PNG or JPEG`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (brands.some((brand) => BMFF_AUDIO_BRANDS.has(brand))) {
|
|
159
|
+
return { kind: "audio", format: "m4a" };
|
|
160
|
+
}
|
|
161
|
+
if (major.startsWith("qt")) return { kind: "video", format: "mov" };
|
|
162
|
+
// Generic brands (isom/iso2/mp41/mp42…) say "MP4 container", not what's
|
|
163
|
+
// inside; honor an audio extension before defaulting to video.
|
|
164
|
+
if (BMFF_AUDIO_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
165
|
+
return { kind: "audio", format: "m4a" };
|
|
166
|
+
}
|
|
167
|
+
return { kind: "video", format: "mp4" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Classify an EBML container: WebM vs generic Matroska by DocType. */
|
|
171
|
+
function ebmlKind(buf: Buffer): FileKind {
|
|
172
|
+
// The DocType string sits in the EBML header, always within the first
|
|
173
|
+
// few dozen bytes; a plain substring scan beats parsing element lengths.
|
|
174
|
+
const header = buf.subarray(0, 64).toString("latin1");
|
|
175
|
+
if (header.includes("webm")) return { kind: "video", format: "webm" };
|
|
176
|
+
return { kind: "video", format: "mkv" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function zipKind(path: string): FileKind {
|
|
180
|
+
switch (extname(path).toLowerCase()) {
|
|
181
|
+
case ".docx":
|
|
182
|
+
return { kind: "docx" };
|
|
183
|
+
case ".xlsx":
|
|
184
|
+
return { kind: "sheet", format: "xlsx" };
|
|
185
|
+
case ".xlsm":
|
|
186
|
+
return { kind: "sheet", format: "xlsm" };
|
|
187
|
+
case ".ods":
|
|
188
|
+
return { kind: "sheet", format: "ods" };
|
|
189
|
+
case ".pptx":
|
|
190
|
+
return { kind: "binary", description: "a PowerPoint deck (.pptx) with no text extractor" };
|
|
191
|
+
case ".odt":
|
|
192
|
+
return {
|
|
193
|
+
kind: "binary",
|
|
194
|
+
description: "an OpenDocument text file (.odt) with no text extractor",
|
|
195
|
+
};
|
|
196
|
+
case ".epub":
|
|
197
|
+
return { kind: "binary", description: "an EPUB e-book with no text extractor" };
|
|
198
|
+
default:
|
|
199
|
+
return { kind: "binary", description: "a zip archive" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function cfbKind(path: string): FileKind {
|
|
204
|
+
switch (extname(path).toLowerCase()) {
|
|
205
|
+
case ".xls":
|
|
206
|
+
return { kind: "sheet", format: "xls" };
|
|
207
|
+
case ".doc":
|
|
208
|
+
return {
|
|
209
|
+
kind: "binary",
|
|
210
|
+
description: "a legacy Word document (.doc) with no text extractor — convert it to .docx",
|
|
211
|
+
};
|
|
212
|
+
case ".ppt":
|
|
213
|
+
return {
|
|
214
|
+
kind: "binary",
|
|
215
|
+
description:
|
|
216
|
+
"a legacy PowerPoint deck (.ppt) with no text extractor — convert it to .pptx",
|
|
217
|
+
};
|
|
218
|
+
default:
|
|
219
|
+
return { kind: "binary", description: "a legacy Office (CFB) container" };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function detectFileKind(buf: Buffer, path: string): FileKind {
|
|
224
|
+
if (startsWith(buf, PDF_MAGIC)) return { kind: "pdf" };
|
|
225
|
+
if (startsWith(buf, PNG_MAGIC)) return { kind: "image", format: "png" };
|
|
226
|
+
if (startsWith(buf, JPEG_MAGIC)) return { kind: "image", format: "jpeg" };
|
|
227
|
+
if (startsWith(buf, GIF_MAGIC)) return { kind: "image", format: "gif" };
|
|
228
|
+
if (startsWith(buf, RIFF_MAGIC)) {
|
|
229
|
+
if (startsWith(buf, WEBP_TAG, 8)) return { kind: "image", format: "webp" };
|
|
230
|
+
if (startsWith(buf, AVI_TAG, 8)) return { kind: "video", format: "avi" };
|
|
231
|
+
if (startsWith(buf, WAVE_TAG, 8)) return { kind: "audio", format: "wav" };
|
|
232
|
+
// Other RIFF payloads fall through to the NUL sniff (→ binary).
|
|
233
|
+
}
|
|
234
|
+
if (startsWith(buf, FTYP_TAG, 4)) return bmffKind(buf, path);
|
|
235
|
+
if (startsWith(buf, EBML_MAGIC)) return ebmlKind(buf);
|
|
236
|
+
if (startsWith(buf, OGG_MAGIC)) return { kind: "audio", format: "ogg" };
|
|
237
|
+
if (startsWith(buf, FLAC_MAGIC)) return { kind: "audio", format: "flac" };
|
|
238
|
+
if (startsWith(buf, ID3_MAGIC)) return { kind: "audio", format: "mp3" };
|
|
239
|
+
// A bare MPEG frame sync (no ID3 tag) is only 11 set bits — too weak a
|
|
240
|
+
// signature alone, so require the extension to agree.
|
|
241
|
+
if (
|
|
242
|
+
buf.length >= 2 &&
|
|
243
|
+
buf[0] === 0xff &&
|
|
244
|
+
((buf[1] ?? 0) & 0xe0) === 0xe0 &&
|
|
245
|
+
extname(path).toLowerCase() === ".mp3"
|
|
246
|
+
) {
|
|
247
|
+
return { kind: "audio", format: "mp3" };
|
|
248
|
+
}
|
|
249
|
+
if (startsWith(buf, ZIP_MAGIC)) return zipKind(path);
|
|
250
|
+
if (startsWith(buf, CFB_MAGIC)) return cfbKind(path);
|
|
251
|
+
// BOM checks must precede the NUL sniff: UTF-16 text is full of NULs.
|
|
252
|
+
if (startsWith(buf, UTF16LE_BOM)) return { kind: "text", encoding: "utf16le" };
|
|
253
|
+
if (startsWith(buf, UTF16BE_BOM)) return { kind: "text", encoding: "utf16be" };
|
|
254
|
+
if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
|
|
255
|
+
return { kind: "binary", description: "binary data (unrecognized format)" };
|
|
256
|
+
}
|
|
257
|
+
return { kind: "text", encoding: "utf8" };
|
|
258
|
+
}
|