@arnilo/prism-tools 0.0.0-stage → 0.13.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/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/browser/README.md +98 -0
- package/browser/skills/browser-automation/SKILL.md +14 -0
- package/browser/src/actions/cdp.ts +131 -0
- package/browser/src/actions/dialog.ts +25 -0
- package/browser/src/actions/download_release.ts +22 -0
- package/browser/src/actions/index.ts +37 -0
- package/browser/src/actions/locator.ts +70 -0
- package/browser/src/actions/navigate.ts +23 -0
- package/browser/src/actions/screenshot.ts +27 -0
- package/browser/src/actions/scroll.ts +24 -0
- package/browser/src/actions/select_page.ts +21 -0
- package/browser/src/actions/types.ts +114 -0
- package/browser/src/actions/upload.ts +44 -0
- package/browser/src/actions/wait.ts +45 -0
- package/browser/src/browser.ts +197 -0
- package/browser/src/cdp.ts +178 -0
- package/browser/src/checkpoint.ts +189 -0
- package/browser/src/config.ts +72 -0
- package/browser/src/downloads.ts +253 -0
- package/browser/src/errors.ts +31 -0
- package/browser/src/evaluate.ts +101 -0
- package/browser/src/index.ts +129 -0
- package/browser/src/limits.ts +271 -0
- package/browser/src/manager.ts +730 -0
- package/browser/src/network.ts +263 -0
- package/browser/src/observe.ts +178 -0
- package/browser/src/policy.ts +133 -0
- package/browser/src/screenshot.ts +107 -0
- package/browser/src/shared-sandbox.ts +53 -0
- package/browser/src/snapshot.ts +99 -0
- package/browser/src/targets.ts +165 -0
- package/browser/src/tools.ts +480 -0
- package/browser/src/types.ts +316 -0
- package/browser/src/uploads.ts +174 -0
- package/coding/README.md +337 -0
- package/coding/src/agent/acp-operations.ts +146 -0
- package/coding/src/agent/artifacts.ts +38 -0
- package/coding/src/agent/ask-user-decision.ts +623 -0
- package/coding/src/agent/atomic-write.ts +23 -0
- package/coding/src/agent/bounded-file.ts +21 -0
- package/coding/src/agent/checks.ts +301 -0
- package/coding/src/agent/coding-checkpoint.ts +828 -0
- package/coding/src/agent/delete.ts +237 -0
- package/coding/src/agent/diagnostics-tool.ts +163 -0
- package/coding/src/agent/diagnostics.ts +262 -0
- package/coding/src/agent/edit-diff.ts +382 -0
- package/coding/src/agent/edit.ts +322 -0
- package/coding/src/agent/effects.ts +102 -0
- package/coding/src/agent/env.ts +28 -0
- package/coding/src/agent/execution-policy.ts +89 -0
- package/coding/src/agent/file-mutation-queue.ts +72 -0
- package/coding/src/agent/forge/github.ts +603 -0
- package/coding/src/agent/forge/index.ts +16 -0
- package/coding/src/agent/forge/types.ts +185 -0
- package/coding/src/agent/git-aware-repository.ts +305 -0
- package/coding/src/agent/git-exec.ts +326 -0
- package/coding/src/agent/git-status.ts +177 -0
- package/coding/src/agent/git-tools.ts +360 -0
- package/coding/src/agent/git.ts +1074 -0
- package/coding/src/agent/glob-match.ts +146 -0
- package/coding/src/agent/glob.ts +182 -0
- package/coding/src/agent/goal-verify.ts +385 -0
- package/coding/src/agent/grep-worker.ts +74 -0
- package/coding/src/agent/grep.ts +459 -0
- package/coding/src/agent/identity.ts +48 -0
- package/coding/src/agent/index.ts +676 -0
- package/coding/src/agent/language/client.ts +429 -0
- package/coding/src/agent/language/framing.ts +190 -0
- package/coding/src/agent/language/index.ts +23 -0
- package/coding/src/agent/language/intelligence.ts +682 -0
- package/coding/src/agent/language/types.ts +187 -0
- package/coding/src/agent/lifecycle-events.ts +121 -0
- package/coding/src/agent/lifecycle.ts +166 -0
- package/coding/src/agent/limits.ts +214 -0
- package/coding/src/agent/move.ts +178 -0
- package/coding/src/agent/mutation-path.ts +52 -0
- package/coding/src/agent/output-accumulator.ts +304 -0
- package/coding/src/agent/path-utils.ts +120 -0
- package/coding/src/agent/process/index.ts +58 -0
- package/coding/src/agent/process/recovery.ts +508 -0
- package/coding/src/agent/process/sessions-host.ts +222 -0
- package/coding/src/agent/process/sessions-monitor.ts +141 -0
- package/coding/src/agent/process/sessions-recovery.ts +241 -0
- package/coding/src/agent/process/sessions-spawn.ts +613 -0
- package/coding/src/agent/process/sessions-teardown.ts +281 -0
- package/coding/src/agent/process/sessions.ts +30 -0
- package/coding/src/agent/process/types.ts +358 -0
- package/coding/src/agent/read-path-set.ts +126 -0
- package/coding/src/agent/read.ts +713 -0
- package/coding/src/agent/repository/glob.ts +147 -0
- package/coding/src/agent/repository/indexed-search.ts +480 -0
- package/coding/src/agent/repository/list.ts +131 -0
- package/coding/src/agent/repository/operations.ts +22 -0
- package/coding/src/agent/repository/path.ts +98 -0
- package/coding/src/agent/repository/search.ts +277 -0
- package/coding/src/agent/repository/types.ts +243 -0
- package/coding/src/agent/repository/walk.ts +126 -0
- package/coding/src/agent/repository.ts +12 -0
- package/coding/src/agent/shell-background.ts +264 -0
- package/coding/src/agent/shell-output.ts +75 -0
- package/coding/src/agent/shell.ts +526 -0
- package/coding/src/agent/todo.ts +399 -0
- package/coding/src/agent/truncate.ts +251 -0
- package/coding/src/agent/write.ts +170 -0
- package/coding/src/ask-user-host.ts +48 -0
- package/coding/src/config.ts +278 -0
- package/coding/src/hard-deny.ts +97 -0
- package/coding/src/index.ts +318 -0
- package/coding/src/principal-guard.ts +84 -0
- package/coding/src/sandbox.ts +92 -0
- package/coding/src/security/approval.ts +144 -0
- package/coding/src/security/command-rules.ts +57 -0
- package/coding/src/security/docker-cli.ts +222 -0
- package/coding/src/security/docker-sandbox.ts +1205 -0
- package/coding/src/security/e2b-sandbox.ts +853 -0
- package/coding/src/security/egress/dns-pin.ts +88 -0
- package/coding/src/security/egress/index.ts +32 -0
- package/coding/src/security/egress/limits.ts +57 -0
- package/coding/src/security/egress/policy.ts +137 -0
- package/coding/src/security/egress/proxy.ts +599 -0
- package/coding/src/security/egress/types.ts +30 -0
- package/coding/src/security/index.ts +199 -0
- package/coding/src/security/native-sandbox.ts +639 -0
- package/coding/src/security/path-containment.ts +55 -0
- package/coding/src/security/sandbox-coding-operations.ts +392 -0
- package/coding/src/security/sandbox-fs-operations.ts +644 -0
- package/coding/src/security/sandbox-limits.ts +147 -0
- package/coding/src/security/sandbox-tar.ts +308 -0
- package/coding/src/security/sandbox.ts +179 -0
- package/coding/src/security/semaphore.ts +55 -0
- package/coding/src/targets.ts +110 -0
- package/coding/src/workspace/diff.ts +78 -0
- package/coding/src/workspace/errors.ts +28 -0
- package/coding/src/workspace/index.ts +5 -0
- package/coding/src/workspace/merge.ts +151 -0
- package/coding/src/workspace/routing.ts +76 -0
- package/coding/src/workspace/service.ts +545 -0
- package/computer-use/README.md +105 -0
- package/computer-use/skills/computer-use-linux/SKILL.md +17 -0
- package/computer-use/src/classify.ts +53 -0
- package/computer-use/src/command.ts +29 -0
- package/computer-use/src/config.ts +69 -0
- package/computer-use/src/create.ts +170 -0
- package/computer-use/src/devices.ts +247 -0
- package/computer-use/src/env.ts +28 -0
- package/computer-use/src/index.ts +157 -0
- package/computer-use/src/skill.ts +21 -0
- package/openapi/README.md +97 -0
- package/openapi/src/compile.ts +386 -0
- package/openapi/src/config.ts +73 -0
- package/openapi/src/errors.ts +16 -0
- package/openapi/src/execute.ts +241 -0
- package/openapi/src/index.ts +124 -0
- package/openapi/src/limits.ts +63 -0
- package/openapi/src/specs.ts +86 -0
- package/openapi/src/tools.ts +60 -0
- package/openapi/src/types.ts +61 -0
- package/package.json +171 -4
- package/research/README.md +72 -0
- package/research/skills/research-report/SKILL.md +12 -0
- package/research/skills/research-scoping/SKILL.md +10 -0
- package/research/skills/research-synthesis/SKILL.md +10 -0
- package/research/skills/source-triage/SKILL.md +10 -0
- package/research/src/check.ts +71 -0
- package/research/src/config.ts +41 -0
- package/research/src/index.ts +147 -0
- package/research/src/ledger.ts +157 -0
- package/research/src/urls.ts +21 -0
- package/web/README.md +287 -0
- package/web/skills/web-research/SKILL.md +17 -0
- package/web/src/brave.ts +66 -0
- package/web/src/cache.ts +151 -0
- package/web/src/config.ts +210 -0
- package/web/src/controls.ts +128 -0
- package/web/src/domains.ts +114 -0
- package/web/src/exa.ts +60 -0
- package/web/src/firecrawl.ts +160 -0
- package/web/src/index.ts +387 -0
- package/web/src/kagi.ts +73 -0
- package/web/src/limits.ts +55 -0
- package/web/src/native/convert.ts +84 -0
- package/web/src/native/fetch.ts +226 -0
- package/web/src/normalize.ts +131 -0
- package/web/src/obscura/cdp.ts +201 -0
- package/web/src/obscura/classify.ts +35 -0
- package/web/src/obscura/cli.ts +254 -0
- package/web/src/obscura/errors.ts +8 -0
- package/web/src/obscura/index.ts +59 -0
- package/web/src/obscura/limits.ts +92 -0
- package/web/src/obscura/mcp.ts +104 -0
- package/web/src/obscura/playwright.ts +133 -0
- package/web/src/obscura/process.ts +235 -0
- package/web/src/obscura/search-profile.ts +73 -0
- package/web/src/obscura/types.ts +48 -0
- package/web/src/obscura/web.ts +419 -0
- package/web/src/prompt-fetch.ts +88 -0
- package/web/src/render.ts +75 -0
- package/web/src/searxng.ts +78 -0
- package/web/src/slice.ts +139 -0
- package/web/src/tavily.ts +77 -0
- package/web/src/tools.ts +182 -0
- package/web/src/transport.ts +310 -0
- package/web/src/types.ts +199 -0
- package/work/README.md +77 -0
- package/work/THIRD_PARTY_NOTICES +24 -0
- package/work/docling/ocr.py +150 -0
- package/work/sandbox/Dockerfile +36 -0
- package/work/sandbox/soffice.sh +12 -0
- package/work/src/command.ts +29 -0
- package/work/src/config.ts +138 -0
- package/work/src/connectors/cli.ts +343 -0
- package/work/src/connectors/drafts.ts +647 -0
- package/work/src/connectors/errors.ts +8 -0
- package/work/src/connectors/file-bytes.ts +149 -0
- package/work/src/connectors/google-workspace-http.ts +399 -0
- package/work/src/connectors/google-workspace.ts +465 -0
- package/work/src/connectors/gws-tools.ts +378 -0
- package/work/src/connectors/http.ts +123 -0
- package/work/src/connectors/idempotency.ts +235 -0
- package/work/src/connectors/identity.ts +34 -0
- package/work/src/connectors/index.ts +93 -0
- package/work/src/connectors/limits.ts +50 -0
- package/work/src/connectors/m365-tools.ts +308 -0
- package/work/src/connectors/microsoft365-http.ts +428 -0
- package/work/src/connectors/microsoft365.ts +402 -0
- package/work/src/connectors/normalize.ts +120 -0
- package/work/src/connectors/tool-helpers.ts +398 -0
- package/work/src/connectors/tools.ts +42 -0
- package/work/src/connectors/types.ts +399 -0
- package/work/src/diagrams/canonicalize.ts +166 -0
- package/work/src/diagrams/caps.ts +52 -0
- package/work/src/diagrams/embed.ts +340 -0
- package/work/src/diagrams/errors.ts +76 -0
- package/work/src/diagrams/index.ts +81 -0
- package/work/src/diagrams/messages.ts +150 -0
- package/work/src/diagrams/origin.ts +52 -0
- package/work/src/diagrams/telemetry.ts +34 -0
- package/work/src/diagrams/xml.ts +267 -0
- package/work/src/document-extraction/index.ts +563 -0
- package/work/src/document-reader/ambient.d.ts +2 -0
- package/work/src/document-reader/errors.ts +7 -0
- package/work/src/document-reader/index.ts +181 -0
- package/work/src/document-reader/mistral-ocr.ts +318 -0
- package/work/src/document-reader/parsers.ts +171 -0
- package/work/src/documents/caps.ts +135 -0
- package/work/src/documents/diff.ts +269 -0
- package/work/src/documents/errors.ts +44 -0
- package/work/src/documents/fidelity.ts +98 -0
- package/work/src/documents/generate.ts +96 -0
- package/work/src/documents/hash.ts +8 -0
- package/work/src/documents/index.ts +14 -0
- package/work/src/documents/model-schema.ts +639 -0
- package/work/src/documents/parse.ts +252 -0
- package/work/src/documents/patch-history.ts +81 -0
- package/work/src/documents/patch.ts +476 -0
- package/work/src/documents/preview-html.ts +245 -0
- package/work/src/documents/preview.ts +227 -0
- package/work/src/documents/telemetry.ts +36 -0
- package/work/src/documents/translate/docx.ts +292 -0
- package/work/src/documents/translate/pptx.ts +304 -0
- package/work/src/documents/translate/xlsx.ts +137 -0
- package/work/src/documents/types.ts +188 -0
- package/work/src/index.ts +311 -0
- package/work/src/runtime/worker-pool-entry.ts +102 -0
- package/work/src/runtime/worker-pool.ts +267 -0
- package/work/src/sandbox/composition.ts +212 -0
- package/work/src/sandbox/image.ts +6 -0
- package/work/src/sandbox/index.ts +14 -0
- package/work/src/sheets/caps.ts +68 -0
- package/work/src/sheets/csv.ts +351 -0
- package/work/src/sheets/decimal.ts +388 -0
- package/work/src/sheets/errors.ts +52 -0
- package/work/src/sheets/index.ts +62 -0
- package/work/src/sheets/inference.ts +378 -0
- package/work/src/sheets/telemetry.ts +34 -0
- package/work/src/sheets/types.ts +162 -0
- package/work/src/sheets/xlsx.ts +388 -0
- package/work/src/skills/index.ts +8 -0
- package/work/src/skills/load.ts +43 -0
- package/work/src/tools/filesystem.ts +21 -0
- package/work/src/tools/index.ts +1 -0
- package/work/src/tools/local-filesystem.ts +33 -0
- package/work/src/tools/office.ts +311 -0
- package/work/vendor/hermes-agent/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_comments.py +289 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_common.py +94 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_create.py +177 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_edit.py +250 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_read.py +149 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_revisions.py +147 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_template.py +70 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_validate.py +156 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/SKILL.md +125 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/_raster.py +76 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_marker.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_pymupdf.py +101 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_create.py +130 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_fill_form.py +97 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_form_layout.py +168 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_make_form.py +145 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_merge.py +50 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_meta.py +115 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_page_image.py +99 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_read.py +153 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_secure.py +71 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_split.py +84 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_stamp.py +143 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_watermark.py +51 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/SKILL.md +220 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_create.py +214 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_edit.py +436 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_from_template.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_read.py +131 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_render.py +93 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py +259 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_edit.py +263 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_read.py +160 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_recalc.py +111 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_restructure.py +337 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/work/vendor-lock.json +11 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read tool: read a file from the host filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Behavioral port of pi's core/tools/read for @arnilo/prism-tools/coding, adapted to Prism's
|
|
5
|
+
* `ToolDefinition` contract. Keeps pi's offset/limit continuation behavior while streaming one
|
|
6
|
+
* bounded text page; image path remains magic-byte MIME → bounded `ImageContent`. Drops pi's
|
|
7
|
+
* TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
|
|
8
|
+
* the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
|
|
9
|
+
*
|
|
10
|
+
* Deviations from pi (documented):
|
|
11
|
+
* - **Image resize is host-owned.** pi resizes images to ≤2000×2000 via a photon/WASM +
|
|
12
|
+
* `worker_threads` helper (`utils/image-process.js`); this package rejects oversize images by
|
|
13
|
+
* `stat`/`buffer.length` against `maxImageBytes` and accepts an optional `transformImage`
|
|
14
|
+
* callback for host-provided resizing.
|
|
15
|
+
* - Abort + all read failures return a Prism `error` result (pi throws/rejects). Prism's
|
|
16
|
+
* `dispatchToolCall` would catch a throw anyway, but returning a clean error result is predictable
|
|
17
|
+
* for direct-`execute` callers and matches the package's `shell` tool.
|
|
18
|
+
* - Truncation footers say "Use the shell tool" (pi: "Use bash") since the package's shell tool is
|
|
19
|
+
* named `shell`.
|
|
20
|
+
*/
|
|
21
|
+
import { Buffer } from "node:buffer";
|
|
22
|
+
import { constants } from "node:fs";
|
|
23
|
+
import { access as fsAccess, stat as fsStat, open } from "node:fs/promises";
|
|
24
|
+
import type { JsonObject, ToolDefinition, ToolExecutionContext, ToolResult } from "@arnilo/prism";
|
|
25
|
+
import { readFileBounded } from "./bounded-file.js";
|
|
26
|
+
import { CODING_OBSERVATION_EFFECT } from "./effects.js";
|
|
27
|
+
import type { ExecutionPolicy } from "./execution-policy.js";
|
|
28
|
+
import { enforceExecutionPolicy } from "./execution-policy.js";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_MAX_BYTES,
|
|
31
|
+
DEFAULT_MAX_IMAGE_BYTES,
|
|
32
|
+
DEFAULT_MAX_LINES,
|
|
33
|
+
DEFAULT_MAX_TEXT_SCAN_BYTES,
|
|
34
|
+
HARD_MAX_BYTES,
|
|
35
|
+
HARD_MAX_IMAGE_BYTES,
|
|
36
|
+
HARD_MAX_LINES,
|
|
37
|
+
HARD_MAX_TEXT_SCAN_BYTES,
|
|
38
|
+
validateCodingLimit,
|
|
39
|
+
} from "./limits.js";
|
|
40
|
+
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.js";
|
|
41
|
+
import type { ReadPathSet } from "./read-path-set.js";
|
|
42
|
+
import { formatSize, type TruncationResult } from "./truncate.js";
|
|
43
|
+
|
|
44
|
+
// --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
|
|
45
|
+
|
|
46
|
+
const IMAGE_TYPE_SNIFF_BYTES = 4100;
|
|
47
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
48
|
+
|
|
49
|
+
/** Detect a supported image MIME type from a buffer's leading bytes. Returns null for non-images. */
|
|
50
|
+
export function detectSupportedImageMimeType(buffer: Buffer): string | null {
|
|
51
|
+
if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
|
|
52
|
+
// SOI marker; 0xff 0xd8 0xff 0xf7 is a JFIF-extension frame, not a standalone JPEG image.
|
|
53
|
+
return buffer[3] === 0xf7 ? null : "image/jpeg";
|
|
54
|
+
}
|
|
55
|
+
if (startsWith(buffer, PNG_SIGNATURE)) {
|
|
56
|
+
return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
|
|
57
|
+
}
|
|
58
|
+
if (startsWithAscii(buffer, 0, "GIF")) {
|
|
59
|
+
return "image/gif";
|
|
60
|
+
}
|
|
61
|
+
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {
|
|
62
|
+
return "image/webp";
|
|
63
|
+
}
|
|
64
|
+
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
|
|
65
|
+
return "image/bmp";
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Sniff the leading bytes of a file and return its image MIME type (null if not a supported image). */
|
|
71
|
+
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
|
|
72
|
+
const fileHandle = await open(filePath, "r");
|
|
73
|
+
try {
|
|
74
|
+
const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES);
|
|
75
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0);
|
|
76
|
+
return detectSupportedImageMimeType(buffer.subarray(0, bytesRead));
|
|
77
|
+
} finally {
|
|
78
|
+
await fileHandle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPng(buffer: Buffer): boolean {
|
|
83
|
+
// First chunk after the 8-byte signature must be a 13-byte IHDR.
|
|
84
|
+
return buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isAnimatedPng(buffer: Buffer): boolean {
|
|
88
|
+
// Walk PNG chunks; an acTL chunk before the first IDAT marks an animated (APNG) image.
|
|
89
|
+
let offset = PNG_SIGNATURE.length;
|
|
90
|
+
while (offset + 8 <= buffer.length) {
|
|
91
|
+
const chunkLength = readUint32BE(buffer, offset);
|
|
92
|
+
const chunkTypeOffset = offset + 4;
|
|
93
|
+
if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
|
|
94
|
+
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
|
|
95
|
+
const nextOffset = offset + 8 + chunkLength + 4;
|
|
96
|
+
if (nextOffset <= offset || nextOffset > buffer.length) return false;
|
|
97
|
+
offset = nextOffset;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isBmp(buffer: Buffer): boolean {
|
|
103
|
+
if (buffer.length < 26) return false;
|
|
104
|
+
const declaredFileSize = readUint32LE(buffer, 2);
|
|
105
|
+
const pixelDataOffset = readUint32LE(buffer, 10);
|
|
106
|
+
const dibHeaderSize = readUint32LE(buffer, 14);
|
|
107
|
+
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
|
|
108
|
+
if (pixelDataOffset < 14 + dibHeaderSize) return false;
|
|
109
|
+
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
|
|
110
|
+
let colorPlanes: number;
|
|
111
|
+
let bitsPerPixel: number;
|
|
112
|
+
if (dibHeaderSize === 12) {
|
|
113
|
+
colorPlanes = readUint16LE(buffer, 22);
|
|
114
|
+
bitsPerPixel = readUint16LE(buffer, 24);
|
|
115
|
+
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
|
|
116
|
+
if (buffer.length < 30) return false;
|
|
117
|
+
colorPlanes = readUint16LE(buffer, 26);
|
|
118
|
+
bitsPerPixel = readUint16LE(buffer, 28);
|
|
119
|
+
} else {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readUint16LE(buffer: Buffer, offset: number): number {
|
|
126
|
+
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
|
|
127
|
+
}
|
|
128
|
+
function readUint32BE(buffer: Buffer, offset: number): number {
|
|
129
|
+
return (
|
|
130
|
+
(buffer[offset] ?? 0) * 0x1000000 + ((buffer[offset + 1] ?? 0) << 16) + ((buffer[offset + 2] ?? 0) << 8) + (buffer[offset + 3] ?? 0)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
function readUint32LE(buffer: Buffer, offset: number): number {
|
|
134
|
+
return (
|
|
135
|
+
(buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8) + ((buffer[offset + 2] ?? 0) << 16) + (buffer[offset + 3] ?? 0) * 0x1000000
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
function startsWith(buffer: Buffer, bytes: readonly number[]): boolean {
|
|
139
|
+
if (buffer.length < bytes.length) return false;
|
|
140
|
+
return bytes.every((byte, index) => buffer[index] === byte);
|
|
141
|
+
}
|
|
142
|
+
function startsWithAscii(buffer: Buffer, offset: number, text: string): boolean {
|
|
143
|
+
if (buffer.length < offset + text.length) return false;
|
|
144
|
+
for (let index = 0; index < text.length; index++) {
|
|
145
|
+
if (buffer[offset + index] !== text.charCodeAt(index)) return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- read tool ---
|
|
151
|
+
|
|
152
|
+
/** Default maximum image file size before read/transform (10 MB). */
|
|
153
|
+
export { DEFAULT_MAX_IMAGE_BYTES } from "./limits.js";
|
|
154
|
+
|
|
155
|
+
/** Input passed to an optional host-owned image transformer. */
|
|
156
|
+
export interface TransformImageInput {
|
|
157
|
+
readonly buffer: Buffer;
|
|
158
|
+
readonly mimeType: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Host callback to resize or re-encode an image before base64 encoding. */
|
|
162
|
+
export type TransformImage = (input: TransformImageInput) => Promise<Buffer>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pluggable operations for the read tool. Override to delegate file reading to remote systems
|
|
166
|
+
* (e.g. SSH) while keeping the tool's truncation/offset/limit behavior.
|
|
167
|
+
*/
|
|
168
|
+
export interface ReadTextOptions {
|
|
169
|
+
readonly offset: number;
|
|
170
|
+
readonly limit?: number;
|
|
171
|
+
readonly maxLines: number;
|
|
172
|
+
readonly maxBytes: number;
|
|
173
|
+
readonly maxScanBytes: number;
|
|
174
|
+
readonly signal?: AbortSignal;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ReadTextResult {
|
|
178
|
+
readonly content: string;
|
|
179
|
+
readonly startLine: number;
|
|
180
|
+
readonly outputLines: number;
|
|
181
|
+
readonly hasMore: boolean;
|
|
182
|
+
readonly nextOffset?: number;
|
|
183
|
+
readonly truncatedBy: "lines" | "bytes" | null;
|
|
184
|
+
readonly firstLineExceedsLimit: boolean;
|
|
185
|
+
readonly scannedBytes: number;
|
|
186
|
+
readonly totalLines?: number;
|
|
187
|
+
readonly totalBytes?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Optional host-selected document parser adapter (plan 018 closeout `doc-reader`).
|
|
192
|
+
*
|
|
193
|
+
* When wired into {@link ReadToolOptions.documentReader}, the read tool tries
|
|
194
|
+
* the reader after the image sniff and before the text page. The reader owns
|
|
195
|
+
* its bounds (input bytes, pages, output text); the read tool re-checks the
|
|
196
|
+
* output cap before returning. An unsupported buffer must return `null` so
|
|
197
|
+
* reads fall through to the 0.1.5 text path. No file-extension sniffing ever
|
|
198
|
+
* activates parsing — activation is explicit via this option.
|
|
199
|
+
*/
|
|
200
|
+
export interface DocumentReader {
|
|
201
|
+
/** Hard input size cap; reads refuse larger files before loading them. */
|
|
202
|
+
readonly maxInputBytes: number;
|
|
203
|
+
/** Hard cap on extracted literal text; the read tool refuses results beyond it. */
|
|
204
|
+
readonly maxTextBytes: number;
|
|
205
|
+
/** Extract literal text; return null when the buffer is not a supported document format. */
|
|
206
|
+
extract(input: { readonly buffer: Buffer; readonly path: string; readonly signal?: AbortSignal }): Promise<DocumentReaderResult | null>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface DocumentReaderResult {
|
|
210
|
+
readonly text: string;
|
|
211
|
+
readonly format: string;
|
|
212
|
+
readonly pages: number;
|
|
213
|
+
readonly truncatedBy: "pages" | "bytes" | null;
|
|
214
|
+
/** Page provenance into `text`. Omitted when a redactor rewrote offsets. */
|
|
215
|
+
readonly pageSpans?: readonly { readonly page: number; readonly start: number; readonly end: number }[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ReadOperations {
|
|
219
|
+
/** Read a bounded binary file. Backends must honor `maxBytes` before retaining more data. */
|
|
220
|
+
readFile: (absolutePath: string, options: { maxBytes: number; signal?: AbortSignal }) => Promise<Buffer>;
|
|
221
|
+
/** Read one bounded text page. Required so remote backends cannot fall back to a full-file read. */
|
|
222
|
+
readText: (absolutePath: string, options: ReadTextOptions) => Promise<ReadTextResult>;
|
|
223
|
+
/** Check the file is readable (throw if not). */
|
|
224
|
+
access: (absolutePath: string, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
225
|
+
/** Return file size in bytes for image bound checks. */
|
|
226
|
+
statFile: (absolutePath: string, options?: { signal?: AbortSignal }) => Promise<{ size: number }>;
|
|
227
|
+
/** Detect image MIME type from the file; return null/undefined for non-images. */
|
|
228
|
+
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ReadToolOptions {
|
|
232
|
+
/** Structured pre-execution policy checked before filesystem access. */
|
|
233
|
+
executionPolicy?: ExecutionPolicy;
|
|
234
|
+
/** Reject image reads larger than this many bytes (default {@link DEFAULT_MAX_IMAGE_BYTES}). */
|
|
235
|
+
maxImageBytes?: number;
|
|
236
|
+
/** Maximum raw bytes scanned to reach one text page (default 64 MiB). */
|
|
237
|
+
maxScanBytes?: number;
|
|
238
|
+
/** Optional host callback to resize or re-encode images before base64 encoding. */
|
|
239
|
+
transformImage?: TransformImage;
|
|
240
|
+
/** Custom operations backend (default: local filesystem). */
|
|
241
|
+
operations?: ReadOperations;
|
|
242
|
+
/** Max lines kept from the head (default 2000). */
|
|
243
|
+
maxLines?: number;
|
|
244
|
+
/** Max bytes kept from the head (default 50KB). */
|
|
245
|
+
maxBytes?: number;
|
|
246
|
+
/** When set, successful reads record the resolved absolute path for read-before-write guards. */
|
|
247
|
+
readPathSet?: ReadPathSet;
|
|
248
|
+
/**
|
|
249
|
+
* Optional host-selected document reader (PDF/Office literal-text extraction).
|
|
250
|
+
* Additive: absent reader means exactly the 0.1.5 text/image behavior.
|
|
251
|
+
*/
|
|
252
|
+
documentReader?: DocumentReader;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function readLocalTextPage(path: string, options: ReadTextOptions): Promise<ReadTextResult> {
|
|
256
|
+
const handle = await open(path, "r");
|
|
257
|
+
let fileSize: number;
|
|
258
|
+
try {
|
|
259
|
+
fileSize = (await handle.stat()).size;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
await handle.close();
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
const chunks: Buffer[] = [];
|
|
265
|
+
let retainedBytes = 0;
|
|
266
|
+
let outputLines = 0;
|
|
267
|
+
let lineNumber = 1;
|
|
268
|
+
let scannedBytes = 0;
|
|
269
|
+
let lineChunkStart = 0;
|
|
270
|
+
let lineByteStart = 0;
|
|
271
|
+
const targetLines = options.limit ?? options.maxLines;
|
|
272
|
+
|
|
273
|
+
const content = (trimFinalLf: boolean): string => {
|
|
274
|
+
const buffer = Buffer.concat(chunks, retainedBytes);
|
|
275
|
+
const end = trimFinalLf && buffer[buffer.length - 1] === 0x0a ? buffer.length - 1 : buffer.length;
|
|
276
|
+
return buffer.subarray(0, end).toString("utf-8");
|
|
277
|
+
};
|
|
278
|
+
const partial = (
|
|
279
|
+
truncatedBy: "lines" | "bytes" | null,
|
|
280
|
+
nextOffset: number,
|
|
281
|
+
firstLineExceedsLimit = false,
|
|
282
|
+
totalLines?: number,
|
|
283
|
+
): ReadTextResult => ({
|
|
284
|
+
content: firstLineExceedsLimit ? "" : content(true),
|
|
285
|
+
startLine: options.offset,
|
|
286
|
+
outputLines,
|
|
287
|
+
hasMore: true,
|
|
288
|
+
nextOffset,
|
|
289
|
+
truncatedBy,
|
|
290
|
+
firstLineExceedsLimit,
|
|
291
|
+
scannedBytes,
|
|
292
|
+
totalLines,
|
|
293
|
+
totalBytes: fileSize,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
298
|
+
while (true) {
|
|
299
|
+
if (options.signal?.aborted) throw new Error("Operation aborted");
|
|
300
|
+
if (scannedBytes >= options.maxScanBytes) {
|
|
301
|
+
if (scannedBytes >= fileSize) break;
|
|
302
|
+
throw new Error(`Text read exceeded ${formatSize(options.maxScanBytes)} scan limit before reaching the requested page`);
|
|
303
|
+
}
|
|
304
|
+
const length = Math.min(buffer.length, options.maxScanBytes - scannedBytes);
|
|
305
|
+
const { bytesRead } = await handle.read(buffer, 0, length, null);
|
|
306
|
+
if (bytesRead === 0) break;
|
|
307
|
+
scannedBytes += bytesRead;
|
|
308
|
+
|
|
309
|
+
let cursor = 0;
|
|
310
|
+
while (cursor < bytesRead) {
|
|
311
|
+
const newline = buffer.indexOf(0x0a, cursor);
|
|
312
|
+
const end = newline === -1 || newline >= bytesRead ? bytesRead : newline + 1;
|
|
313
|
+
const selected = lineNumber >= options.offset && outputLines < targetLines;
|
|
314
|
+
if (selected) {
|
|
315
|
+
const piece = buffer.subarray(cursor, end);
|
|
316
|
+
if (retainedBytes + piece.length > options.maxBytes) {
|
|
317
|
+
chunks.length = lineChunkStart;
|
|
318
|
+
retainedBytes = lineByteStart;
|
|
319
|
+
return partial("bytes", lineNumber, outputLines === 0);
|
|
320
|
+
}
|
|
321
|
+
chunks.push(Buffer.from(piece));
|
|
322
|
+
retainedBytes += piece.length;
|
|
323
|
+
}
|
|
324
|
+
cursor = end;
|
|
325
|
+
if (newline === -1 || newline >= bytesRead) break;
|
|
326
|
+
|
|
327
|
+
if (selected) {
|
|
328
|
+
outputLines++;
|
|
329
|
+
if (outputLines >= targetLines) {
|
|
330
|
+
let totalLines: number | undefined;
|
|
331
|
+
if (scannedBytes === fileSize) {
|
|
332
|
+
let remainingNewlines = 0;
|
|
333
|
+
for (let index = cursor; index < bytesRead; index++) {
|
|
334
|
+
if (buffer[index] === 0x0a) remainingNewlines++;
|
|
335
|
+
}
|
|
336
|
+
totalLines = lineNumber + remainingNewlines + 1;
|
|
337
|
+
}
|
|
338
|
+
return partial(options.limit === undefined ? "lines" : null, lineNumber + 1, false, totalLines);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
lineNumber++;
|
|
342
|
+
lineChunkStart = chunks.length;
|
|
343
|
+
lineByteStart = retainedBytes;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (lineNumber < options.offset) {
|
|
348
|
+
throw new Error(`Offset ${options.offset} is beyond end of file (${lineNumber} lines total)`);
|
|
349
|
+
}
|
|
350
|
+
if (lineNumber >= options.offset && outputLines < targetLines) outputLines++;
|
|
351
|
+
return {
|
|
352
|
+
content: content(false),
|
|
353
|
+
startLine: options.offset,
|
|
354
|
+
outputLines,
|
|
355
|
+
hasMore: false,
|
|
356
|
+
truncatedBy: null,
|
|
357
|
+
firstLineExceedsLimit: false,
|
|
358
|
+
scannedBytes,
|
|
359
|
+
totalLines: lineNumber,
|
|
360
|
+
totalBytes: scannedBytes,
|
|
361
|
+
};
|
|
362
|
+
} finally {
|
|
363
|
+
await handle.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const defaultReadOperations: ReadOperations = {
|
|
368
|
+
readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
|
|
369
|
+
readText: readLocalTextPage,
|
|
370
|
+
access: (path) => fsAccess(path, constants.R_OK),
|
|
371
|
+
statFile: async (path) => {
|
|
372
|
+
const info = await fsStat(path);
|
|
373
|
+
return { size: info.size };
|
|
374
|
+
},
|
|
375
|
+
detectImageMimeType: detectSupportedImageMimeTypeFromFile,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
function imageSizeError(actualBytes: number, maxImageBytes: number): string {
|
|
379
|
+
return `Image file is ${formatSize(actualBytes)}, exceeds ${formatSize(maxImageBytes)} limit.`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function loadImageBuffer(
|
|
383
|
+
absolutePath: string,
|
|
384
|
+
mimeType: string,
|
|
385
|
+
ops: ReadOperations,
|
|
386
|
+
options: {
|
|
387
|
+
maxImageBytes: number;
|
|
388
|
+
transformImage?: TransformImage;
|
|
389
|
+
signal?: AbortSignal;
|
|
390
|
+
},
|
|
391
|
+
): Promise<{ buffer: Buffer; resized: boolean }> {
|
|
392
|
+
if (options.signal?.aborted) {
|
|
393
|
+
throw new Error("Operation aborted");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const { size } = await ops.statFile(absolutePath, { signal: options.signal });
|
|
397
|
+
if (size > options.maxImageBytes) {
|
|
398
|
+
throw new Error(imageSizeError(size, options.maxImageBytes));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let buffer = await ops.readFile(absolutePath, {
|
|
402
|
+
maxBytes: options.maxImageBytes,
|
|
403
|
+
signal: options.signal,
|
|
404
|
+
});
|
|
405
|
+
if (buffer.length > options.maxImageBytes) {
|
|
406
|
+
throw new Error(imageSizeError(buffer.length, options.maxImageBytes));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let resized = false;
|
|
410
|
+
if (options.transformImage) {
|
|
411
|
+
if (options.signal?.aborted) {
|
|
412
|
+
throw new Error("Operation aborted");
|
|
413
|
+
}
|
|
414
|
+
buffer = await options.transformImage({ buffer, mimeType });
|
|
415
|
+
resized = true;
|
|
416
|
+
if (buffer.length > options.maxImageBytes) {
|
|
417
|
+
throw new Error(`Transformed image is ${formatSize(buffer.length)}, exceeds ${formatSize(options.maxImageBytes)} limit.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { buffer, resized };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function errorResult(toolCallId: string, message: string): ToolResult {
|
|
425
|
+
return {
|
|
426
|
+
toolCallId,
|
|
427
|
+
name: "read",
|
|
428
|
+
content: [{ type: "text", text: message }],
|
|
429
|
+
error: { message },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Scan paginated `readText` output for the first line containing `findText` (literal substring,
|
|
435
|
+
* no regex). Starts at `startLine` and advances via `nextOffset` until a hit, EOF, or the scan
|
|
436
|
+
* cap. Returns the 1-indexed hit line, or `undefined` when the needle is not found before EOF.
|
|
437
|
+
* Throws on scan-limit / abort / no-progress so the caller's error path surfaces a clean result.
|
|
438
|
+
* Pages with `limit` unset so each backend page is `maxLines`-sized (fewer round trips).
|
|
439
|
+
*/
|
|
440
|
+
async function findMatchLine(
|
|
441
|
+
ops: ReadOperations,
|
|
442
|
+
absolutePath: string,
|
|
443
|
+
findText: string,
|
|
444
|
+
findMode: "exact" | "case-insensitive",
|
|
445
|
+
startLine: number,
|
|
446
|
+
maxLines: number,
|
|
447
|
+
maxBytes: number,
|
|
448
|
+
maxScanBytes: number,
|
|
449
|
+
signal: AbortSignal | undefined,
|
|
450
|
+
): Promise<number | undefined> {
|
|
451
|
+
const caseInsensitive = findMode === "case-insensitive";
|
|
452
|
+
const needle = caseInsensitive ? findText.toLowerCase() : findText;
|
|
453
|
+
let offset = startLine;
|
|
454
|
+
let guard = 0; // ponytail: safety cap for backends that report hasMore without advancing
|
|
455
|
+
while (true) {
|
|
456
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
457
|
+
const page = await ops.readText(absolutePath, { offset, maxLines, maxBytes, maxScanBytes, signal });
|
|
458
|
+
if (!page.firstLineExceedsLimit && page.content.length > 0) {
|
|
459
|
+
const lines = page.content.split("\n");
|
|
460
|
+
for (let index = 0; index < lines.length; index++) {
|
|
461
|
+
const line = caseInsensitive ? lines[index].toLowerCase() : lines[index];
|
|
462
|
+
if (line.includes(needle)) return page.startLine + index;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!page.hasMore || page.nextOffset === undefined) return undefined;
|
|
466
|
+
if (page.scannedBytes >= maxScanBytes) throw new Error(`findText did not match within ${formatSize(maxScanBytes)} scan limit`);
|
|
467
|
+
if (page.nextOffset <= offset) throw new Error(`findText could not advance past line ${offset}`);
|
|
468
|
+
offset = page.nextOffset;
|
|
469
|
+
if (++guard > 100_000) throw new Error("findText exceeded iteration guard");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Prefix each line with its 1-based number and a tab, so the model can cite `file:line` and pick
|
|
475
|
+
* offsets. Stops at the last line whose prefixed form still fits `maxBytes`; the page caps count
|
|
476
|
+
* the prefixed text, not the raw file bytes.
|
|
477
|
+
*/
|
|
478
|
+
function numberLines(content: string, startLine: number, maxBytes: number): { text: string; lines: number; clipped: boolean } {
|
|
479
|
+
const body = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
480
|
+
const out: string[] = [];
|
|
481
|
+
let bytes = 0;
|
|
482
|
+
for (const line of body.split("\n")) {
|
|
483
|
+
const prefixed = `${startLine + out.length}\t${line}`;
|
|
484
|
+
const added = Buffer.byteLength(prefixed, "utf8") + (out.length > 0 ? 1 : 0);
|
|
485
|
+
if (bytes + added > maxBytes && out.length > 0) return { text: out.join("\n"), lines: out.length, clipped: true };
|
|
486
|
+
out.push(prefixed);
|
|
487
|
+
bytes += added;
|
|
488
|
+
}
|
|
489
|
+
return { text: out.join("\n"), lines: out.length, clipped: false };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function createReadTool(cwd: string, options?: ReadToolOptions): ToolDefinition {
|
|
493
|
+
if (options && "autoResizeImages" in options) {
|
|
494
|
+
throw new TypeError('Read tool: "autoResizeImages" was removed in 0.1.5; use "transformImage" instead.');
|
|
495
|
+
}
|
|
496
|
+
const ops = options?.operations ?? defaultReadOperations;
|
|
497
|
+
const maxLines = validateCodingLimit("maxLines", options?.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
498
|
+
const maxBytes = validateCodingLimit("maxBytes", options?.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
499
|
+
const maxImageBytes = validateCodingLimit("maxImageBytes", options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, HARD_MAX_IMAGE_BYTES);
|
|
500
|
+
const maxScanBytes = validateCodingLimit("maxScanBytes", options?.maxScanBytes ?? DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_TEXT_SCAN_BYTES);
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
name: "read",
|
|
504
|
+
kind: "read",
|
|
505
|
+
effect: CODING_OBSERVATION_EFFECT,
|
|
506
|
+
effects: ["read"],
|
|
507
|
+
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. Each text line is prefixed with its 1-based line number and a tab (e.g. "12\\treturn 1;"); the prefix is not part of the file. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). When truncated, continue with the suggested offset until complete. Prefer grep to find text across many files.`,
|
|
508
|
+
parameters: {
|
|
509
|
+
type: "object",
|
|
510
|
+
properties: {
|
|
511
|
+
path: { type: "string", description: "Path to the file to read (relative or absolute)" },
|
|
512
|
+
offset: { type: "number", description: "Line number to start reading from (1-indexed)" },
|
|
513
|
+
limit: { type: "number", description: "Maximum number of lines to read" },
|
|
514
|
+
findText: {
|
|
515
|
+
type: "string",
|
|
516
|
+
description:
|
|
517
|
+
"Literal substring to search for (no regex). Returns the page starting at the first matching line at/after offset. No match is an error result.",
|
|
518
|
+
},
|
|
519
|
+
findMode: { type: "string", enum: ["exact", "case-insensitive"], description: "Match mode for findText (default 'exact')." },
|
|
520
|
+
},
|
|
521
|
+
required: ["path"],
|
|
522
|
+
additionalProperties: false,
|
|
523
|
+
} as JsonObject,
|
|
524
|
+
async execute(args, context: ToolExecutionContext): Promise<ToolResult> {
|
|
525
|
+
const toolCallId = context.toolCallId;
|
|
526
|
+
|
|
527
|
+
if (context.signal?.aborted) {
|
|
528
|
+
return errorResult(toolCallId, "Operation aborted");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
532
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
533
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
534
|
+
const findText = typeof args.findText === "string" ? args.findText : undefined;
|
|
535
|
+
const findModeRaw = typeof args.findMode === "string" ? args.findMode : "exact";
|
|
536
|
+
|
|
537
|
+
if (path.length === 0) {
|
|
538
|
+
return errorResult(toolCallId, "path is required and must be a non-empty string.");
|
|
539
|
+
}
|
|
540
|
+
if (findText !== undefined) {
|
|
541
|
+
if (findText.length === 0) {
|
|
542
|
+
return errorResult(toolCallId, "findText must be a non-empty string.");
|
|
543
|
+
}
|
|
544
|
+
if (findModeRaw !== "exact" && findModeRaw !== "case-insensitive") {
|
|
545
|
+
return errorResult(toolCallId, "findMode must be 'exact' or 'case-insensitive'.");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
let startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
|
|
551
|
+
const requestedLines = limit === undefined ? undefined : validateCodingLimit("limit", limit, HARD_MAX_LINES);
|
|
552
|
+
const absolutePath = options?.operations ? resolveToCwd(path, cwd) : await resolveReadPathAsync(path, cwd);
|
|
553
|
+
|
|
554
|
+
const policyCheck = await enforceExecutionPolicy(
|
|
555
|
+
options?.executionPolicy,
|
|
556
|
+
{
|
|
557
|
+
kind: "read",
|
|
558
|
+
operation: "read",
|
|
559
|
+
paths: [absolutePath],
|
|
560
|
+
risk: "low",
|
|
561
|
+
metadata: { offset, limit, sessionId: context.sessionId, runId: context.runId, signal: context.signal },
|
|
562
|
+
},
|
|
563
|
+
toolCallId,
|
|
564
|
+
"read",
|
|
565
|
+
);
|
|
566
|
+
if (!policyCheck.allowed) return policyCheck.result;
|
|
567
|
+
const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
|
|
568
|
+
|
|
569
|
+
await ops.access(allowedPath, { signal: context.signal });
|
|
570
|
+
|
|
571
|
+
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(allowedPath) : undefined;
|
|
572
|
+
|
|
573
|
+
if (mimeType) {
|
|
574
|
+
const { buffer, resized } = await loadImageBuffer(allowedPath, mimeType, ops, {
|
|
575
|
+
maxImageBytes,
|
|
576
|
+
transformImage: options?.transformImage,
|
|
577
|
+
signal: context.signal,
|
|
578
|
+
});
|
|
579
|
+
options?.readPathSet?.add(allowedPath);
|
|
580
|
+
return {
|
|
581
|
+
toolCallId,
|
|
582
|
+
name: "read",
|
|
583
|
+
content: [
|
|
584
|
+
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
585
|
+
{ type: "image", data: buffer.toString("base64"), mimeType },
|
|
586
|
+
],
|
|
587
|
+
metadata: { image: { mimeType, resized, bytes: buffer.length } },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const documentReader = options?.documentReader;
|
|
592
|
+
if (documentReader) {
|
|
593
|
+
const { size } = await ops.statFile(allowedPath, { signal: context.signal });
|
|
594
|
+
if (size > documentReader.maxInputBytes) {
|
|
595
|
+
return errorResult(toolCallId, `Document is ${formatSize(size)}, exceeds ${formatSize(documentReader.maxInputBytes)} limit.`);
|
|
596
|
+
}
|
|
597
|
+
const buffer = await ops.readFile(allowedPath, {
|
|
598
|
+
maxBytes: documentReader.maxInputBytes,
|
|
599
|
+
signal: context.signal,
|
|
600
|
+
});
|
|
601
|
+
if (buffer.length > documentReader.maxInputBytes) {
|
|
602
|
+
return errorResult(
|
|
603
|
+
toolCallId,
|
|
604
|
+
`Document is ${formatSize(buffer.length)}, exceeds ${formatSize(documentReader.maxInputBytes)} limit.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const extracted = await documentReader.extract({ buffer, path: allowedPath, signal: context.signal });
|
|
608
|
+
if (extracted) {
|
|
609
|
+
if (Buffer.byteLength(extracted.text, "utf8") > documentReader.maxTextBytes) {
|
|
610
|
+
throw new Error("DocumentReader.extract returned text beyond its maxTextBytes cap");
|
|
611
|
+
}
|
|
612
|
+
options?.readPathSet?.add(allowedPath);
|
|
613
|
+
return {
|
|
614
|
+
toolCallId,
|
|
615
|
+
name: "read",
|
|
616
|
+
content: [{ type: "text", text: extracted.text }],
|
|
617
|
+
metadata: {
|
|
618
|
+
document: { format: extracted.format, pages: extracted.pages, truncatedBy: extracted.truncatedBy },
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (findText !== undefined) {
|
|
625
|
+
const hit = await findMatchLine(
|
|
626
|
+
ops,
|
|
627
|
+
allowedPath,
|
|
628
|
+
findText,
|
|
629
|
+
findModeRaw as "exact" | "case-insensitive",
|
|
630
|
+
startLine,
|
|
631
|
+
maxLines,
|
|
632
|
+
maxBytes,
|
|
633
|
+
maxScanBytes,
|
|
634
|
+
context.signal,
|
|
635
|
+
);
|
|
636
|
+
if (hit === undefined) {
|
|
637
|
+
return errorResult(toolCallId, `Could not find ${JSON.stringify(findText)} in ${path}.`);
|
|
638
|
+
}
|
|
639
|
+
startLine = hit;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const page = await ops.readText(allowedPath, {
|
|
643
|
+
offset: startLine,
|
|
644
|
+
limit: requestedLines,
|
|
645
|
+
maxLines,
|
|
646
|
+
maxBytes,
|
|
647
|
+
maxScanBytes,
|
|
648
|
+
signal: context.signal,
|
|
649
|
+
});
|
|
650
|
+
const rawBytes = Buffer.byteLength(page.content, "utf-8");
|
|
651
|
+
if (rawBytes > maxBytes || page.outputLines > (requestedLines ?? maxLines)) {
|
|
652
|
+
throw new Error("ReadOperations.readText returned data beyond the requested bounds");
|
|
653
|
+
}
|
|
654
|
+
const numbered =
|
|
655
|
+
page.firstLineExceedsLimit || page.content.length === 0 ? undefined : numberLines(page.content, page.startLine, maxBytes);
|
|
656
|
+
// A lone line that only overflows once its prefix is added gets the shell-fallback notice.
|
|
657
|
+
const prefixedLineOverflows = numbered?.clipped === true && numbered.lines === 0;
|
|
658
|
+
const firstLineExceedsLimit = page.firstLineExceedsLimit || prefixedLineOverflows;
|
|
659
|
+
const content = numbered?.text ?? page.content;
|
|
660
|
+
const seenLines = numbered?.lines ?? page.outputLines;
|
|
661
|
+
const clipped = numbered?.clipped === true && numbered.lines > 0;
|
|
662
|
+
const hasMore = page.hasMore || clipped;
|
|
663
|
+
const nextOffset = clipped ? page.startLine + seenLines : page.nextOffset;
|
|
664
|
+
const contentBytes = Buffer.byteLength(content, "utf-8");
|
|
665
|
+
const totalLines = page.totalLines ?? page.startLine + page.outputLines + (page.hasMore ? 1 : 0) - 1;
|
|
666
|
+
const truncation: TruncationResult = {
|
|
667
|
+
content,
|
|
668
|
+
truncated: page.truncatedBy !== null || clipped,
|
|
669
|
+
truncatedBy: clipped ? "bytes" : page.truncatedBy,
|
|
670
|
+
totalLines,
|
|
671
|
+
totalBytes: page.totalBytes ?? page.scannedBytes,
|
|
672
|
+
outputLines: seenLines,
|
|
673
|
+
outputBytes: contentBytes,
|
|
674
|
+
lastLinePartial: false,
|
|
675
|
+
firstLineExceedsLimit,
|
|
676
|
+
maxLines,
|
|
677
|
+
maxBytes,
|
|
678
|
+
totalLinesKnown: page.totalLines !== undefined,
|
|
679
|
+
totalBytesKnown: page.totalBytes !== undefined,
|
|
680
|
+
};
|
|
681
|
+
let outputText = content;
|
|
682
|
+
if (firstLineExceedsLimit) {
|
|
683
|
+
outputText = `[Line ${page.startLine} exceeds ${formatSize(maxBytes)} limit. Use the shell tool: sed -n '${page.startLine}p' ${path} | head -c ${maxBytes}]`;
|
|
684
|
+
} else if (hasMore && nextOffset !== undefined) {
|
|
685
|
+
const endLine = page.startLine + seenLines - 1;
|
|
686
|
+
const truncatedBy = clipped ? "bytes" : page.truncatedBy;
|
|
687
|
+
if (page.totalLines !== undefined) {
|
|
688
|
+
const remaining = page.totalLines - endLine;
|
|
689
|
+
outputText += truncatedBy
|
|
690
|
+
? `\n\n[Showing lines ${page.startLine}-${endLine} of ${page.totalLines}${truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${nextOffset} to continue.]`
|
|
691
|
+
: `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
692
|
+
} else {
|
|
693
|
+
outputText += `\n\n[Showing lines ${page.startLine}-${endLine}${truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${nextOffset} to continue.]`;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
options?.readPathSet?.add(allowedPath);
|
|
698
|
+
return {
|
|
699
|
+
toolCallId,
|
|
700
|
+
name: "read",
|
|
701
|
+
content: [{ type: "text", text: outputText }],
|
|
702
|
+
metadata: { truncation, scannedBytes: page.scannedBytes },
|
|
703
|
+
};
|
|
704
|
+
} catch (error) {
|
|
705
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
706
|
+
return errorResult(toolCallId, message);
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Re-exported for hosts building custom read tools or analyzing truncation metadata. */
|
|
713
|
+
export type { TruncationResult };
|