@theokit/sdk-tools 0.24.1 → 0.26.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/dist/index.d.cts CHANGED
@@ -1099,24 +1099,59 @@ declare function todoItemsToPlanNodes(items: readonly TodoItem[]): PlanNode[];
1099
1099
  /**
1100
1100
  * `truncateOutput` — utility for truncating large tool output.
1101
1101
  *
1102
- * When output exceeds `maxBytes`, writes the full content to a temp file
1103
- * and returns a truncated version with a reference to the full output.
1102
+ * When output exceeds `maxBytes`, writes the full content to a temp file and returns a truncated
1103
+ * version with a reference to the full output.
1104
+ *
1105
+ * ## M77 — why this grew instead of a new truncator being written
1106
+ *
1107
+ * The M77 discovery measured **six** output ceilings across `sdk-tools`, with five different values
1108
+ * and no coordination between them (`web-fetch.ts` 1 MB, `shell-exec.ts` 5 MB, `git-diff.ts` 5 MB,
1109
+ * `run-vitest.ts` 10 MB, `git-status.ts` configurable, and this helper's 30 000 B). It also found
1110
+ * that this helper — exported from the barrel — had **zero production consumers**: the SDK had
1111
+ * already paid for a shared truncator that nobody used while every tool rolled its own.
1112
+ *
1113
+ * Adding a seventh ceiling in the runtime would have been the defect, not the fix. So `toolResultBudget`
1114
+ * extends this one instead (parsimony-ladder rung 4), gaining the two things it lacked:
1115
+ *
1116
+ * - **`head-tail` mode** — the previous cut was head-only, which for command output discards
1117
+ * exactly the part that matters: the error, the summary, the final prompt.
1118
+ * - **`originalBytes`** — a machine-readable marker. The only prior signal was an English sentence
1119
+ * injected into the text (`"[Output truncated. Full output: …]"`), so a consumer wanting to know
1120
+ * how much was lost had to parse prose.
1121
+ *
1122
+ * Consolidating the MECHANISM is deliberately not the same as unifying the VALUES: 1 MB for
1123
+ * `web-fetch` and 10 MB for `run-vitest` may differ for good reasons. That second step is out of
1124
+ * scope here (plan ADR D2).
1104
1125
  *
1105
1126
  * Return shape:
1106
- * - `{ content: string, truncated: false }`
1107
- * - `{ content: string, truncated: true, overflowPath: string }`
1127
+ * - `{ content, truncated: false, originalBytes }`
1128
+ * - `{ content, truncated: true, originalBytes, overflowPath }`
1108
1129
  */
1130
+ /** How the middle is dropped when output exceeds the budget. */
1131
+ type TruncationMode = "head" | "head-tail";
1109
1132
  interface TruncationOptions {
1110
1133
  /** Maximum output size in bytes before truncation. Default: 30_000. */
1111
1134
  maxBytes?: number;
1112
1135
  /** Directory for overflow files. Default: ".theocode/tool-output". */
1113
1136
  outputDir?: string;
1137
+ /**
1138
+ * `"head"` (default) keeps the first `maxBytes` — the historical behaviour, preserved because this
1139
+ * helper is public API. `"head-tail"` splits the budget between the start and the END, which is
1140
+ * where command output usually carries its conclusion.
1141
+ */
1142
+ mode?: TruncationMode;
1114
1143
  }
1115
1144
  interface TruncationResult {
1116
1145
  /** The (possibly truncated) content. */
1117
1146
  content: string;
1118
1147
  /** Whether the output was truncated. */
1119
1148
  truncated: boolean;
1149
+ /**
1150
+ * Byte length of the ORIGINAL output, present on both paths. Always present by design: a field
1151
+ * that only appears on failure forces every consumer through an `undefined` check, which is the
1152
+ * door magic values come in through.
1153
+ */
1154
+ originalBytes: number;
1120
1155
  /** Path to the full output file, present only when truncated. */
1121
1156
  overflowPath?: string;
1122
1157
  }
package/dist/index.d.ts CHANGED
@@ -1099,24 +1099,59 @@ declare function todoItemsToPlanNodes(items: readonly TodoItem[]): PlanNode[];
1099
1099
  /**
1100
1100
  * `truncateOutput` — utility for truncating large tool output.
1101
1101
  *
1102
- * When output exceeds `maxBytes`, writes the full content to a temp file
1103
- * and returns a truncated version with a reference to the full output.
1102
+ * When output exceeds `maxBytes`, writes the full content to a temp file and returns a truncated
1103
+ * version with a reference to the full output.
1104
+ *
1105
+ * ## M77 — why this grew instead of a new truncator being written
1106
+ *
1107
+ * The M77 discovery measured **six** output ceilings across `sdk-tools`, with five different values
1108
+ * and no coordination between them (`web-fetch.ts` 1 MB, `shell-exec.ts` 5 MB, `git-diff.ts` 5 MB,
1109
+ * `run-vitest.ts` 10 MB, `git-status.ts` configurable, and this helper's 30 000 B). It also found
1110
+ * that this helper — exported from the barrel — had **zero production consumers**: the SDK had
1111
+ * already paid for a shared truncator that nobody used while every tool rolled its own.
1112
+ *
1113
+ * Adding a seventh ceiling in the runtime would have been the defect, not the fix. So `toolResultBudget`
1114
+ * extends this one instead (parsimony-ladder rung 4), gaining the two things it lacked:
1115
+ *
1116
+ * - **`head-tail` mode** — the previous cut was head-only, which for command output discards
1117
+ * exactly the part that matters: the error, the summary, the final prompt.
1118
+ * - **`originalBytes`** — a machine-readable marker. The only prior signal was an English sentence
1119
+ * injected into the text (`"[Output truncated. Full output: …]"`), so a consumer wanting to know
1120
+ * how much was lost had to parse prose.
1121
+ *
1122
+ * Consolidating the MECHANISM is deliberately not the same as unifying the VALUES: 1 MB for
1123
+ * `web-fetch` and 10 MB for `run-vitest` may differ for good reasons. That second step is out of
1124
+ * scope here (plan ADR D2).
1104
1125
  *
1105
1126
  * Return shape:
1106
- * - `{ content: string, truncated: false }`
1107
- * - `{ content: string, truncated: true, overflowPath: string }`
1127
+ * - `{ content, truncated: false, originalBytes }`
1128
+ * - `{ content, truncated: true, originalBytes, overflowPath }`
1108
1129
  */
1130
+ /** How the middle is dropped when output exceeds the budget. */
1131
+ type TruncationMode = "head" | "head-tail";
1109
1132
  interface TruncationOptions {
1110
1133
  /** Maximum output size in bytes before truncation. Default: 30_000. */
1111
1134
  maxBytes?: number;
1112
1135
  /** Directory for overflow files. Default: ".theocode/tool-output". */
1113
1136
  outputDir?: string;
1137
+ /**
1138
+ * `"head"` (default) keeps the first `maxBytes` — the historical behaviour, preserved because this
1139
+ * helper is public API. `"head-tail"` splits the budget between the start and the END, which is
1140
+ * where command output usually carries its conclusion.
1141
+ */
1142
+ mode?: TruncationMode;
1114
1143
  }
1115
1144
  interface TruncationResult {
1116
1145
  /** The (possibly truncated) content. */
1117
1146
  content: string;
1118
1147
  /** Whether the output was truncated. */
1119
1148
  truncated: boolean;
1149
+ /**
1150
+ * Byte length of the ORIGINAL output, present on both paths. Always present by design: a field
1151
+ * that only appears on failure forces every consumer through an `undefined` check, which is the
1152
+ * door magic values come in through.
1153
+ */
1154
+ originalBytes: number;
1120
1155
  /** Path to the full output file, present only when truncated. */
1121
1156
  overflowPath?: string;
1122
1157
  }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { spawn } from 'child_process';
11
11
  import { resolveInteractive, InteractiveUnavailableError, NoSuchSessionError } from '@theokit/sdk/interactive';
12
12
  import { lookup } from 'dns/promises';
13
13
  import { isIP } from 'net';
14
+ import { randomUUID } from 'crypto';
14
15
 
15
16
  // src/apply-patch.ts
16
17
 
@@ -1798,19 +1799,11 @@ function createQuestionTool(opts) {
1798
1799
  }
1799
1800
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
1800
1801
  var BINARY_PROBE_BYTES = 8 * 1024;
1801
- var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
1802
- function isForbiddenAtAnyDepth(path) {
1803
- const segs = path.replace(/\\/g, "/").split("/").filter(Boolean);
1804
- return segs.some((s) => {
1805
- if (s === ".env.example") return false;
1806
- return SENSITIVE_SEGMENTS.has(s) || /^\.env\./.test(s);
1807
- });
1808
- }
1809
1802
  function forbiddenReadError(path, allowAbsolute) {
1810
1803
  if (isForbiddenPath(path)) {
1811
1804
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1812
1805
  }
1813
- if (allowAbsolute && isAbsolute(path) && isForbiddenAtAnyDepth(path)) {
1806
+ if (allowAbsolute && isAbsolute(path) && ehProibidoEmQualquerProfundidade(path)) {
1814
1807
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
1815
1808
  }
1816
1809
  return null;
@@ -2551,24 +2544,44 @@ function createTodolistTool() {
2551
2544
  getItems: (threadId) => ops(threadId).getItems()
2552
2545
  };
2553
2546
  }
2547
+ function decodeWholeCodePoints(buf) {
2548
+ return new TextDecoder("utf-8").decode(buf, { stream: true });
2549
+ }
2554
2550
  function truncateOutput(output, opts) {
2555
2551
  const maxBytes = opts?.maxBytes ?? 3e4;
2556
2552
  const outputDir = opts?.outputDir ?? ".theocode/tool-output";
2557
- const byteLength = Buffer.byteLength(output, "utf-8");
2558
- if (byteLength <= maxBytes) {
2559
- return { content: output, truncated: false };
2553
+ const mode = opts?.mode ?? "head";
2554
+ const originalBytes = Buffer.byteLength(output, "utf-8");
2555
+ if (originalBytes <= maxBytes) {
2556
+ return { content: output, truncated: false, originalBytes };
2560
2557
  }
2561
2558
  mkdirSync(outputDir, { recursive: true });
2562
- const filename = `overflow-${Date.now()}.txt`;
2559
+ const filename = `overflow-${Date.now()}-${randomUUID().slice(0, 8)}.txt`;
2563
2560
  const overflowPath = join(outputDir, filename);
2564
2561
  writeFileSync(overflowPath, output, "utf-8");
2565
- const truncated = Buffer.from(output, "utf-8").subarray(0, maxBytes).toString("utf-8");
2562
+ const buf = Buffer.from(output, "utf-8");
2566
2563
  const trailer = `
2567
2564
 
2568
2565
  [Output truncated. Full output: ${overflowPath}]`;
2566
+ if (mode === "head-tail") {
2567
+ const half = Math.floor(maxBytes / 2);
2568
+ const head = decodeWholeCodePoints(buf.subarray(0, half));
2569
+ const tail = decodeWholeCodePoints(buf.subarray(buf.length - half));
2570
+ return {
2571
+ content: `${head}
2572
+
2573
+ [\u2026 ${String(originalBytes - maxBytes)} bytes omitted \u2026]
2574
+
2575
+ ${tail}${trailer}`,
2576
+ truncated: true,
2577
+ originalBytes,
2578
+ overflowPath
2579
+ };
2580
+ }
2569
2581
  return {
2570
- content: truncated + trailer,
2582
+ content: decodeWholeCodePoints(buf.subarray(0, maxBytes)) + trailer,
2571
2583
  truncated: true,
2584
+ originalBytes,
2572
2585
  overflowPath
2573
2586
  };
2574
2587
  }