@crewhaus/tool-result-store 0.1.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/tool-result-store",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Persist large tool results to disk and return a preview the model can re-read",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/tool-catalog": "0.0.0",
17
+ "@crewhaus/tool-executor": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/tool-result-store"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-result-store#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,183 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ToolResult } from "@crewhaus/tool-executor";
6
+ import {
7
+ DEFAULT_PREVIEW_LINES,
8
+ DEFAULT_THRESHOLD_BYTES,
9
+ resolveStoragePath,
10
+ storeAndPreview,
11
+ } from "./index";
12
+
13
+ const TMP_ROOTS: string[] = [];
14
+ function newTempRoot(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-result-store-"));
16
+ TMP_ROOTS.push(dir);
17
+ return dir;
18
+ }
19
+ afterAll(() => {
20
+ for (const dir of TMP_ROOTS) {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ function makeResult(content: string, isError = false): ToolResult {
26
+ return { toolUseId: "tu_X", content, isError };
27
+ }
28
+
29
+ describe("storeAndPreview — under threshold", () => {
30
+ test("small content is returned verbatim, no file written", async () => {
31
+ const rootDir = newTempRoot();
32
+ const out = await storeAndPreview(makeResult("tiny output"), {
33
+ runId: "run_a",
34
+ toolUseId: "tu_1",
35
+ rootDir,
36
+ });
37
+ expect(out.persisted).toBe(false);
38
+ expect(out.fullPath).toBeNull();
39
+ expect(out.previewContent).toBe("tiny output");
40
+ });
41
+
42
+ test("exactly at threshold is still treated as small", async () => {
43
+ const rootDir = newTempRoot();
44
+ const content = "a".repeat(DEFAULT_THRESHOLD_BYTES);
45
+ const out = await storeAndPreview(makeResult(content), {
46
+ runId: "run_a",
47
+ toolUseId: "tu_1",
48
+ rootDir,
49
+ });
50
+ expect(out.persisted).toBe(false);
51
+ expect(out.fullPath).toBeNull();
52
+ expect(out.previewContent).toBe(content);
53
+ });
54
+ });
55
+
56
+ describe("storeAndPreview — over threshold", () => {
57
+ test("large content is persisted; preview shows first N lines + marker", async () => {
58
+ const rootDir = newTempRoot();
59
+ const lines = Array.from({ length: 250 }, (_, i) => `line ${i + 1}`);
60
+ const content = lines.join("\n");
61
+ // pad past the byte threshold so the persistence path fires
62
+ const padded = `${content}\n${"x".repeat(DEFAULT_THRESHOLD_BYTES)}`;
63
+ expect(Buffer.byteLength(padded, "utf8")).toBeGreaterThan(DEFAULT_THRESHOLD_BYTES);
64
+ const out = await storeAndPreview(makeResult(padded), {
65
+ runId: "run_b",
66
+ toolUseId: "tu_2",
67
+ rootDir,
68
+ });
69
+ expect(out.persisted).toBe(true);
70
+ expect(out.fullPath).not.toBeNull();
71
+ if (out.fullPath === null) throw new Error("unreachable");
72
+ expect(statSync(out.fullPath).size).toBe(Buffer.byteLength(padded, "utf8"));
73
+
74
+ // Preview = first DEFAULT_PREVIEW_LINES lines + truncation marker.
75
+ if (typeof out.previewContent !== "string") throw new Error("expected string preview");
76
+ const previewLines = out.previewContent.split("\n");
77
+ // last line should be the marker, the line before that may be partial.
78
+ expect(previewLines[previewLines.length - 1]).toContain("[truncated, full output at ");
79
+ expect(previewLines[previewLines.length - 1]).toContain(out.fullPath);
80
+ expect(previewLines.length).toBe(DEFAULT_PREVIEW_LINES + 1);
81
+ // First preview line should be the very first line of input.
82
+ expect(previewLines[0]).toBe("line 1");
83
+ });
84
+
85
+ test("custom previewLines, thresholdBytes, rootDir overrides honored", async () => {
86
+ const rootDir = newTempRoot();
87
+ const content = "line a\nline b\nline c\nline d\nline e\nline f";
88
+ const out = await storeAndPreview(makeResult(content), {
89
+ runId: "run_c",
90
+ toolUseId: "tu_3",
91
+ rootDir,
92
+ thresholdBytes: 5,
93
+ previewLines: 2,
94
+ });
95
+ expect(out.persisted).toBe(true);
96
+ expect(out.previewContent).toContain("line a\nline b\n[truncated, full output at ");
97
+ expect(out.fullPath).toContain(rootDir);
98
+ });
99
+
100
+ test("idempotent on retry — second call with same ids returns same path without throwing", async () => {
101
+ const rootDir = newTempRoot();
102
+ const content = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
103
+ const first = await storeAndPreview(makeResult(content), {
104
+ runId: "run_d",
105
+ toolUseId: "tu_4",
106
+ rootDir,
107
+ });
108
+ const second = await storeAndPreview(makeResult(content), {
109
+ runId: "run_d",
110
+ toolUseId: "tu_4",
111
+ rootDir,
112
+ });
113
+ expect(first.fullPath).toBe(second.fullPath);
114
+ if (first.fullPath === null) throw new Error("unreachable");
115
+ // File still exists and has the original content.
116
+ expect(readFileSync(first.fullPath, "utf8")).toBe(content);
117
+ });
118
+
119
+ test("multi-byte UTF-8 byte length, not character length, drives the threshold", async () => {
120
+ const rootDir = newTempRoot();
121
+ // 5000 emoji × 4 bytes each = 20_000 bytes (over threshold) but only 5000 chars.
122
+ const content = "🌟".repeat(5000);
123
+ expect(content.length).toBe(10000); // emoji is 2 surrogate code units in JS
124
+ expect(Buffer.byteLength(content, "utf8")).toBeGreaterThan(DEFAULT_THRESHOLD_BYTES);
125
+ const out = await storeAndPreview(makeResult(content), {
126
+ runId: "run_e",
127
+ toolUseId: "tu_5",
128
+ rootDir,
129
+ });
130
+ expect(out.persisted).toBe(true);
131
+ });
132
+
133
+ test("error results (isError: true) also get persisted when large", async () => {
134
+ const rootDir = newTempRoot();
135
+ const stack = `Error: boom\n${" at foo\n".repeat(5000)}`;
136
+ const out = await storeAndPreview(makeResult(stack, true), {
137
+ runId: "run_f",
138
+ toolUseId: "tu_6",
139
+ rootDir,
140
+ });
141
+ expect(out.persisted).toBe(true);
142
+ expect(out.fullPath).toContain(`${rootDir}/run_f/tu_6.txt`);
143
+ });
144
+ });
145
+
146
+ describe("storeAndPreview — path traversal guard", () => {
147
+ test("runId with .. is rejected", async () => {
148
+ const rootDir = newTempRoot();
149
+ const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
150
+ await expect(
151
+ storeAndPreview(makeResult(big), {
152
+ runId: "../escape",
153
+ toolUseId: "tu_7",
154
+ rootDir,
155
+ }),
156
+ ).rejects.toThrow(/runId/);
157
+ });
158
+
159
+ test("toolUseId with slash is rejected", async () => {
160
+ const rootDir = newTempRoot();
161
+ const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
162
+ await expect(
163
+ storeAndPreview(makeResult(big), {
164
+ runId: "ok",
165
+ toolUseId: "etc/passwd",
166
+ rootDir,
167
+ }),
168
+ ).rejects.toThrow(/toolUseId/);
169
+ });
170
+
171
+ test("empty runId is rejected", async () => {
172
+ const rootDir = newTempRoot();
173
+ const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
174
+ await expect(
175
+ storeAndPreview(makeResult(big), { runId: "", toolUseId: "tu", rootDir }),
176
+ ).rejects.toThrow(/runId/);
177
+ });
178
+
179
+ test("resolveStoragePath returns a path under rootDir", () => {
180
+ const path = resolveStoragePath("run_x", "tu_y", "/tmp/cr-test");
181
+ expect(path).toContain("/tmp/cr-test/run_x/tu_y.txt");
182
+ });
183
+ });
package/src/index.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Catalog R3 `tool-result-store` — persist large tool outputs to disk
3
+ * and return a preview the model can re-read via the `Read` tool. This
4
+ * keeps wide messages out of the conversation context (cheaper, faster)
5
+ * while preserving full output for any later look-up.
6
+ *
7
+ * Default policy:
8
+ * - Threshold: 10240 bytes (10 KB) of UTF-8.
9
+ * - Storage path: `<rootDir>/<runId>/<toolUseId>.txt`
10
+ * (rootDir defaults to `.crewhaus/tool-results` under cwd).
11
+ * - Preview: first 100 lines + `[truncated, full output at <fullPath>]`
12
+ *
13
+ * Idempotent writes: the file is created with `flag: "wx"` (write-
14
+ * exclusive). If a previous run already wrote the same `(runId,
15
+ * toolUseId)` pair we treat that as success and reuse the existing file.
16
+ *
17
+ * Path traversal: `runId` and `toolUseId` are joined under `rootDir`
18
+ * after rejecting any value containing path separators or `..` to
19
+ * prevent a tool-result from escaping its run directory.
20
+ *
21
+ * Reference: `claude-code/utils/toolResultStorage.ts` — uses
22
+ * `<persisted-output>` XML wrappers, a per-tool size budget, and a
23
+ * GrowthBook flag to override per tool. We collapse to a single global
24
+ * threshold and a plain-text marker.
25
+ */
26
+ import { mkdir, writeFile } from "node:fs/promises";
27
+ import { dirname, join, resolve, sep } from "node:path";
28
+ import { RuntimeError } from "@crewhaus/errors";
29
+ import type { ToolExecuteResult } from "@crewhaus/tool-catalog";
30
+ import type { ToolResult } from "@crewhaus/tool-executor";
31
+
32
+ export type StoreOptions = {
33
+ readonly runId: string;
34
+ readonly toolUseId: string;
35
+ readonly thresholdBytes?: number;
36
+ readonly previewLines?: number;
37
+ readonly rootDir?: string;
38
+ };
39
+
40
+ export type StoredResult = {
41
+ readonly previewContent: ToolExecuteResult;
42
+ readonly fullPath: string | null;
43
+ readonly persisted: boolean;
44
+ };
45
+
46
+ export const DEFAULT_THRESHOLD_BYTES = 10240;
47
+ export const DEFAULT_PREVIEW_LINES = 100;
48
+ export const DEFAULT_ROOT_DIR = ".crewhaus/tool-results";
49
+
50
+ /**
51
+ * If `result.content` is at or under threshold, return it unchanged.
52
+ * Otherwise persist the full text under `<rootDir>/<runId>/<toolUseId>.txt`
53
+ * and return `{ previewContent, fullPath, persisted: true }`. Errors
54
+ * (`isError: true`) follow the same path so large stack traces are
55
+ * captured.
56
+ */
57
+ export async function storeAndPreview(
58
+ result: ToolResult,
59
+ opts: StoreOptions,
60
+ ): Promise<StoredResult> {
61
+ const thresholdBytes = opts.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
62
+ const previewLines = opts.previewLines ?? DEFAULT_PREVIEW_LINES;
63
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
64
+
65
+ // Section 14 — non-string content (image content arrays) bypasses
66
+ // persistence entirely. The blocks are forwarded as-is to the model so
67
+ // it can see the image; size capping happens inside the producing tool
68
+ // (e.g. tool-image enforces a 5 MB per-image limit on disk).
69
+ if (typeof result.content !== "string") {
70
+ return { previewContent: result.content, fullPath: null, persisted: false };
71
+ }
72
+
73
+ const byteLength = Buffer.byteLength(result.content, "utf8");
74
+ if (byteLength <= thresholdBytes) {
75
+ return { previewContent: result.content, fullPath: null, persisted: false };
76
+ }
77
+
78
+ rejectUnsafeSegment("runId", opts.runId);
79
+ rejectUnsafeSegment("toolUseId", opts.toolUseId);
80
+
81
+ const fullPath = join(rootDir, opts.runId, `${opts.toolUseId}.txt`);
82
+ await mkdir(dirname(fullPath), { recursive: true });
83
+ try {
84
+ await writeFile(fullPath, result.content, { flag: "wx" });
85
+ } catch (err) {
86
+ const code = (err as NodeJS.ErrnoException).code;
87
+ if (code !== "EEXIST") throw err;
88
+ // Another run already persisted this exact (runId, toolUseId) — treat
89
+ // as success. The body is identical because tool_use_id is unique.
90
+ }
91
+
92
+ const lines = result.content.split("\n");
93
+ const head = lines.slice(0, previewLines).join("\n");
94
+ const previewContent = `${head}\n[truncated, full output at ${fullPath}]`;
95
+
96
+ return { previewContent, fullPath, persisted: true };
97
+ }
98
+
99
+ function rejectUnsafeSegment(label: string, value: string): void {
100
+ if (
101
+ value === "" ||
102
+ value === "." ||
103
+ value === ".." ||
104
+ value.includes("/") ||
105
+ value.includes("\\") ||
106
+ value.includes("\0") ||
107
+ value.includes("..")
108
+ ) {
109
+ throw new RuntimeError(
110
+ `tool-result-store: ${label} "${value}" contains path-traversal characters`,
111
+ );
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Used by tests to confirm the resolved storage location for a given
117
+ * runId/toolUseId pair. Performs the same traversal rejection as
118
+ * `storeAndPreview()` so callers see the same errors.
119
+ */
120
+ export function resolveStoragePath(
121
+ runId: string,
122
+ toolUseId: string,
123
+ rootDir: string = DEFAULT_ROOT_DIR,
124
+ ): string {
125
+ rejectUnsafeSegment("runId", runId);
126
+ rejectUnsafeSegment("toolUseId", toolUseId);
127
+ const abs = resolve(rootDir, runId, `${toolUseId}.txt`);
128
+ // Sanity: the resolved path must still live under rootDir.
129
+ const root = resolve(rootDir);
130
+ if (!abs.startsWith(`${root}${sep}`)) {
131
+ throw new RuntimeError("tool-result-store: resolved path escapes rootDir");
132
+ }
133
+ return abs;
134
+ }