@theokit/sdk-tools 0.25.0 → 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
 
@@ -2543,24 +2544,44 @@ function createTodolistTool() {
2543
2544
  getItems: (threadId) => ops(threadId).getItems()
2544
2545
  };
2545
2546
  }
2547
+ function decodeWholeCodePoints(buf) {
2548
+ return new TextDecoder("utf-8").decode(buf, { stream: true });
2549
+ }
2546
2550
  function truncateOutput(output, opts) {
2547
2551
  const maxBytes = opts?.maxBytes ?? 3e4;
2548
2552
  const outputDir = opts?.outputDir ?? ".theocode/tool-output";
2549
- const byteLength = Buffer.byteLength(output, "utf-8");
2550
- if (byteLength <= maxBytes) {
2551
- 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 };
2552
2557
  }
2553
2558
  mkdirSync(outputDir, { recursive: true });
2554
- const filename = `overflow-${Date.now()}.txt`;
2559
+ const filename = `overflow-${Date.now()}-${randomUUID().slice(0, 8)}.txt`;
2555
2560
  const overflowPath = join(outputDir, filename);
2556
2561
  writeFileSync(overflowPath, output, "utf-8");
2557
- const truncated = Buffer.from(output, "utf-8").subarray(0, maxBytes).toString("utf-8");
2562
+ const buf = Buffer.from(output, "utf-8");
2558
2563
  const trailer = `
2559
2564
 
2560
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
+ }
2561
2581
  return {
2562
- content: truncated + trailer,
2582
+ content: decodeWholeCodePoints(buf.subarray(0, maxBytes)) + trailer,
2563
2583
  truncated: true,
2584
+ originalBytes,
2564
2585
  overflowPath
2565
2586
  };
2566
2587
  }