@arnilo/prism-tools 0.0.0-stage → 0.13.1
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 +418 -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 +29 -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 +584 -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,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `todo_write` planning tool + task-completion continuation stop hook (plan 137 Task 7).
|
|
3
|
+
*
|
|
4
|
+
* Stateless by design: the canonical todo list is the latest successful `todo_write` tool result
|
|
5
|
+
* in the transcript, so it survives durable resume for free and the stop hook scans history
|
|
6
|
+
* instead of keeping a second store. The hook continues a run that would end with open items,
|
|
7
|
+
* and stops it when the model makes no tool calls and no list change across continuations.
|
|
8
|
+
*
|
|
9
|
+
* Because the list lives in history, compaction must not cut it away: {@link todoPinnedEntryIds}
|
|
10
|
+
* reports the session entries a compaction has to keep.
|
|
11
|
+
*/
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import type {
|
|
14
|
+
JsonObject,
|
|
15
|
+
Message,
|
|
16
|
+
RunEndDecision,
|
|
17
|
+
RunEndPayload,
|
|
18
|
+
SessionEntry,
|
|
19
|
+
ToolDefinition,
|
|
20
|
+
ToolExecutionContext,
|
|
21
|
+
ToolResult,
|
|
22
|
+
ToolResultContent,
|
|
23
|
+
} from "@arnilo/prism";
|
|
24
|
+
import { CODING_OBSERVATION_EFFECT } from "./effects.js";
|
|
25
|
+
import { validateCodingLimit } from "./limits.js";
|
|
26
|
+
|
|
27
|
+
export const TODO_WRITE_TOOL_NAME = "todo_write" as const;
|
|
28
|
+
|
|
29
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
|
30
|
+
|
|
31
|
+
export interface TodoItem {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly content: string;
|
|
34
|
+
readonly status: TodoStatus;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shape carried on the `todo_write` tool result metadata (and so in transcript history). */
|
|
38
|
+
export interface TodoListMetadata {
|
|
39
|
+
readonly todos: readonly TodoItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const TODO_STATUSES: readonly TodoStatus[] = ["pending", "in_progress", "completed", "cancelled"];
|
|
43
|
+
|
|
44
|
+
const OPEN_TODO_STATUSES: ReadonlySet<TodoStatus> = new Set<TodoStatus>(["pending", "in_progress"]);
|
|
45
|
+
|
|
46
|
+
const STATUS_GLYPHS: Readonly<Record<TodoStatus, string>> = {
|
|
47
|
+
pending: " ",
|
|
48
|
+
in_progress: "~",
|
|
49
|
+
completed: "x",
|
|
50
|
+
cancelled: "-",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_MAX_TODO_ITEMS = 50;
|
|
54
|
+
export const HARD_MAX_TODO_ITEMS = 200;
|
|
55
|
+
export const DEFAULT_MAX_TODO_CONTENT_BYTES = 512;
|
|
56
|
+
export const HARD_MAX_TODO_CONTENT_BYTES = 4_096;
|
|
57
|
+
export const DEFAULT_MAX_TODO_ID_BYTES = 128;
|
|
58
|
+
export const HARD_MAX_TODO_ID_BYTES = 512;
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_MAX_TODO_NO_PROGRESS = 2;
|
|
61
|
+
export const HARD_MAX_TODO_NO_PROGRESS = 20;
|
|
62
|
+
export const DEFAULT_MAX_TODO_STEER_ITEMS = 20;
|
|
63
|
+
export const HARD_MAX_TODO_STEER_ITEMS = 50;
|
|
64
|
+
export const DEFAULT_MAX_TODO_STEER_BYTES = 4_096;
|
|
65
|
+
export const HARD_MAX_TODO_STEER_BYTES = 16_384;
|
|
66
|
+
|
|
67
|
+
export interface TodoWriteToolOptions {
|
|
68
|
+
readonly maxItems?: number;
|
|
69
|
+
readonly maxContentBytes?: number;
|
|
70
|
+
readonly maxIdBytes?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ResolvedTodoWriteLimits {
|
|
74
|
+
readonly maxItems: number;
|
|
75
|
+
readonly maxContentBytes: number;
|
|
76
|
+
readonly maxIdBytes: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveTodoWriteLimits(options?: TodoWriteToolOptions): ResolvedTodoWriteLimits {
|
|
80
|
+
return {
|
|
81
|
+
maxItems: validateCodingLimit("maxItems", options?.maxItems ?? DEFAULT_MAX_TODO_ITEMS, HARD_MAX_TODO_ITEMS),
|
|
82
|
+
maxContentBytes: validateCodingLimit(
|
|
83
|
+
"maxContentBytes",
|
|
84
|
+
options?.maxContentBytes ?? DEFAULT_MAX_TODO_CONTENT_BYTES,
|
|
85
|
+
HARD_MAX_TODO_CONTENT_BYTES,
|
|
86
|
+
),
|
|
87
|
+
maxIdBytes: validateCodingLimit("maxIdBytes", options?.maxIdBytes ?? DEFAULT_MAX_TODO_ID_BYTES, HARD_MAX_TODO_ID_BYTES),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const READ_LIMITS: ResolvedTodoWriteLimits = {
|
|
92
|
+
maxItems: HARD_MAX_TODO_ITEMS,
|
|
93
|
+
maxContentBytes: HARD_MAX_TODO_CONTENT_BYTES,
|
|
94
|
+
maxIdBytes: HARD_MAX_TODO_ID_BYTES,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function byteLength(value: string): number {
|
|
102
|
+
return Buffer.byteLength(value, "utf8");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function requireBoundedString(value: unknown, label: string, maxBytes: number): string {
|
|
106
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
107
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
108
|
+
}
|
|
109
|
+
if (byteLength(value) > maxBytes) {
|
|
110
|
+
throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requireStatus(value: unknown, label: string): TodoStatus {
|
|
116
|
+
if (typeof value !== "string" || !TODO_STATUSES.includes(value as TodoStatus)) {
|
|
117
|
+
throw new Error(`${label} must be one of ${TODO_STATUSES.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
return value as TodoStatus;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseTodoItem(value: unknown, index: number, limits: ResolvedTodoWriteLimits): TodoItem {
|
|
123
|
+
if (!isRecord(value)) throw new Error(`todos[${index}] must be an object`);
|
|
124
|
+
return {
|
|
125
|
+
id: requireBoundedString(value.id, `todos[${index}].id`, limits.maxIdBytes),
|
|
126
|
+
content: requireBoundedString(value.content, `todos[${index}].content`, limits.maxContentBytes),
|
|
127
|
+
status: requireStatus(value.status, `todos[${index}].status`),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse and validate `todo_write` arguments; throws with a model-facing message. */
|
|
132
|
+
export function parseTodoWriteArgs(args: JsonObject, limits: ResolvedTodoWriteLimits = resolveTodoWriteLimits()): readonly TodoItem[] {
|
|
133
|
+
const raw = args.todos;
|
|
134
|
+
if (!Array.isArray(raw)) throw new Error("todos must be an array");
|
|
135
|
+
if (raw.length > limits.maxItems) throw new Error(`todos exceeds ${limits.maxItems} items`);
|
|
136
|
+
const todos = raw.map((value, index) => parseTodoItem(value, index, limits));
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
for (const todo of todos) {
|
|
139
|
+
if (seen.has(todo.id)) throw new Error(`todos contains duplicate id "${todo.id}"`);
|
|
140
|
+
seen.add(todo.id);
|
|
141
|
+
}
|
|
142
|
+
return todos;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read a todo list back from stored metadata; invalid shapes read as absent, never throw. */
|
|
146
|
+
export function parseTodoListMetadata(metadata: unknown): readonly TodoItem[] | undefined {
|
|
147
|
+
if (!isRecord(metadata) || !Array.isArray(metadata.todos)) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
return metadata.todos.map((value, index) => parseTodoItem(value, index, READ_LIMITS));
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Render the list for the model (also the tool result text). */
|
|
156
|
+
export function formatTodoList(todos: readonly TodoItem[]): string {
|
|
157
|
+
if (todos.length === 0) return "Todo list cleared (no items).";
|
|
158
|
+
const lines = todos.map((todo) => `- [${STATUS_GLYPHS[todo.status]}] ${todo.id}: ${todo.content}`);
|
|
159
|
+
const open = todos.filter((todo) => OPEN_TODO_STATUSES.has(todo.status)).length;
|
|
160
|
+
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
161
|
+
const cancelled = todos.filter((todo) => todo.status === "cancelled").length;
|
|
162
|
+
lines.push(`Summary: ${open} open, ${completed} completed, ${cancelled} cancelled.`);
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function errorResult(toolCallId: string, message: string): ToolResult {
|
|
167
|
+
return {
|
|
168
|
+
toolCallId,
|
|
169
|
+
name: TODO_WRITE_TOOL_NAME,
|
|
170
|
+
content: [{ type: "text", text: message }],
|
|
171
|
+
error: { message },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createTodoWriteTool(options?: TodoWriteToolOptions): ToolDefinition {
|
|
176
|
+
const limits = resolveTodoWriteLimits(options);
|
|
177
|
+
return {
|
|
178
|
+
name: TODO_WRITE_TOOL_NAME,
|
|
179
|
+
kind: "think",
|
|
180
|
+
effect: CODING_OBSERVATION_EFFECT,
|
|
181
|
+
effects: [],
|
|
182
|
+
description:
|
|
183
|
+
"Replace the full todo list for the current task. Use it for multi-step work: one item per step, status pending/in_progress/completed/cancelled. Send every item on each call; the latest call is the current list.",
|
|
184
|
+
parameters: {
|
|
185
|
+
type: "object",
|
|
186
|
+
properties: {
|
|
187
|
+
todos: {
|
|
188
|
+
type: "array",
|
|
189
|
+
description: "The complete todo list (replaces any previous list).",
|
|
190
|
+
items: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
id: { type: "string", description: "Stable short id for the item" },
|
|
194
|
+
content: { type: "string", description: "What the step is" },
|
|
195
|
+
status: { type: "string", enum: [...TODO_STATUSES] },
|
|
196
|
+
},
|
|
197
|
+
required: ["id", "content", "status"],
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ["todos"],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
} as JsonObject,
|
|
205
|
+
execute(args: JsonObject, context: ToolExecutionContext): ToolResult {
|
|
206
|
+
let todos: readonly TodoItem[];
|
|
207
|
+
try {
|
|
208
|
+
todos = parseTodoWriteArgs(args, limits);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return errorResult(context.toolCallId, error instanceof Error ? error.message : String(error));
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
toolCallId: context.toolCallId,
|
|
214
|
+
name: TODO_WRITE_TOOL_NAME,
|
|
215
|
+
content: [{ type: "text", text: formatTodoList(todos) }],
|
|
216
|
+
metadata: { todos },
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The `tool_result` block of a successful `todo_write` call, or `undefined` for any other message. */
|
|
223
|
+
function todoResultBlock(message: Message | undefined): ToolResultContent | undefined {
|
|
224
|
+
if (message?.role !== "tool") return undefined;
|
|
225
|
+
for (const block of message.content) {
|
|
226
|
+
if (block.type !== "tool_result" || block.name !== TODO_WRITE_TOOL_NAME) continue;
|
|
227
|
+
if (block.error !== undefined && block.error !== null) continue;
|
|
228
|
+
return block;
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Latest successful `todo_write` list in a transcript. Scans backwards and stops at the first
|
|
235
|
+
* result, so cost stays proportional to the distance to the most recent list. A failed call is
|
|
236
|
+
* skipped (the previous successful list still stands).
|
|
237
|
+
*/
|
|
238
|
+
export function latestTodoList(history: readonly Message[]): readonly TodoItem[] | undefined {
|
|
239
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
240
|
+
const message = history[index];
|
|
241
|
+
if (message === undefined || todoResultBlock(message) === undefined) continue;
|
|
242
|
+
const todos = parseTodoListMetadata(message.metadata);
|
|
243
|
+
if (todos !== undefined) return todos;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Session entry ids a compaction must keep so the latest plan stays readable: the assistant entry
|
|
250
|
+
* that called `todo_write` plus every tool entry of that turn. A pinned tool result without its
|
|
251
|
+
* assistant call is not a valid transcript, so the whole turn is pinned or nothing is.
|
|
252
|
+
*
|
|
253
|
+
* Returns `[]` when no successful call exists (the tool was never used, or every call failed) or
|
|
254
|
+
* when the owning assistant entry is missing — in both cases there is nothing the hook could read.
|
|
255
|
+
*/
|
|
256
|
+
export function todoPinnedEntryIds(entries: readonly SessionEntry[]): readonly string[] {
|
|
257
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
258
|
+
const entry = entries[index];
|
|
259
|
+
if (entry?.kind !== "message" || entry.message === undefined) continue;
|
|
260
|
+
const block = todoResultBlock(entry.message);
|
|
261
|
+
if (block === undefined || parseTodoListMetadata(entry.message.metadata) === undefined) continue;
|
|
262
|
+
const start = assistantEntryIndex(entries, index, block.toolCallId);
|
|
263
|
+
if (start < 0) return [];
|
|
264
|
+
const ids: string[] = [];
|
|
265
|
+
for (let cursor = start; cursor < entries.length; cursor += 1) {
|
|
266
|
+
const current = entries[cursor];
|
|
267
|
+
if (current?.kind !== "message" || current.message === undefined) break;
|
|
268
|
+
if (cursor > start && current.message.role !== "tool") break;
|
|
269
|
+
ids.push(current.id);
|
|
270
|
+
}
|
|
271
|
+
return ids;
|
|
272
|
+
}
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function assistantEntryIndex(entries: readonly SessionEntry[], before: number, toolCallId: string): number {
|
|
277
|
+
for (let index = before - 1; index >= 0; index -= 1) {
|
|
278
|
+
const message = entries[index]?.message;
|
|
279
|
+
if (message?.role !== "assistant") continue;
|
|
280
|
+
if (message.content.some((block) => block.type === "tool_call" && block.id === toolCallId)) return index;
|
|
281
|
+
}
|
|
282
|
+
return -1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function countToolResults(history: readonly Message[]): number {
|
|
286
|
+
let count = 0;
|
|
287
|
+
for (const message of history) {
|
|
288
|
+
if (message.role !== "tool") continue;
|
|
289
|
+
for (const block of message.content) {
|
|
290
|
+
if (block.type === "tool_result") count += 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return count;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface TodoContinuationOptions {
|
|
297
|
+
/** Consecutive continuations with no new tool call and no list change before stopping. Default 2. */
|
|
298
|
+
readonly maxNoProgress?: number;
|
|
299
|
+
readonly maxSteerItems?: number;
|
|
300
|
+
readonly maxSteerBytes?: number;
|
|
301
|
+
/** Called when the gate stops after consecutive ends with no tool call and no list change. */
|
|
302
|
+
readonly onNoProgressStop?: (info: TodoNoProgressStopInfo) => void;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface TodoNoProgressStopInfo {
|
|
306
|
+
readonly openTodos: readonly TodoItem[];
|
|
307
|
+
readonly noProgress: number;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The `run.end` decide function the package registers as `@arnilo/prism-tools/coding:run.end#todo`. */
|
|
311
|
+
export type TodoContinuationDecision = (payload: RunEndPayload, context: { readonly signal?: AbortSignal }) => RunEndDecision | undefined;
|
|
312
|
+
|
|
313
|
+
interface TodoRunState {
|
|
314
|
+
signature: string;
|
|
315
|
+
noProgress: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const MAX_TRACKED_RUNS = 1_024;
|
|
319
|
+
|
|
320
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
321
|
+
const bytes = Buffer.from(value, "utf8");
|
|
322
|
+
return bytes.length <= maxBytes ? value : bytes.subarray(0, maxBytes).toString("utf8");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function continueDecision(open: readonly TodoItem[], maxSteerItems: number, maxSteerBytes: number): RunEndDecision {
|
|
326
|
+
const shown = open.slice(0, maxSteerItems);
|
|
327
|
+
const lines = shown.map((todo) => `- [${todo.status}] ${todo.id}: ${todo.content}`);
|
|
328
|
+
if (open.length > shown.length) lines.push(`- …and ${open.length - shown.length} more`);
|
|
329
|
+
const steer = truncateUtf8(
|
|
330
|
+
`Open todo items (${open.length}):\n${lines.join("\n")}\nContinue with the remaining items, or mark them completed/cancelled with a reason.`,
|
|
331
|
+
maxSteerBytes,
|
|
332
|
+
);
|
|
333
|
+
return { continue: { reason: "Open todo items remain; the task is not complete.", steer } };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Build the todo continuation gate: a `run.end` decide function (roadmap §2.4). While open todos
|
|
338
|
+
* remain, the run is asked to continue once per loop end, carrying the list as a steer. It stops
|
|
339
|
+
* continuing after `maxNoProgress` consecutive ends with no new tool call and no list change. The
|
|
340
|
+
* package registers it as `@arnilo/prism-tools/coding:run.end#todo`; the kernel caps continuations
|
|
341
|
+
* through `limits.maxContinuations`.
|
|
342
|
+
*/
|
|
343
|
+
export function createTodoContinuationDecision(options?: TodoContinuationOptions): TodoContinuationDecision {
|
|
344
|
+
const maxNoProgress =
|
|
345
|
+
options?.maxNoProgress === undefined
|
|
346
|
+
? DEFAULT_MAX_TODO_NO_PROGRESS
|
|
347
|
+
: validateCodingLimit("maxNoProgress", options.maxNoProgress, HARD_MAX_TODO_NO_PROGRESS);
|
|
348
|
+
const maxSteerItems =
|
|
349
|
+
options?.maxSteerItems === undefined
|
|
350
|
+
? DEFAULT_MAX_TODO_STEER_ITEMS
|
|
351
|
+
: validateCodingLimit("maxSteerItems", options.maxSteerItems, HARD_MAX_TODO_STEER_ITEMS);
|
|
352
|
+
const maxSteerBytes =
|
|
353
|
+
options?.maxSteerBytes === undefined
|
|
354
|
+
? DEFAULT_MAX_TODO_STEER_BYTES
|
|
355
|
+
: validateCodingLimit("maxSteerBytes", options.maxSteerBytes, HARD_MAX_TODO_STEER_BYTES);
|
|
356
|
+
const onNoProgressStop = options?.onNoProgressStop;
|
|
357
|
+
|
|
358
|
+
// Per-run continuation counters; one decision instance is reused across runs.
|
|
359
|
+
const runs = new Map<string, TodoRunState>();
|
|
360
|
+
|
|
361
|
+
return (payload, context): RunEndDecision | undefined => {
|
|
362
|
+
const key = `${payload.sessionId}:${payload.runId}`;
|
|
363
|
+
// User abort always wins over continuation.
|
|
364
|
+
if (context.signal?.aborted) {
|
|
365
|
+
runs.delete(key);
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const todos = latestTodoList(payload.history);
|
|
369
|
+
if (todos === undefined) {
|
|
370
|
+
runs.delete(key);
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const open = todos.filter((todo) => OPEN_TODO_STATUSES.has(todo.status));
|
|
374
|
+
if (open.length === 0) {
|
|
375
|
+
runs.delete(key);
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const signature = `${countToolResults(payload.history)}:${JSON.stringify(todos)}`;
|
|
380
|
+
const previous = runs.get(key);
|
|
381
|
+
if (previous === undefined) {
|
|
382
|
+
if (runs.size >= MAX_TRACKED_RUNS) runs.clear();
|
|
383
|
+
runs.set(key, { signature, noProgress: 0 });
|
|
384
|
+
return continueDecision(open, maxSteerItems, maxSteerBytes);
|
|
385
|
+
}
|
|
386
|
+
if (previous.signature === signature) {
|
|
387
|
+
previous.noProgress += 1;
|
|
388
|
+
if (previous.noProgress >= maxNoProgress) {
|
|
389
|
+
runs.delete(key);
|
|
390
|
+
onNoProgressStop?.({ openTodos: open, noProgress: previous.noProgress });
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
previous.signature = signature;
|
|
395
|
+
previous.noProgress = 0;
|
|
396
|
+
}
|
|
397
|
+
return continueDecision(open, maxSteerItems, maxSteerBytes);
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared truncation utilities for tool outputs.
|
|
3
|
+
*
|
|
4
|
+
* Behavioral port of pi's core/tools/truncate for @arnilo/prism-tools/coding.
|
|
5
|
+
* stdlib only (Buffer). Truncation is based on two independent limits — whichever
|
|
6
|
+
* is hit first wins:
|
|
7
|
+
* - Line limit (default: 2000 lines)
|
|
8
|
+
* - Byte limit (default: 50KB)
|
|
9
|
+
*
|
|
10
|
+
* Never returns partial lines (except the documented tail single-line edge case).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, HARD_MAX_BYTES, HARD_MAX_LINES, validateCodingLimit } from "./limits.js";
|
|
14
|
+
|
|
15
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "./limits.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default char cap for {@link truncateLine}. pi names this GREP_MAX_LINE_LENGTH
|
|
19
|
+
* because grep is its only caller; grep is out of scope for this package, so the
|
|
20
|
+
* constant stays internal and unprefixed.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_LINE_CHAR_LIMIT = 500;
|
|
23
|
+
|
|
24
|
+
export interface TruncationOptions {
|
|
25
|
+
/** Maximum number of lines (default: 2000) */
|
|
26
|
+
maxLines?: number;
|
|
27
|
+
/** Maximum number of bytes (default: 50KB) */
|
|
28
|
+
maxBytes?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TruncationResult {
|
|
32
|
+
/** The truncated content */
|
|
33
|
+
content: string;
|
|
34
|
+
/** Whether truncation occurred */
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
/** Which limit was hit: "lines", "bytes", or null if not truncated */
|
|
37
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
38
|
+
/** Total lines when known; otherwise a scanned lower bound. */
|
|
39
|
+
totalLines: number;
|
|
40
|
+
/** Whether `totalLines` is exact (streamed text pages may stop early). */
|
|
41
|
+
totalLinesKnown?: boolean;
|
|
42
|
+
/** Total bytes when known; otherwise scanned bytes. */
|
|
43
|
+
totalBytes: number;
|
|
44
|
+
/** Whether `totalBytes` is exact (streamed text pages may stop early). */
|
|
45
|
+
totalBytesKnown?: boolean;
|
|
46
|
+
/** Number of complete lines in the truncated output */
|
|
47
|
+
outputLines: number;
|
|
48
|
+
/** Number of bytes in the truncated output */
|
|
49
|
+
outputBytes: number;
|
|
50
|
+
/** Whether the last line was partially truncated (only for tail truncation edge case) */
|
|
51
|
+
lastLinePartial: boolean;
|
|
52
|
+
/** Whether the first line exceeded the byte limit (for head truncation) */
|
|
53
|
+
firstLineExceedsLimit: boolean;
|
|
54
|
+
/** The max lines limit that was applied */
|
|
55
|
+
maxLines: number;
|
|
56
|
+
/** The max bytes limit that was applied */
|
|
57
|
+
maxBytes: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function splitLinesForCounting(content: string): string[] {
|
|
61
|
+
if (content.length === 0) return [];
|
|
62
|
+
const lines = content.split("\n");
|
|
63
|
+
if (content.endsWith("\n")) lines.pop();
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Format bytes as human-readable size. */
|
|
68
|
+
export function formatSize(bytes: number): string {
|
|
69
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
70
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
71
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Truncate content from the head (keep first N lines/bytes).
|
|
76
|
+
* Suitable for file reads where you want to see the beginning.
|
|
77
|
+
*
|
|
78
|
+
* Never returns partial lines. If the first line alone exceeds the byte limit,
|
|
79
|
+
* returns empty content with firstLineExceedsLimit=true.
|
|
80
|
+
*/
|
|
81
|
+
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
|
82
|
+
const maxLines = validateCodingLimit("maxLines", options.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
83
|
+
const maxBytes = validateCodingLimit("maxBytes", options.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
84
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
85
|
+
const lines = splitLinesForCounting(content);
|
|
86
|
+
const totalLines = lines.length;
|
|
87
|
+
|
|
88
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
89
|
+
return {
|
|
90
|
+
content,
|
|
91
|
+
truncated: false,
|
|
92
|
+
truncatedBy: null,
|
|
93
|
+
totalLines,
|
|
94
|
+
totalBytes,
|
|
95
|
+
outputLines: totalLines,
|
|
96
|
+
outputBytes: totalBytes,
|
|
97
|
+
lastLinePartial: false,
|
|
98
|
+
firstLineExceedsLimit: false,
|
|
99
|
+
maxLines,
|
|
100
|
+
maxBytes,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// First line alone exceeds the byte limit → nothing fits.
|
|
105
|
+
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
|
|
106
|
+
if (firstLineBytes > maxBytes) {
|
|
107
|
+
return {
|
|
108
|
+
content: "",
|
|
109
|
+
truncated: true,
|
|
110
|
+
truncatedBy: "bytes",
|
|
111
|
+
totalLines,
|
|
112
|
+
totalBytes,
|
|
113
|
+
outputLines: 0,
|
|
114
|
+
outputBytes: 0,
|
|
115
|
+
lastLinePartial: false,
|
|
116
|
+
firstLineExceedsLimit: true,
|
|
117
|
+
maxLines,
|
|
118
|
+
maxBytes,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const outputLinesArr: string[] = [];
|
|
123
|
+
let outputBytesCount = 0;
|
|
124
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
125
|
+
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
|
|
128
|
+
if (outputBytesCount + lineBytes > maxBytes) {
|
|
129
|
+
truncatedBy = "bytes";
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
outputLinesArr.push(line);
|
|
133
|
+
outputBytesCount += lineBytes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
|
137
|
+
truncatedBy = "lines";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const outputContent = outputLinesArr.join("\n");
|
|
141
|
+
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
|
142
|
+
return {
|
|
143
|
+
content: outputContent,
|
|
144
|
+
truncated: true,
|
|
145
|
+
truncatedBy,
|
|
146
|
+
totalLines,
|
|
147
|
+
totalBytes,
|
|
148
|
+
outputLines: outputLinesArr.length,
|
|
149
|
+
outputBytes: finalOutputBytes,
|
|
150
|
+
lastLinePartial: false,
|
|
151
|
+
firstLineExceedsLimit: false,
|
|
152
|
+
maxLines,
|
|
153
|
+
maxBytes,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Truncate content from the tail (keep last N lines/bytes).
|
|
159
|
+
* Suitable for shell output where you want to see the end (errors, final results).
|
|
160
|
+
*
|
|
161
|
+
* May return a partial first line if the last line of the original content exceeds
|
|
162
|
+
* the byte limit.
|
|
163
|
+
*/
|
|
164
|
+
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
|
165
|
+
const maxLines = validateCodingLimit("maxLines", options.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
166
|
+
const maxBytes = validateCodingLimit("maxBytes", options.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
167
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
168
|
+
const lines = splitLinesForCounting(content);
|
|
169
|
+
const totalLines = lines.length;
|
|
170
|
+
|
|
171
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
172
|
+
return {
|
|
173
|
+
content,
|
|
174
|
+
truncated: false,
|
|
175
|
+
truncatedBy: null,
|
|
176
|
+
totalLines,
|
|
177
|
+
totalBytes,
|
|
178
|
+
outputLines: totalLines,
|
|
179
|
+
outputBytes: totalBytes,
|
|
180
|
+
lastLinePartial: false,
|
|
181
|
+
firstLineExceedsLimit: false,
|
|
182
|
+
maxLines,
|
|
183
|
+
maxBytes,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const outputLinesArr: string[] = [];
|
|
188
|
+
let outputBytesCount = 0;
|
|
189
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
190
|
+
let lastLinePartial = false;
|
|
191
|
+
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
|
192
|
+
const line = lines[i];
|
|
193
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
|
194
|
+
if (outputBytesCount + lineBytes > maxBytes) {
|
|
195
|
+
truncatedBy = "bytes";
|
|
196
|
+
// Edge case: no lines added yet and this line alone exceeds maxBytes →
|
|
197
|
+
// take the end of the line (partial).
|
|
198
|
+
if (outputLinesArr.length === 0) {
|
|
199
|
+
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
|
200
|
+
outputLinesArr.unshift(truncatedLine);
|
|
201
|
+
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
|
202
|
+
lastLinePartial = true;
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
outputLinesArr.unshift(line);
|
|
207
|
+
outputBytesCount += lineBytes;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
|
211
|
+
truncatedBy = "lines";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const outputContent = outputLinesArr.join("\n");
|
|
215
|
+
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
|
216
|
+
return {
|
|
217
|
+
content: outputContent,
|
|
218
|
+
truncated: true,
|
|
219
|
+
truncatedBy,
|
|
220
|
+
totalLines,
|
|
221
|
+
totalBytes,
|
|
222
|
+
outputLines: outputLinesArr.length,
|
|
223
|
+
outputBytes: finalOutputBytes,
|
|
224
|
+
lastLinePartial,
|
|
225
|
+
firstLineExceedsLimit: false,
|
|
226
|
+
maxLines,
|
|
227
|
+
maxBytes,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Truncate a string to fit within a byte limit, keeping the end. Handles
|
|
233
|
+
* multi-byte UTF-8 characters by advancing to the next character boundary.
|
|
234
|
+
*/
|
|
235
|
+
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
|
236
|
+
const buf = Buffer.from(str, "utf-8");
|
|
237
|
+
if (buf.length <= maxBytes) return str;
|
|
238
|
+
let start = buf.length - maxBytes;
|
|
239
|
+
// Skip continuation bytes (0x80-masked) to land on a character start.
|
|
240
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
|
|
241
|
+
return buf.subarray(start).toString("utf-8");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Truncate a single line to max characters, adding a `[truncated]` suffix.
|
|
246
|
+
* Used for long single-line outputs (e.g. grep match lines).
|
|
247
|
+
*/
|
|
248
|
+
export function truncateLine(line: string, maxChars: number = DEFAULT_LINE_CHAR_LIMIT): { text: string; wasTruncated: boolean } {
|
|
249
|
+
if (line.length <= maxChars) return { text: line, wasTruncated: false };
|
|
250
|
+
return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
|
|
251
|
+
}
|