scream-code 0.16.3 → 0.16.5

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.
@@ -6,7 +6,7 @@ const __dirname = __cjsShimDirname(__filename);
6
6
  import { i as __require, o as __toESM, r as __exportAll, t as __commonJSMin } from "./chunk-D90kvbyJ.mjs";
7
7
  import { C as join$1, D as resolve$2, E as relative$1, S as isAbsolute$1, T as parse$7, a as isSupportedFile, b as basename$1, i as ingestFile, r as ingestDirectory, t as multiSearch, w as normalize$1, x as dirname$2, y as KnowledgeStore } from "./src-tDEINaMV.mjs";
8
8
  import { t as require_base64_js } from "./base64-js-DzVmk6Nb.mjs";
9
- import { a as setLocale, i as getLocale, n as assertScreamHostIdentity, o as t, r as createScreamDefaultHeaders, t as TextInputDialogComponent } from "./text-input-dialog-GajpxjyL.mjs";
9
+ import { a as setLocale, i as getLocale, n as assertScreamHostIdentity, o as t, r as createScreamDefaultHeaders, t as TextInputDialogComponent } from "./text-input-dialog-KaFgI_Ko.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
12
12
  import * as fs$1 from "node:fs/promises";
@@ -33,6 +33,7 @@ import * as nodeOs from "node:os";
33
33
  import { homedir, release, tmpdir } from "node:os";
34
34
  import * as vs from "zlib";
35
35
  import Qr from "zlib";
36
+ import { diffLines, diffWords } from "diff";
36
37
  import { EventEmitter as EventEmitter$1 } from "node:events";
37
38
  import { StringDecoder } from "node:string_decoder";
38
39
  import Pi from "assert";
@@ -52,7 +53,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
52
53
  import chalk from "chalk";
53
54
  import { Box, Container, Image, Input, Key, Markdown, Spacer, Text, decodeKittyPrintable, fuzzyFilter, getCapabilities, getImageDimensions, matchesKey, sliceByColumn, truncateToWidth, visibleWidth } from "@liutod-scream/pi-tui";
54
55
  import { highlight, supportsLanguage } from "cli-highlight";
55
- import { diffWords } from "diff";
56
56
  import { gt, valid } from "semver";
57
57
  //#region ../../packages/agent-core/src/errors/codes.ts
58
58
  /**
@@ -48278,10 +48278,25 @@ var BackgroundProcessManager = class {
48278
48278
  const outputSessionDir = this.outputSessionDirFor(taskId);
48279
48279
  if (outputSessionDir !== void 0) {
48280
48280
  await entry?.outputWriteQueue;
48281
- const persisted = await readTaskOutput(outputSessionDir, taskId);
48282
- if (persisted.length > 0) {
48283
- if (tail !== void 0 && tail < persisted.length) return persisted.slice(-tail);
48284
- return persisted;
48281
+ if (tail !== void 0 && tail > 0) {
48282
+ const size = await taskOutputSizeBytes(outputSessionDir, taskId);
48283
+ if (size > 0) {
48284
+ const windowBytes = Math.min(size, tail * 4 + 7);
48285
+ const start = size - windowBytes;
48286
+ let persisted = await readTaskOutputBytes(outputSessionDir, taskId, start, windowBytes);
48287
+ if (persisted.length > 0) {
48288
+ if (start > 0) {
48289
+ let lead = 0;
48290
+ while (lead < persisted.length && persisted.codePointAt(lead) === 65533 && lead < 3) lead += 1;
48291
+ if (lead > 0) persisted = persisted.slice(lead);
48292
+ }
48293
+ return tail < persisted.length ? persisted.slice(-tail) : persisted;
48294
+ }
48295
+ return this.getOutput(taskId, tail);
48296
+ }
48297
+ } else {
48298
+ const persisted = await readTaskOutput(outputSessionDir, taskId);
48299
+ if (persisted.length > 0) return tail !== void 0 && tail < persisted.length ? persisted.slice(-tail) : persisted;
48285
48300
  }
48286
48301
  }
48287
48302
  return this.getOutput(taskId, tail);
@@ -69519,6 +69534,77 @@ function withTimeout$1(promise, timeoutMs, parentSignal) {
69519
69534
  });
69520
69535
  }
69521
69536
  //#endregion
69537
+ //#region ../../packages/agent-core/src/tools/support/file-diff.ts
69538
+ /**
69539
+ * Line-diff statistics for the file-mutating tools.
69540
+ *
69541
+ * Edit and Write both hold the pre-write and post-write text of the file they
69542
+ * touch, so they can report exact `+added / -removed` line counts to the UI.
69543
+ * Deriving those counts from the tool arguments instead is systematically
69544
+ * wrong (a Write overwrite hides every removed line; an Edit with
69545
+ * `replace_all` sees only one occurrence), so the tools report them here.
69546
+ *
69547
+ * Only counts leave this module: file contents must never be embedded in the
69548
+ * display payload, which is persisted with the session transcript.
69549
+ */
69550
+ /**
69551
+ * Upper bound applied to either side of the diff. Beyond it the line diff is
69552
+ * skipped (returns `undefined`) so an oversized file cannot stall the calling
69553
+ * tool; the caller then omits the display and the UI falls back to its
69554
+ * argument-derived counts.
69555
+ */
69556
+ const MAX_DIFF_CHARS = 1e6;
69557
+ /**
69558
+ * Wall-clock budget for a single line diff. jsdiff's Myers implementation has
69559
+ * adversarial cases that stay quadratic-ish well inside {@link MAX_DIFF_CHARS}:
69560
+ * two ~400 KB blobs with no common lines measured minutes before aborting. Past
69561
+ * the budget jsdiff gives up and the result is treated exactly like the size
69562
+ * guard (no display, argument-derived fallback) — stalling the tool, and with it
69563
+ * the event loop, is never acceptable for a UI statistic.
69564
+ */
69565
+ const DIFF_BUDGET_MS = 250;
69566
+ /**
69567
+ * `diffLines` with the runtime `timeout` option. jsdiff honours `timeout`
69568
+ * (returning `undefined` when it gives up) but its published typings omit it.
69569
+ */
69570
+ const diffLinesWithBudget = diffLines;
69571
+ /**
69572
+ * Count the lines in a `diffLines` part value the way `git diff --numstat`
69573
+ * counts them: a trailing newline terminates the preceding line instead of
69574
+ * opening an empty one, and the empty string has no lines.
69575
+ */
69576
+ function countLines$1(value) {
69577
+ if (value.length === 0) return 0;
69578
+ const lineBreaks = value.split("\n").length - 1;
69579
+ return value.endsWith("\n") ? lineBreaks : lineBreaks + 1;
69580
+ }
69581
+ /**
69582
+ * Exact line counts between `before` and `after`: every added / removed part
69583
+ * `diffLines` reports contributes its own line count, so a single replaced
69584
+ * line is one addition plus one removal. Whitespace is significant (jsdiff's
69585
+ * `ignoreWhitespace` is off by default) and `\r\n` endings are compared
69586
+ * literally.
69587
+ *
69588
+ * Returns `undefined` when either side exceeds {@link MAX_DIFF_CHARS} or the
69589
+ * diff does not finish within {@link DIFF_BUDGET_MS}.
69590
+ */
69591
+ function fileDiffSummary(before, after) {
69592
+ if (before.length > MAX_DIFF_CHARS || after.length > MAX_DIFF_CHARS) return void 0;
69593
+ const parts = diffLinesWithBudget(before, after, { timeout: DIFF_BUDGET_MS });
69594
+ if (parts === void 0) return void 0;
69595
+ let added = 0;
69596
+ let removed = 0;
69597
+ for (const part of parts) {
69598
+ const lines = countLines$1(part.value);
69599
+ if (part.added) added += lines;
69600
+ else if (part.removed) removed += lines;
69601
+ }
69602
+ return {
69603
+ added,
69604
+ removed
69605
+ };
69606
+ }
69607
+ //#endregion
69522
69608
  //#region ../../packages/agent-core/src/tools/support/scan-cache.ts
69523
69609
  var FsScanCache = class {
69524
69610
  cache = /* @__PURE__ */ new Map();
@@ -69842,6 +69928,29 @@ function replaceOnceLiteral(content, oldString, newString) {
69842
69928
  if (index === -1) return content;
69843
69929
  return content.slice(0, index) + newString + content.slice(index + oldString.length);
69844
69930
  }
69931
+ /**
69932
+ * Attach the exact `+added / -removed` line counts to a successful result so
69933
+ * the UI can total them per activity group.
69934
+ *
69935
+ * The counts come from the real pre-edit and post-edit text, so a
69936
+ * `replace_all` edit reports the whole-file delta instead of one occurrence's
69937
+ * worth. Error results carry no display: nothing was applied, or the failure
69938
+ * path never established the post-edit text.
69939
+ */
69940
+ function withFileDiffDisplay(result, before, after) {
69941
+ if (result.isError === true) return result;
69942
+ const summary = fileDiffSummary(before, after);
69943
+ if (summary === void 0) return result;
69944
+ const display = {
69945
+ kind: "file_diff",
69946
+ added: summary.added,
69947
+ removed: summary.removed
69948
+ };
69949
+ return {
69950
+ ...result,
69951
+ display
69952
+ };
69953
+ }
69845
69954
  var EditTool = class {
69846
69955
  jian;
69847
69956
  workspace;
@@ -69915,7 +70024,8 @@ var EditTool = class {
69915
70024
  output: "new_string contains merge conflict markers (<<<<<<< / ======= / >>>>>>>). Remove them before writing. These markers indicate an unresolved merge and should not be introduced into files."
69916
70025
  };
69917
70026
  try {
69918
- const modelView = toModelTextView(await this.jian.readText(safePath));
70027
+ const raw = await this.jian.readText(safePath);
70028
+ const modelView = toModelTextView(raw);
69919
70029
  const content = modelView.text;
69920
70030
  const replaceAll = args.replace_all ?? false;
69921
70031
  const existingBlocks = scanConflictLines(content.split("\n"));
@@ -69975,20 +70085,20 @@ var EditTool = class {
69975
70085
  output: `old_string is not unique in ${args.path} (found ${String(count)} occurrences at lines: ${matchLineNumbers.join(", ")}${truncatedNote}). To replace every occurrence, set replace_all=true. To target a specific one, include more surrounding context lines from one of those line ranges in old_string.`
69976
70086
  };
69977
70087
  }
69978
- const newContent = replaceOnceLiteral(content, args.old_string, args.new_string);
69979
- await this.jian.writeText(safePath, materializeModelText(newContent, modelView.lineEndingStyle));
70088
+ const writtenText = materializeModelText(replaceOnceLiteral(content, args.old_string, args.new_string), modelView.lineEndingStyle);
70089
+ await this.jian.writeText(safePath, writtenText);
69980
70090
  scanCache.clear();
69981
70091
  const { notice, hasErrors } = await this.appendDiagnostics(safePath);
69982
70092
  const output = `Replaced 1 occurrence in ${args.path}`;
69983
70093
  const message = notice.length > 0 ? notice : void 0;
69984
- return hasErrors ? {
70094
+ return withFileDiffDisplay(hasErrors ? {
69985
70095
  isError: true,
69986
70096
  output,
69987
70097
  message
69988
70098
  } : {
69989
70099
  output,
69990
70100
  message
69991
- };
70101
+ }, raw, writtenText);
69992
70102
  }
69993
70103
  const parts = content.split(args.old_string);
69994
70104
  const replacementCount = parts.length - 1;
@@ -69999,20 +70109,20 @@ var EditTool = class {
69999
70109
  output: `old_string not found in ${args.path} (file has ${String(lineCount)} lines). The file contents may be out of date — re-read with the Read tool. If you already re-read, verify old_string matches exactly: indentation, trailing whitespace, and line endings (LF vs CRLF) must all match the Read output view.`
70000
70110
  };
70001
70111
  }
70002
- const newContent = parts.join(args.new_string);
70003
- await this.jian.writeText(safePath, materializeModelText(newContent, modelView.lineEndingStyle));
70112
+ const writtenText = materializeModelText(parts.join(args.new_string), modelView.lineEndingStyle);
70113
+ await this.jian.writeText(safePath, writtenText);
70004
70114
  scanCache.clear();
70005
70115
  const { notice, hasErrors } = await this.appendDiagnostics(safePath);
70006
70116
  const output = `Replaced ${String(replacementCount)} occurrences in ${args.path}`;
70007
70117
  const message = notice.length > 0 ? notice : void 0;
70008
- return hasErrors ? {
70118
+ return withFileDiffDisplay(hasErrors ? {
70009
70119
  isError: true,
70010
70120
  output,
70011
70121
  message
70012
70122
  } : {
70013
70123
  output,
70014
70124
  message
70015
- };
70125
+ }, raw, writtenText);
70016
70126
  } catch (error) {
70017
70127
  if (error?.code === "EISDIR") return {
70018
70128
  isError: true,
@@ -75681,6 +75791,12 @@ var write_default = "Write does not preserve or infer the previous line-ending s
75681
75791
  const S_IFMT$2 = 61440;
75682
75792
  /** File-type bits of a directory. */
75683
75793
  const S_IFDIR$1 = 16384;
75794
+ /**
75795
+ * Size ceiling (bytes, from `stat`) for reading the pre-write text that feeds
75796
+ * the `file_diff` display. Mirrors the guard inside `fileDiffSummary`: past it
75797
+ * the diff is skipped anyway, so the file is never read only to be discarded.
75798
+ */
75799
+ const MAX_DIFF_SOURCE_BYTES = 1e6;
75684
75800
  const WriteInputSchema = z.object({
75685
75801
  path: z.string().describe("Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. The parent directory must already exist."),
75686
75802
  content: z.string().describe("Raw full file content to write exactly as provided. This does not use the Read/Edit text view."),
@@ -75739,6 +75855,8 @@ var WriteTool = class {
75739
75855
  };
75740
75856
  try {
75741
75857
  const mode = args.mode ?? "overwrite";
75858
+ const existing = await this.readExistingText(safePath);
75859
+ const display = mode === "append" ? this.appendFileDiffDisplay(args.content, existing) : this.overwriteFileDiffDisplay(args.content, existing);
75742
75860
  if (mode === "append") await this.jian.writeText(safePath, args.content, { mode: "a" });
75743
75861
  else await this.jian.writeText(safePath, args.content);
75744
75862
  scanCache.clear();
@@ -75746,13 +75864,18 @@ var WriteTool = class {
75746
75864
  const { notice, hasErrors } = await this.appendDiagnostics(safePath);
75747
75865
  const output = `${mode === "append" ? "Appended" : "Wrote"} ${String(bytesWritten)} bytes to ${args.path}`;
75748
75866
  const message = notice.length > 0 ? notice : void 0;
75749
- return hasErrors ? {
75867
+ if (hasErrors) return {
75750
75868
  isError: true,
75751
75869
  output,
75752
75870
  message
75753
- } : {
75871
+ };
75872
+ return display === void 0 ? {
75754
75873
  output,
75755
75874
  message
75875
+ } : {
75876
+ output,
75877
+ message,
75878
+ display
75756
75879
  };
75757
75880
  } catch (error) {
75758
75881
  if (error?.code === "ENOENT") return {
@@ -75766,6 +75889,74 @@ var WriteTool = class {
75766
75889
  }
75767
75890
  }
75768
75891
  /**
75892
+ * Compute the `file_diff` line counts for an append.
75893
+ *
75894
+ * Appending concatenates bytes, so a file that does not end with a newline has
75895
+ * its last line rewritten by the first appended line — counting only the new
75896
+ * content (`+N -0`) would hide that rewritten line. Diffing the real pre-write
75897
+ * text against that text plus the new content is exact; a target that does not
75898
+ * exist yet can only gain lines.
75899
+ */
75900
+ appendFileDiffDisplay(content, existing) {
75901
+ if (existing.kind === "unavailable") return void 0;
75902
+ const before = existing.kind === "missing" ? "" : existing.text;
75903
+ const summary = fileDiffSummary(before, before + content);
75904
+ if (summary === void 0) return void 0;
75905
+ return {
75906
+ kind: "file_diff",
75907
+ added: summary.added,
75908
+ removed: summary.removed
75909
+ };
75910
+ }
75911
+ /**
75912
+ * Compute the `file_diff` line counts for an overwrite.
75913
+ *
75914
+ * A file that does not exist yet can only gain lines, so the argument-derived
75915
+ * count is exact there. Otherwise the previous text is diffed against the
75916
+ * new text, which is the only way to see the removed lines an overwrite
75917
+ * destroys. Returns `undefined` (no display) when the previous text is
75918
+ * unknown or too large to diff.
75919
+ */
75920
+ overwriteFileDiffDisplay(content, existing) {
75921
+ if (existing.kind === "missing") return {
75922
+ kind: "file_diff",
75923
+ added: countLines$1(content),
75924
+ removed: 0
75925
+ };
75926
+ if (existing.kind === "unavailable") return void 0;
75927
+ const summary = fileDiffSummary(existing.text, content);
75928
+ if (summary === void 0) return void 0;
75929
+ return {
75930
+ kind: "file_diff",
75931
+ added: summary.added,
75932
+ removed: summary.removed
75933
+ };
75934
+ }
75935
+ /**
75936
+ * Best-effort read of the text this call is about to overwrite, for the
75937
+ * `file_diff` display only.
75938
+ *
75939
+ * The `stat` size check runs first so an oversized file is never pulled into
75940
+ * memory just to be diffed. A failing `stat` for any reason other than
75941
+ * `ENOENT` is inconclusive, so the read is still attempted.
75942
+ */
75943
+ async readExistingText(safePath) {
75944
+ try {
75945
+ if ((await this.jian.stat(safePath)).stSize > MAX_DIFF_SOURCE_BYTES) return { kind: "unavailable" };
75946
+ } catch (error) {
75947
+ if (error?.code === "ENOENT") return { kind: "missing" };
75948
+ }
75949
+ try {
75950
+ return {
75951
+ kind: "text",
75952
+ text: await this.jian.readText(safePath)
75953
+ };
75954
+ } catch (error) {
75955
+ if (error?.code === "ENOENT") return { kind: "missing" };
75956
+ return { kind: "unavailable" };
75957
+ }
75958
+ }
75959
+ /**
75769
75960
  * Best-effort check that the parent directory exists and is a directory.
75770
75961
  *
75771
75962
  * The path schema documents this precondition; probing it up front turns a
@@ -86069,7 +86260,8 @@ const SNAPSHOT_FOLDED_CONTEXT_TYPES = new Set([
86069
86260
  "context.append_loop_event",
86070
86261
  "context.apply_compaction",
86071
86262
  "micro_compaction.apply",
86072
- "full_compaction.complete"
86263
+ "full_compaction.complete",
86264
+ "request.header"
86073
86265
  ]);
86074
86266
  /**
86075
86267
  * Serialized-line prefix of a context.snapshot record. Persisted records
@@ -86088,11 +86280,42 @@ const SNAPSHOT_FOLDED_LINE_PREFIXES = [...SNAPSHOT_FOLDED_CONTEXT_TYPES].map((ty
86088
86280
  const SNAPSHOT_RECORD_LINE_PREFIX_BYTES = Buffer.from(SNAPSHOT_RECORD_LINE_PREFIX, "utf8");
86089
86281
  /** UTF-8 bytes of each entry of {@link SNAPSHOT_FOLDED_LINE_PREFIXES}. */
86090
86282
  const SNAPSHOT_FOLDED_LINE_PREFIX_BYTES = SNAPSHOT_FOLDED_LINE_PREFIXES.map((prefix) => Buffer.from(prefix, "utf8"));
86091
- function startsWithPrefix(data, prefix) {
86092
- return data.length >= prefix.length && data.subarray(0, prefix.length).equals(prefix);
86283
+ /**
86284
+ * Longest folded-type line prefix: the number of leading bytes a line needs
86285
+ * before the parse filter can decide whether to drop it. Until that many bytes
86286
+ * are seen the head is the only part of the line held in memory.
86287
+ */
86288
+ const SNAPSHOT_FOLDED_MAX_PREFIX_BYTES = SNAPSHOT_FOLDED_LINE_PREFIX_BYTES.reduce((longest, prefix) => Math.max(longest, prefix.length), 0);
86289
+ /** Bytes-level prefix test for a line head that may be shorter than the prefix. */
86290
+ function headStartsWithPrefix(head, headLength, prefix) {
86291
+ return headLength >= prefix.length && head.subarray(0, prefix.length).equals(prefix);
86093
86292
  }
86094
- function startsWithAnyPrefix(data, prefixes) {
86095
- return prefixes.some((prefix) => startsWithPrefix(data, prefix));
86293
+ function headStartsWithAnyPrefix(head, headLength, prefixes) {
86294
+ return prefixes.some((prefix) => headStartsWithPrefix(head, headLength, prefix));
86295
+ }
86296
+ /**
86297
+ * Whether folded context records that predate the last snapshot can be skipped
86298
+ * without decoding. Safe only when no wire migration is needed: a version
86299
+ * mismatch triggers migrations/rewrite that must see EVERY record, so
86300
+ * old/new-version files fall back to full parsing. The version lives in the
86301
+ * first (metadata) line. A header that cannot be parsed returns `false` here —
86302
+ * the same line is parsed again below, where the error is thrown.
86303
+ */
86304
+ function readSkipFoldedFlag(headerText) {
86305
+ try {
86306
+ return JSON.parse(headerText).protocol_version === "1.4";
86307
+ } catch {
86308
+ return false;
86309
+ }
86310
+ }
86311
+ /** File size in bytes, or `undefined` when the file does not exist yet. */
86312
+ async function fileSize(path) {
86313
+ try {
86314
+ return (await stat(path)).size;
86315
+ } catch (error) {
86316
+ if (error.code === "ENOENT") return void 0;
86317
+ throw error;
86318
+ }
86096
86319
  }
86097
86320
  var FileSystemAgentRecordPersistence = class {
86098
86321
  filePath;
@@ -86102,6 +86325,10 @@ var FileSystemAgentRecordPersistence = class {
86102
86325
  directorySynced = false;
86103
86326
  rewriteSeq = 0;
86104
86327
  flushPromise;
86328
+ /** Set while `compact()` runs; writes queue up instead of draining. */
86329
+ compacting = false;
86330
+ /** Folded-history bytes skipped by the most recent full read(). */
86331
+ droppedBytes = 0;
86105
86332
  error;
86106
86333
  constructor(filePath, options = {}) {
86107
86334
  this.filePath = filePath;
@@ -86109,54 +86336,189 @@ var FileSystemAgentRecordPersistence = class {
86109
86336
  }
86110
86337
  async *read() {
86111
86338
  await this.flush();
86112
- const lines = [];
86113
- let lastSnapshotLineNumber = -1;
86339
+ const size = await fileSize(this.filePath);
86340
+ if (size === void 0 || size === 0) return;
86341
+ const lastSnapshotLineNumber = await this.scanLastSnapshotLine(size);
86342
+ if (lastSnapshotLineNumber === void 0) return;
86343
+ yield* this.streamRecords(size, lastSnapshotLineNumber);
86344
+ }
86345
+ /**
86346
+ * Byte-level pass that returns the line number of the last
86347
+ * `context.snapshot` line, or `-1` when the file has none. `undefined` means
86348
+ * the file does not exist (a brand-new session has no wire yet — not an
86349
+ * error). Nothing is decoded and no line is retained: memory stays at one
86350
+ * stream chunk.
86351
+ */
86352
+ async scanLastSnapshotLine(size) {
86353
+ const prefix = SNAPSHOT_RECORD_LINE_PREFIX_BYTES;
86354
+ const head = Buffer.allocUnsafe(prefix.length);
86355
+ let headLength = 0;
86114
86356
  let lineNumber = 0;
86115
- let openChunks = [];
86116
- const stream = createReadStream(this.filePath);
86357
+ let lastSnapshotLineNumber = -1;
86358
+ const stream = createReadStream(this.filePath, { end: size - 1 });
86117
86359
  try {
86118
86360
  for await (const chunk of stream) {
86119
86361
  let searchFrom = 0;
86120
86362
  for (;;) {
86121
86363
  const newlineIndex = chunk.indexOf(10, searchFrom);
86364
+ const end = newlineIndex === -1 ? chunk.length : newlineIndex;
86365
+ if (end > searchFrom && headLength < prefix.length) {
86366
+ const take = Math.min(prefix.length - headLength, end - searchFrom);
86367
+ chunk.copy(head, headLength, searchFrom, searchFrom + take);
86368
+ headLength += take;
86369
+ }
86122
86370
  if (newlineIndex === -1) break;
86123
- const parts = openChunks.length > 0 ? [...openChunks, chunk.subarray(searchFrom, newlineIndex)] : [chunk.subarray(searchFrom, newlineIndex)];
86124
- let lineData = Buffer.concat(parts);
86125
- if (lineData.length > 0 && lineData.at(-1) === 13) lineData = lineData.subarray(0, lineData.length - 1);
86126
86371
  lineNumber++;
86127
- if (startsWithPrefix(lineData, SNAPSHOT_RECORD_LINE_PREFIX_BYTES)) lastSnapshotLineNumber = lineNumber;
86128
- lines.push({
86129
- data: lineData,
86130
- lineNumber,
86131
- allowTruncated: false
86132
- });
86372
+ if (headLength === prefix.length && head.equals(prefix)) lastSnapshotLineNumber = lineNumber;
86373
+ headLength = 0;
86374
+ searchFrom = newlineIndex + 1;
86375
+ }
86376
+ }
86377
+ } catch (error) {
86378
+ if (error.code === "ENOENT") return void 0;
86379
+ throw error;
86380
+ }
86381
+ return lastSnapshotLineNumber;
86382
+ }
86383
+ /**
86384
+ * Streaming replay pass. Folded records predating the last snapshot are
86385
+ * dropped WITHOUT being decoded — the restore fast-path discards them anyway
86386
+ * (records/index.ts snapshot branch), so the yielded stream is identical to a
86387
+ * full decode while neither the file nor the skipped lines are ever held in
86388
+ * memory. An unterminated trailing line is tolerated (see parseRecordLine):
86389
+ * the last write may have crashed mid-flush.
86390
+ */
86391
+ async *streamRecords(size, lastSnapshotLineNumber) {
86392
+ const head = Buffer.allocUnsafe(SNAPSHOT_FOLDED_MAX_PREFIX_BYTES);
86393
+ let headLength = 0;
86394
+ let openChunks = [];
86395
+ let decided = false;
86396
+ let retaining = false;
86397
+ let headerSeen = false;
86398
+ let skipFoldedBeforeSnapshot = false;
86399
+ let lineNumber = 0;
86400
+ let lineBytes = 0;
86401
+ let skippedBytes = 0;
86402
+ const retainedLine = () => openChunks.length === 1 ? openChunks[0] : Buffer.concat(openChunks);
86403
+ const keepLine = (currentLine) => !(skipFoldedBeforeSnapshot && currentLine < lastSnapshotLineNumber && headStartsWithAnyPrefix(head, headLength, SNAPSHOT_FOLDED_LINE_PREFIX_BYTES));
86404
+ const stream = createReadStream(this.filePath, { end: size - 1 });
86405
+ try {
86406
+ for await (const chunk of stream) {
86407
+ let searchFrom = 0;
86408
+ for (;;) {
86409
+ const newlineIndex = chunk.indexOf(10, searchFrom);
86410
+ const end = newlineIndex === -1 ? chunk.length : newlineIndex;
86411
+ if (!decided && end > searchFrom) {
86412
+ const take = Math.min(SNAPSHOT_FOLDED_MAX_PREFIX_BYTES - headLength, end - searchFrom);
86413
+ chunk.copy(head, headLength, searchFrom, searchFrom + take);
86414
+ headLength += take;
86415
+ searchFrom += take;
86416
+ lineBytes += take;
86417
+ if (headLength === SNAPSHOT_FOLDED_MAX_PREFIX_BYTES) {
86418
+ decided = true;
86419
+ retaining = keepLine(lineNumber + 1);
86420
+ if (retaining) openChunks.push(Buffer.from(head));
86421
+ }
86422
+ }
86423
+ if (retaining && end > searchFrom) openChunks.push(chunk.subarray(searchFrom, end));
86424
+ if (newlineIndex === -1) {
86425
+ lineBytes += end - searchFrom;
86426
+ break;
86427
+ }
86428
+ lineBytes += newlineIndex + 1 - searchFrom;
86429
+ lineNumber++;
86430
+ if (!decided) {
86431
+ decided = true;
86432
+ retaining = keepLine(lineNumber);
86433
+ if (retaining && headLength > 0) openChunks.push(Buffer.from(head.subarray(0, headLength)));
86434
+ }
86435
+ if (retaining) {
86436
+ let lineData = retainedLine();
86437
+ if (lineData.length > 0 && lineData.at(-1) === 13) lineData = lineData.subarray(0, lineData.length - 1);
86438
+ const lineText = lineData.toString("utf8");
86439
+ if (!headerSeen) {
86440
+ headerSeen = true;
86441
+ skipFoldedBeforeSnapshot = readSkipFoldedFlag(lineText);
86442
+ }
86443
+ const record = parseRecordLine(lineText, lineNumber, this.filePath, false);
86444
+ if (record !== void 0) yield record;
86445
+ }
86446
+ if (!retaining) skippedBytes += lineBytes;
86133
86447
  openChunks = [];
86448
+ headLength = 0;
86449
+ decided = false;
86450
+ retaining = false;
86451
+ lineBytes = 0;
86134
86452
  searchFrom = newlineIndex + 1;
86135
86453
  }
86136
- if (searchFrom < chunk.length) openChunks.push(chunk.subarray(searchFrom));
86137
86454
  }
86138
86455
  } catch (error) {
86139
86456
  if (error.code === "ENOENT") return;
86140
86457
  throw error;
86141
86458
  }
86142
- if (openChunks.length > 0) {
86143
- lineNumber++;
86144
- lines.push({
86145
- data: Buffer.concat(openChunks),
86146
- lineNumber,
86147
- allowTruncated: true
86148
- });
86149
- }
86150
- let skipFoldedBeforeSnapshot = false;
86151
- if (lines.length > 0) try {
86152
- skipFoldedBeforeSnapshot = JSON.parse(lines[0].data.toString("utf8")).protocol_version === "1.4";
86153
- } catch {
86154
- skipFoldedBeforeSnapshot = false;
86459
+ if (!decided && headLength > 0) {
86460
+ decided = true;
86461
+ retaining = keepLine(lineNumber + 1);
86462
+ if (retaining) openChunks.push(Buffer.from(head.subarray(0, headLength)));
86155
86463
  }
86156
- for (const entry of lines) {
86157
- if (skipFoldedBeforeSnapshot && entry.lineNumber < lastSnapshotLineNumber && startsWithAnyPrefix(entry.data, SNAPSHOT_FOLDED_LINE_PREFIX_BYTES)) continue;
86158
- const record = parseRecordLine(entry.data.toString("utf8"), entry.lineNumber, this.filePath, entry.allowTruncated);
86464
+ if (retaining && openChunks.length > 0) {
86465
+ lineNumber++;
86466
+ let lineData = retainedLine();
86467
+ if (lineData.length > 0 && lineData.at(-1) === 13) lineData = lineData.subarray(0, lineData.length - 1);
86468
+ const record = parseRecordLine(lineData.toString("utf8"), lineNumber, this.filePath, true);
86159
86469
  if (record !== void 0) yield record;
86470
+ } else if (!retaining) skippedBytes += lineBytes;
86471
+ this.droppedBytes = skippedBytes;
86472
+ }
86473
+ /** Folded-history bytes skipped by the most recent full read(). */
86474
+ droppedBytesOnLastRead() {
86475
+ return this.droppedBytes;
86476
+ }
86477
+ /** Whether the last read() skipped enough folded history to be worth reclaiming on disk. */
86478
+ shouldCompactOnResume() {
86479
+ return this.droppedBytes >= (this.options.compactThresholdBytes ?? 8388608);
86480
+ }
86481
+ /**
86482
+ * Physically drop every record that predates the last `context.snapshot`
86483
+ * (exactly the lines `read()` skips). Byte-preserving: retained lines are
86484
+ * copied verbatim, so unknown records, CRLF framing and blob references
86485
+ * survive untouched. Writes a temp file, fsyncs it, then atomically renames
86486
+ * it over the wire. While it runs, appends queue up instead of draining
86487
+ * (see compacting) and are flushed right after the swap.
86488
+ */
86489
+ async compact() {
86490
+ await this.flush();
86491
+ this.compacting = true;
86492
+ const tmpPath = `${this.filePath}.${process.pid}.${this.rewriteSeq++}.compact.tmp`;
86493
+ let swapped = false;
86494
+ try {
86495
+ const size = await fileSize(this.filePath);
86496
+ if (size === void 0 || size === 0) return;
86497
+ const lastSnapshotLineNumber = await this.scanLastSnapshotLine(size);
86498
+ if (lastSnapshotLineNumber === void 0) return;
86499
+ const directory = dirname$2(this.filePath);
86500
+ const tmp = await open(tmpPath, "w");
86501
+ let written = 0;
86502
+ try {
86503
+ written = await copyRetainingLines(this.filePath, tmp, size, lastSnapshotLineNumber);
86504
+ await tmp.sync();
86505
+ } finally {
86506
+ await tmp.close();
86507
+ }
86508
+ if (written === size) {
86509
+ this.droppedBytes = 0;
86510
+ return;
86511
+ }
86512
+ if (await fileSize(this.filePath) !== size) return;
86513
+ await rename(tmpPath, this.filePath);
86514
+ swapped = true;
86515
+ await syncDir(directory);
86516
+ this.directorySynced = true;
86517
+ this.droppedBytes = 0;
86518
+ } finally {
86519
+ if (!swapped) await rm(tmpPath, { force: true }).catch(() => {});
86520
+ this.compacting = false;
86521
+ if (this.shouldClear || this.pendingRecords.length > 0) this.scheduleFlush();
86160
86522
  }
86161
86523
  }
86162
86524
  append(input) {
@@ -86172,6 +86534,7 @@ var FileSystemAgentRecordPersistence = class {
86172
86534
  }
86173
86535
  async flush() {
86174
86536
  this.throwIfError();
86537
+ while (this.compacting) await new Promise((resolve) => setTimeout(resolve, 10));
86175
86538
  while (this.flushPromise !== void 0 || this.shouldClear || this.pendingRecords.length > 0) {
86176
86539
  await this.ensureFlush();
86177
86540
  this.throwIfError();
@@ -86186,6 +86549,7 @@ var FileSystemAgentRecordPersistence = class {
86186
86549
  });
86187
86550
  }
86188
86551
  ensureFlush() {
86552
+ if (this.compacting) return Promise.resolve();
86189
86553
  if (this.flushPromise !== void 0) return this.flushPromise;
86190
86554
  const promise = this.drainPendingRecords().catch((error) => {
86191
86555
  this.error = error;
@@ -86201,20 +86565,20 @@ var FileSystemAgentRecordPersistence = class {
86201
86565
  if (this.error !== void 0) throw this.error;
86202
86566
  }
86203
86567
  async drainPendingRecords() {
86204
- while (this.shouldClear || this.pendingRecords.length > 0) await this.drainBatch();
86568
+ while (!this.compacting && (this.shouldClear || this.pendingRecords.length > 0)) await this.drainBatch();
86205
86569
  }
86206
86570
  async drainBatch() {
86207
86571
  const shouldClear = this.shouldClear;
86208
86572
  const batch = this.pendingRecords.splice(0);
86209
86573
  this.shouldClear = false;
86210
- const content = (this.options.blobStore !== void 0 ? await Promise.all(batch.map((record) => this.options.blobStore.offload(record))) : batch).map((e) => JSON.stringify(e) + "\n").join("");
86574
+ const writable = this.options.blobStore !== void 0 ? await Promise.all(batch.map((record) => this.options.blobStore.offload(record))) : batch;
86211
86575
  const directory = dirname$2(this.filePath);
86212
86576
  await mkdir(directory, { recursive: true });
86213
86577
  if (shouldClear) {
86214
86578
  const tmpPath = `${this.filePath}.${process.pid}.${this.rewriteSeq++}.tmp`;
86215
86579
  const tmp = await open(tmpPath, "w");
86216
86580
  try {
86217
- if (content.length > 0) await tmp.writeFile(content, "utf8");
86581
+ await writeChunked(tmp, writable);
86218
86582
  await tmp.sync();
86219
86583
  } finally {
86220
86584
  await tmp.close();
@@ -86226,7 +86590,7 @@ var FileSystemAgentRecordPersistence = class {
86226
86590
  }
86227
86591
  const fh = await open(this.filePath, "a");
86228
86592
  try {
86229
- if (content.length > 0) await fh.writeFile(content, "utf8");
86593
+ await writeChunked(fh, writable);
86230
86594
  await fh.sync();
86231
86595
  } finally {
86232
86596
  await fh.close();
@@ -86237,6 +86601,94 @@ var FileSystemAgentRecordPersistence = class {
86237
86601
  }
86238
86602
  }
86239
86603
  };
86604
+ const MAX_WRITE_CHUNK_CHARS = 1e6;
86605
+ /**
86606
+ * Byte-preserving filter pass for compact(): copies every line that
86607
+ * streamRecords() would retain (same fold decision, same unterminated-tail
86608
+ * tolerance) into `out`, returning the number of bytes written. Kept in the
86609
+ * same shape as streamRecords() on purpose — the two must agree on which
86610
+ * lines survive; change both together.
86611
+ */
86612
+ async function copyRetainingLines(filePath, out, size, lastSnapshotLineNumber) {
86613
+ const head = Buffer.allocUnsafe(SNAPSHOT_FOLDED_MAX_PREFIX_BYTES);
86614
+ let headLength = 0;
86615
+ let openChunks = [];
86616
+ let decided = false;
86617
+ let retaining = false;
86618
+ let headerSeen = false;
86619
+ let skipFoldedBeforeSnapshot = false;
86620
+ let lineNumber = 0;
86621
+ let written = 0;
86622
+ const keepLine = (currentLine) => !(skipFoldedBeforeSnapshot && currentLine < lastSnapshotLineNumber && headStartsWithAnyPrefix(head, headLength, SNAPSHOT_FOLDED_LINE_PREFIX_BYTES));
86623
+ const writeRetainedLine = async () => {
86624
+ const data = openChunks.length === 1 ? openChunks[0] : Buffer.concat(openChunks);
86625
+ if (data.length > 0) {
86626
+ await out.write(data);
86627
+ written += data.length;
86628
+ }
86629
+ };
86630
+ const stream = createReadStream(filePath, { end: size - 1 });
86631
+ for await (const chunk of stream) {
86632
+ let searchFrom = 0;
86633
+ for (;;) {
86634
+ const newlineIndex = chunk.indexOf(10, searchFrom);
86635
+ const end = newlineIndex === -1 ? chunk.length : newlineIndex;
86636
+ if (!decided && end > searchFrom) {
86637
+ const take = Math.min(SNAPSHOT_FOLDED_MAX_PREFIX_BYTES - headLength, end - searchFrom);
86638
+ chunk.copy(head, headLength, searchFrom, searchFrom + take);
86639
+ headLength += take;
86640
+ searchFrom += take;
86641
+ if (headLength === SNAPSHOT_FOLDED_MAX_PREFIX_BYTES) {
86642
+ decided = true;
86643
+ retaining = keepLine(lineNumber + 1);
86644
+ if (retaining) openChunks.push(Buffer.from(head));
86645
+ }
86646
+ }
86647
+ if (retaining && end > searchFrom) openChunks.push(chunk.subarray(searchFrom, end));
86648
+ if (newlineIndex === -1) break;
86649
+ lineNumber++;
86650
+ if (!decided) {
86651
+ decided = true;
86652
+ retaining = keepLine(lineNumber);
86653
+ if (retaining && headLength > 0) openChunks.push(Buffer.from(head.subarray(0, headLength)));
86654
+ }
86655
+ if (retaining) {
86656
+ openChunks.push(chunk.subarray(newlineIndex, newlineIndex + 1));
86657
+ if (!headerSeen) {
86658
+ headerSeen = true;
86659
+ let lineData = openChunks.length === 1 ? openChunks[0] : Buffer.concat(openChunks);
86660
+ if (lineData.length > 0 && lineData.at(-1) === 10) lineData = lineData.subarray(0, lineData.length - 1);
86661
+ if (lineData.length > 0 && lineData.at(-1) === 13) lineData = lineData.subarray(0, lineData.length - 1);
86662
+ skipFoldedBeforeSnapshot = readSkipFoldedFlag(lineData.toString("utf8"));
86663
+ }
86664
+ await writeRetainedLine();
86665
+ }
86666
+ openChunks = [];
86667
+ headLength = 0;
86668
+ decided = false;
86669
+ retaining = false;
86670
+ searchFrom = newlineIndex + 1;
86671
+ }
86672
+ }
86673
+ if (!decided && headLength > 0) {
86674
+ decided = true;
86675
+ retaining = keepLine(lineNumber + 1);
86676
+ if (retaining) openChunks.push(Buffer.from(head.subarray(0, headLength)));
86677
+ }
86678
+ if (retaining && openChunks.length > 0) await writeRetainedLine();
86679
+ return written;
86680
+ }
86681
+ async function writeChunked(handle, records) {
86682
+ let chunk = "";
86683
+ for (const record of records) {
86684
+ chunk += JSON.stringify(record) + "\n";
86685
+ if (chunk.length >= MAX_WRITE_CHUNK_CHARS) {
86686
+ await handle.write(chunk, null, "utf8");
86687
+ chunk = "";
86688
+ }
86689
+ }
86690
+ if (chunk.length > 0) await handle.write(chunk, null, "utf8");
86691
+ }
86240
86692
  function parseRecordLine(line, lineNumber, filePath, allowTruncated) {
86241
86693
  if (line.length === 0) return void 0;
86242
86694
  try {
@@ -86570,6 +87022,12 @@ function restoreAgentRecord(agent, input) {
86570
87022
  case "context.append_message":
86571
87023
  agent.context.appendMessage(input.message);
86572
87024
  return;
87025
+ case "context.stream_draft":
87026
+ agent.replayBuilder.replacePartialDraft(input.turnId, {
87027
+ text: input.text,
87028
+ think: input.think
87029
+ });
87030
+ return;
86573
87031
  case "context.append_loop_event":
86574
87032
  agent.context.appendLoopEvent(input.event);
86575
87033
  return;
@@ -86651,6 +87109,7 @@ var AgentRecords = class {
86651
87109
  let hasMetadata = false;
86652
87110
  let shouldRewrite = false;
86653
87111
  let warning;
87112
+ let buffered = false;
86654
87113
  const replayedRecords = [];
86655
87114
  for await (const record of this.persistence.read()) {
86656
87115
  if (!hasMetadata) {
@@ -86665,38 +87124,45 @@ var AgentRecords = class {
86665
87124
  migrations = resolveWireMigrations(readVersion);
86666
87125
  shouldRewrite = readVersion !== "1.4";
86667
87126
  }
87127
+ buffered = shouldRewrite || warning !== void 0;
86668
87128
  }
86669
87129
  let migratedRecord = migrateWireRecord(record, migrations);
86670
87130
  if (migratedRecord.type === "metadata") migratedRecord = {
86671
87131
  ...migratedRecord,
86672
87132
  protocol_version: "1.4"
86673
87133
  };
86674
- replayedRecords.push(migratedRecord);
86675
- }
86676
- let snapshotIndex = -1;
86677
- for (let i = replayedRecords.length - 1; i >= 0; i--) if (replayedRecords[i]?.type === "context.snapshot") {
86678
- snapshotIndex = i;
86679
- break;
86680
- }
86681
- let foldedCompactionSummary;
86682
- for (let i = 0; i < replayedRecords.length; i++) {
86683
- const record = replayedRecords[i];
86684
- if (!record) continue;
86685
- if (i < snapshotIndex && isSnapshotFoldedContextRecord(record.type)) {
86686
- if (record.type === "context.apply_compaction") foldedCompactionSummary = record.summary;
87134
+ if (buffered) {
87135
+ replayedRecords.push(migratedRecord);
86687
87136
  continue;
86688
87137
  }
86689
- this.restore(record);
86690
- if (record.type === "context.snapshot" && foldedCompactionSummary !== void 0) {
86691
- recoverMemosFromCompactionSummary(this.agent, foldedCompactionSummary);
86692
- foldedCompactionSummary = void 0;
87138
+ this.restore(migratedRecord);
87139
+ }
87140
+ if (buffered) {
87141
+ let snapshotIndex = -1;
87142
+ for (let i = replayedRecords.length - 1; i >= 0; i--) if (replayedRecords[i]?.type === "context.snapshot") {
87143
+ snapshotIndex = i;
87144
+ break;
87145
+ }
87146
+ let foldedCompactionSummary;
87147
+ for (let i = 0; i < replayedRecords.length; i++) {
87148
+ const record = replayedRecords[i];
87149
+ if (!record) continue;
87150
+ if (i < snapshotIndex && isSnapshotFoldedContextRecord(record.type)) {
87151
+ if (record.type === "context.apply_compaction") foldedCompactionSummary = record.summary;
87152
+ continue;
87153
+ }
87154
+ this.restore(record);
87155
+ if (record.type === "context.snapshot" && foldedCompactionSummary !== void 0) {
87156
+ recoverMemosFromCompactionSummary(this.agent, foldedCompactionSummary);
87157
+ foldedCompactionSummary = void 0;
87158
+ }
86693
87159
  }
86694
87160
  }
86695
87161
  this.agent.context.dropVacuousOpenMessages();
86696
87162
  if (shouldRewrite) {
86697
87163
  this.persistence.rewrite(replayedRecords);
86698
87164
  await this.persistence.flush();
86699
- }
87165
+ } else if (this.persistence.shouldCompactOnResume?.() === true && this.persistence.compact !== void 0) await this.persistence.compact();
86700
87166
  if (this.agent.blobStore !== void 0) for (const msg of this.agent.context.history) await this.agent.blobStore.rehydrateParts(msg.content);
86701
87167
  return { warning };
86702
87168
  }
@@ -86723,6 +87189,8 @@ var ReplayBuilder = class {
86723
87189
  records = [];
86724
87190
  /** Indices (into `records`) of user-turn-start records, oldest first. */
86725
87191
  userTurnStarts = [];
87192
+ /** Index (into `records`) of the partial-draft record per turn, if present. */
87193
+ partialDraftIndices = /* @__PURE__ */ new Map();
86726
87194
  constructor(agent) {
86727
87195
  this.agent = agent;
86728
87196
  }
@@ -86735,6 +87203,43 @@ var ReplayBuilder = class {
86735
87203
  const dropBefore = this.userTurnStarts[this.userTurnStarts.length - 10];
86736
87204
  this.records.splice(0, dropBefore);
86737
87205
  this.userTurnStarts = this.userTurnStarts.slice(-10).map((i) => i - dropBefore);
87206
+ for (const [turnId, index] of this.partialDraftIndices) {
87207
+ const shifted = index - dropBefore;
87208
+ if (shifted < 0) this.partialDraftIndices.delete(turnId);
87209
+ else this.partialDraftIndices.set(turnId, shifted);
87210
+ }
87211
+ }
87212
+ /**
87213
+ * Track the latest surviving stream draft for a turn during restore: the
87214
+ * first draft pushes a `stream_draft_partial` record, later drafts replace
87215
+ * it in place (no index shifts), and a clearing draft (empty text+think)
87216
+ * removes it. Only used while `agent.records.restoring` is true.
87217
+ */
87218
+ replacePartialDraft(turnId, draft) {
87219
+ if (!this.agent.records.restoring) return;
87220
+ const existing = this.partialDraftIndices.get(turnId);
87221
+ if (draft.text.length === 0 && draft.think.length === 0) {
87222
+ if (existing !== void 0) {
87223
+ this.records.splice(existing, 1);
87224
+ this.partialDraftIndices.delete(turnId);
87225
+ for (const [id, index] of this.partialDraftIndices) if (index > existing) this.partialDraftIndices.set(id, index - 1);
87226
+ this.userTurnStarts = this.userTurnStarts.map((i) => i > existing ? i - 1 : i);
87227
+ }
87228
+ return;
87229
+ }
87230
+ const record = {
87231
+ type: "stream_draft_partial",
87232
+ turnId,
87233
+ text: draft.text,
87234
+ think: draft.think
87235
+ };
87236
+ if (existing !== void 0 && this.records[existing]?.type === "stream_draft_partial") {
87237
+ this.records[existing] = record;
87238
+ return;
87239
+ }
87240
+ if (existing !== void 0) this.partialDraftIndices.delete(turnId);
87241
+ this.records.push(record);
87242
+ this.partialDraftIndices.set(turnId, this.records.length - 1);
86738
87243
  }
86739
87244
  buildResult() {
86740
87245
  return this.records;
@@ -95041,7 +95546,8 @@ async function executeLoopStep(deps) {
95041
95546
  dispatchEvent,
95042
95547
  turnId,
95043
95548
  currentStep,
95044
- stepUuid
95549
+ stepUuid,
95550
+ onStreamingDraft: deps.onStreamingDraft ?? (() => {})
95045
95551
  })
95046
95552
  };
95047
95553
  let response;
@@ -95175,18 +95681,75 @@ function stepEndProviderDiagnostics(response, stopReason) {
95175
95681
  ...response.rawFinishReason !== void 0 ? { rawFinishReason: response.rawFinishReason } : {}
95176
95682
  };
95177
95683
  }
95684
+ /**
95685
+ * Accumulates the in-flight stream for the crash-recovery draft and flushes
95686
+ * it through `onDraft` at most once per 1.5s or 4k new characters, so a fast
95687
+ * stream does not write the wire on every delta while a slow stream still
95688
+ * checkpoints promptly. Failures in the sink never break the stream.
95689
+ */
95690
+ var StreamDraftTracker = class {
95691
+ onDraft;
95692
+ text = "";
95693
+ think = "";
95694
+ charsSinceFlush = 0;
95695
+ lastFlushAt = Date.now();
95696
+ wireHasDraft = false;
95697
+ constructor(onDraft) {
95698
+ this.onDraft = onDraft;
95699
+ }
95700
+ onText(delta) {
95701
+ this.text += delta;
95702
+ this.charsSinceFlush += delta.length;
95703
+ this.maybeFlush();
95704
+ }
95705
+ onThink(delta) {
95706
+ this.think += delta;
95707
+ this.charsSinceFlush += delta.length;
95708
+ this.maybeFlush();
95709
+ }
95710
+ /**
95711
+ * Real content parts are landing: drop the draft (persisted as empty) — but
95712
+ * only if the wire actually carries one. A stream that finished before any
95713
+ * draft flush (fast mock streams, short replies) must not pollute the wire
95714
+ * with an empty clear record.
95715
+ */
95716
+ clear() {
95717
+ if (!this.wireHasDraft) return;
95718
+ this.text = "";
95719
+ this.think = "";
95720
+ this.charsSinceFlush = 0;
95721
+ this.flush();
95722
+ }
95723
+ maybeFlush() {
95724
+ if (Date.now() - this.lastFlushAt < DRAFT_FLUSH_INTERVAL_MS && this.charsSinceFlush < DRAFT_FLUSH_MIN_CHARS) return;
95725
+ this.flush();
95726
+ }
95727
+ flush() {
95728
+ this.lastFlushAt = Date.now();
95729
+ this.charsSinceFlush = 0;
95730
+ try {
95731
+ this.onDraft(this.text, this.think);
95732
+ this.wireHasDraft = this.text.length > 0 || this.think.length > 0;
95733
+ } catch {}
95734
+ }
95735
+ };
95736
+ const DRAFT_FLUSH_INTERVAL_MS = 1500;
95737
+ const DRAFT_FLUSH_MIN_CHARS = 4096;
95178
95738
  function createChatStreamingCallbacks(deps) {
95179
- const { dispatchEvent, turnId, currentStep, stepUuid } = deps;
95739
+ const { dispatchEvent, turnId, currentStep, stepUuid, onStreamingDraft } = deps;
95180
95740
  let textIndex = 0;
95181
95741
  let thinkIndex = 0;
95742
+ const draftTracker = new StreamDraftTracker(onStreamingDraft);
95182
95743
  return {
95183
95744
  onTextDelta: (delta) => {
95745
+ draftTracker.onText(delta);
95184
95746
  dispatchEvent({
95185
95747
  type: "text.delta",
95186
95748
  delta
95187
95749
  });
95188
95750
  },
95189
95751
  onThinkDelta: (delta) => {
95752
+ draftTracker.onThink(delta);
95190
95753
  dispatchEvent({
95191
95754
  type: "thinking.delta",
95192
95755
  delta
@@ -95202,6 +95765,7 @@ function createChatStreamingCallbacks(deps) {
95202
95765
  },
95203
95766
  onTextPart: async (part) => {
95204
95767
  const index = textIndex++;
95768
+ draftTracker.clear();
95205
95769
  await dispatchEvent({
95206
95770
  type: "block.start",
95207
95771
  uuid: randomUUID(),
@@ -95231,6 +95795,7 @@ function createChatStreamingCallbacks(deps) {
95231
95795
  },
95232
95796
  onThinkPart: async (part) => {
95233
95797
  const index = thinkIndex++;
95798
+ draftTracker.clear();
95234
95799
  await dispatchEvent({
95235
95800
  type: "block.start",
95236
95801
  uuid: randomUUID(),
@@ -95310,7 +95875,8 @@ async function runTurn(input) {
95310
95875
  maxRetryAttempts,
95311
95876
  recordUsage: recordStepUsage,
95312
95877
  hasPendingSteer: input.hasPendingSteer,
95313
- mediaProjection
95878
+ mediaProjection,
95879
+ onStreamingDraft: input.onStreamingDraft
95314
95880
  });
95315
95881
  activeStep = void 0;
95316
95882
  if (stepResult.totalCalls > 0) {
@@ -101542,6 +102108,15 @@ var TurnFlow = class {
101542
102108
  maxSteps: loopControl?.maxStepsPerTurn,
101543
102109
  maxRetryAttempts: loopControl?.maxRetriesPerStep,
101544
102110
  hasPendingSteer: () => this.steerBuffer.some((steer) => steer.origin.kind === "user" && steer.interrupt !== false),
102111
+ onStreamingDraft: (text, think) => {
102112
+ this.agent.records.logRecord({
102113
+ type: "context.stream_draft",
102114
+ turnId: String(turnId),
102115
+ text,
102116
+ think,
102117
+ time: Date.now()
102118
+ });
102119
+ },
101545
102120
  hooks: {
101546
102121
  beforeStep: async ({ signal: stepSignal, stepNumber }) => {
101547
102122
  this.flushSteerBuffer();
@@ -102219,6 +102794,8 @@ var Agent = class {
102219
102794
  /** Read-only manifest of the core engine subsystems (see {@link AgentServices}). */
102220
102795
  services;
102221
102796
  lastLlmConfigLogSignature;
102797
+ /** Last rendered system prompt written to a request.header record. */
102798
+ lastRequestHeaderSystemPrompt;
102222
102799
  sharedEmbeddingEngine;
102223
102800
  resolveRuntimeSystemPrompt;
102224
102801
  constructor(options) {
@@ -102523,12 +103100,14 @@ var Agent = class {
102523
103100
  for (const message of history) if (message.partial === true) partialMessageCount += 1;
102524
103101
  const requestMetadata = { estimatedInputTokens: estimateTokens$1(systemPrompt) + estimateTokensForMessages(history) + estimateTokensForTools(tools) };
102525
103102
  if (partialMessageCount > 0) requestMetadata.partialMessageCount = partialMessageCount;
103103
+ const systemPromptReused = systemPrompt === this.lastRequestHeaderSystemPrompt;
103104
+ this.lastRequestHeaderSystemPrompt = systemPrompt;
102526
103105
  this.records.logRecord({
102527
103106
  type: "request.header",
102528
103107
  provider: provider.name,
102529
103108
  model: provider.modelName,
102530
103109
  modelAlias: this.config.modelAlias ?? "",
102531
- systemPrompt,
103110
+ ...systemPromptReused ? { systemPromptReused: true } : { systemPrompt },
102532
103111
  activeTools: tools.map((t) => t.name),
102533
103112
  messagesCount: history.length,
102534
103113
  estimatedInputTokens: requestMetadata.estimatedInputTokens ?? 0
@@ -108049,7 +108628,7 @@ var Session$1 = class {
108049
108628
  parents: true,
108050
108629
  existOk: true
108051
108630
  });
108052
- await this.options.jian.writeText(this.metadataPath, text);
108631
+ await this.options.jian.writeTextAtomic(this.metadataPath, text);
108053
108632
  };
108054
108633
  this.writeMetadataPromise = this.writeMetadataPromise.then(() => write(), () => write());
108055
108634
  return this.writeMetadataPromise;
@@ -121686,7 +122265,7 @@ var LocalFetchURLProvider = class {
121686
122265
  * `confident` distinguishes detection strength: a convertible Content-Type
121687
122266
  * means conversion failure is a real error (corrupt document), while an
121688
122267
  * extension-only guess (e.g. an HTML viewer page at a .pdf URL) falls back
121689
- * to the normal text extraction path instead of erroring (omp's behavior).
122268
+ * to the normal text extraction path instead of erroring.
121690
122269
  */
121691
122270
  async fetchDocument(response, extension, contentType, confident) {
121692
122271
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -121797,7 +122376,7 @@ const RESULT_TITLE_RE$1 = /<a\b[^>]*\bclass="[^"]*\bresult__a\b[^"]*"[^>]*\bhref
121797
122376
  const RESULT_SNIPPET_RE$1 = /<(?:a|div|span)\b[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div|span)>/;
121798
122377
  /** Strip inline tags (DDG wraps query terms in `<b>`) and decode entities. */
121799
122378
  function decodeHtmlText$2(value) {
121800
- return value.replace(/<[^>]*>/g, " ").replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))).replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(Number.parseInt(code, 16))).replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, "\"").replace(/&#39;|&apos;/gi, "'").replace(/\s+/g, " ").trim();
122379
+ return value.replaceAll(/<[^>]*>/g, " ").replaceAll(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replaceAll(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))).replaceAll(/&nbsp;/gi, " ").replaceAll(/&amp;/gi, "&").replaceAll(/&lt;/gi, "<").replaceAll(/&gt;/gi, ">").replaceAll(/&quot;/gi, "\"").replaceAll(/&#39;|&apos;/gi, "'").replaceAll(/\s+/g, " ").trim();
121801
122380
  }
121802
122381
  /**
121803
122382
  * Resolve a DDG result href to the underlying target URL. DDG routes
@@ -121806,7 +122385,7 @@ function decodeHtmlText$2(value) {
121806
122385
  */
121807
122386
  function unwrapResultUrl(href) {
121808
122387
  if (href === "") return void 0;
121809
- const decoded = href.replace(/&amp;/gi, "&");
122388
+ const decoded = href.replaceAll(/&amp;/gi, "&");
121810
122389
  const wrapMatch = decoded.match(/[?&]uddg=([^&]+)/);
121811
122390
  if (wrapMatch?.[1] !== void 0) try {
121812
122391
  return decodeURIComponent(wrapMatch[1]);
@@ -122125,27 +122704,32 @@ function buildExportManifest(args) {
122125
122704
  }
122126
122705
  //#endregion
122127
122706
  //#region ../../packages/agent-core/src/session/export/wire-scan.ts
122707
+ /**
122708
+ * Scan a session's `wire.jsonl` for activity timestamps and the first user
122709
+ * input.
122710
+ *
122711
+ * The log is append-only and line-delimited, but a long-lived session can grow
122712
+ * it to gigabytes, so it is streamed rather than read into a single string: a
122713
+ * `StringDecoder` turns each chunk into text without splitting multi-byte
122714
+ * characters, complete lines are consumed as they arrive, and only the four
122715
+ * aggregated values are retained. Any read failure (including a missing file)
122716
+ * yields an empty scan.
122717
+ */
122128
122718
  async function scanSessionWire(sessionDir) {
122129
- let raw;
122130
- try {
122131
- raw = await readFile(join$1(sessionDir, "wire.jsonl"), "utf-8");
122132
- } catch {
122133
- return {};
122134
- }
122135
122719
  let firstActivityMs;
122136
122720
  let lastActivityMs;
122137
122721
  let lastUserMessageMs;
122138
122722
  let firstUserInput;
122139
- for (const line of raw.split("\n")) {
122723
+ const consumeLine = (line) => {
122140
122724
  const trimmed = line.trim();
122141
- if (trimmed.length === 0) continue;
122725
+ if (trimmed.length === 0) return;
122142
122726
  let parsed;
122143
122727
  try {
122144
122728
  parsed = JSON.parse(trimmed);
122145
122729
  } catch {
122146
- continue;
122730
+ return;
122147
122731
  }
122148
- if (typeof parsed !== "object" || parsed === null) continue;
122732
+ if (typeof parsed !== "object" || parsed === null) return;
122149
122733
  const record = parsed;
122150
122734
  const timeMs = typeof record.time === "number" ? normalizeTimestampMs(record.time) : void 0;
122151
122735
  if (timeMs !== void 0) {
@@ -122156,6 +122740,27 @@ async function scanSessionWire(sessionDir) {
122156
122740
  if (timeMs !== void 0) lastUserMessageMs = timeMs;
122157
122741
  if (firstUserInput === void 0 && typeof record.userInput === "string" && record.userInput.trim().length > 0) firstUserInput = record.userInput;
122158
122742
  }
122743
+ };
122744
+ try {
122745
+ const decoder = new StringDecoder("utf8");
122746
+ let pending = "";
122747
+ let scanned = 0;
122748
+ const stream = createReadStream(join$1(sessionDir, "wire.jsonl"));
122749
+ for await (const chunk of stream) {
122750
+ pending += decoder.write(chunk);
122751
+ let newlineIndex = pending.indexOf("\n", scanned);
122752
+ while (newlineIndex !== -1) {
122753
+ consumeLine(pending.slice(0, newlineIndex));
122754
+ pending = pending.slice(newlineIndex + 1);
122755
+ scanned = 0;
122756
+ newlineIndex = pending.indexOf("\n", scanned);
122757
+ }
122758
+ scanned = pending.length;
122759
+ }
122760
+ pending += decoder.end();
122761
+ if (pending.length > 0) consumeLine(pending);
122762
+ } catch {
122763
+ return {};
122159
122764
  }
122160
122765
  return {
122161
122766
  firstActivityMs,
@@ -123053,22 +123658,56 @@ async function writeExportZip(args) {
123053
123658
  await mkdir(dirname$2(args.outputPath), { recursive: true });
123054
123659
  const entries = ["manifest.json"];
123055
123660
  const zip = new import_yazl.ZipFile();
123661
+ const zipFailure = new Promise((_resolve, reject) => {
123662
+ zip.once("error", reject);
123663
+ });
123056
123664
  zip.addBuffer(Buffer.from(JSON.stringify(args.manifest, null, 2), "utf-8"), "manifest.json");
123057
123665
  for (const abs of args.sessionFiles) {
123058
123666
  const rel = relative$1(args.sessionDir, abs).split(/[\\/]/).join("/");
123059
- const data = await readFile(abs);
123060
- zip.addBuffer(data, rel);
123667
+ addStreamedFile(zip, abs, rel);
123061
123668
  entries.push(rel);
123062
123669
  }
123063
- for (const extra of args.extraEntries ?? []) try {
123064
- const data = "data" in extra ? extra.data : await readFile(extra.source);
123065
- zip.addBuffer(data, extra.target);
123670
+ for (const extra of args.extraEntries ?? []) {
123671
+ if ("data" in extra) {
123672
+ zip.addBuffer(extra.data, extra.target);
123673
+ entries.push(extra.target);
123674
+ continue;
123675
+ }
123676
+ try {
123677
+ if (!(await stat(extra.source)).isFile()) continue;
123678
+ } catch {
123679
+ continue;
123680
+ }
123681
+ addStreamedFile(zip, extra.source, extra.target);
123066
123682
  entries.push(extra.target);
123067
- } catch {}
123068
- zip.end();
123069
- await pipeline$1(zip.outputStream, createWriteStream$1(args.outputPath));
123683
+ }
123684
+ try {
123685
+ zip.end();
123686
+ await Promise.race([pipeline$1(zip.outputStream, createWriteStream$1(args.outputPath)), zipFailure]);
123687
+ } catch (error) {
123688
+ await rm(args.outputPath, { force: true }).catch(() => {});
123689
+ throw error;
123690
+ }
123070
123691
  return entries;
123071
123692
  }
123693
+ /**
123694
+ * Add a file as a lazily-streamed zip entry.
123695
+ *
123696
+ * `ZipFile.addFile` records the size from a stat and then asserts the byte count
123697
+ * while reading, so exporting a session that is still appending to its wire or
123698
+ * log files — or that rotates them mid-export — would abort the whole export.
123699
+ * Reading lazily without a declared size keeps the entry tolerant of growth,
123700
+ * opens one file at a time, and still never holds the file in memory.
123701
+ * (`addReadStreamLazy` is the entry point yazl's own `addFile` uses; the
123702
+ * published typings just do not declare it.)
123703
+ */
123704
+ function addStreamedFile(zip, source, target) {
123705
+ zip.addReadStreamLazy(target, (callback) => {
123706
+ const stream = createReadStream(source);
123707
+ stream.on("error", (error) => zip.emit("error", error));
123708
+ callback(null, stream);
123709
+ });
123710
+ }
123072
123711
  //#endregion
123073
123712
  //#region ../../packages/agent-core/src/session/export/session-export.ts
123074
123713
  const SESSION_LOG_REL = "logs/scream-code.log";
@@ -123086,10 +123725,9 @@ async function exportSessionDirectory(input) {
123086
123725
  let bundledGlobal = false;
123087
123726
  const globalPath = input.globalLogPath ?? (input.homeDir === void 0 ? void 0 : resolveGlobalLogPath(input.homeDir));
123088
123727
  if (input.request.includeGlobalLog === true && globalPath !== void 0) {
123089
- const data = await readOptionalFile(globalPath);
123090
- if (data !== void 0) {
123728
+ if (await pathIsFile(globalPath)) {
123091
123729
  extras.push({
123092
- data,
123730
+ source: globalPath,
123093
123731
  target: GLOBAL_LOG_REL
123094
123732
  });
123095
123733
  bundledGlobal = true;
@@ -123119,11 +123757,11 @@ async function exportSessionDirectory(input) {
123119
123757
  manifest
123120
123758
  };
123121
123759
  }
123122
- async function readOptionalFile(path) {
123760
+ async function pathIsFile(path) {
123123
123761
  try {
123124
- return await readFile(path);
123762
+ return (await stat(path)).isFile();
123125
123763
  } catch {
123126
- return;
123764
+ return false;
123127
123765
  }
123128
123766
  }
123129
123767
  //#endregion
@@ -123609,6 +124247,10 @@ function hasCustomTitle(metadata) {
123609
124247
  }
123610
124248
  //#endregion
123611
124249
  //#region ../../packages/agent-core/src/session/store/session-index.ts
124250
+ let sessionIndexCache;
124251
+ function invalidateSessionIndexCache() {
124252
+ sessionIndexCache = void 0;
124253
+ }
123612
124254
  function sessionIndexPath(homeDir) {
123613
124255
  return join$1(homeDir, "session_index.jsonl");
123614
124256
  }
@@ -123619,12 +124261,24 @@ async function appendSessionIndexEntry(homeDir, entry) {
123619
124261
  mode: 448
123620
124262
  });
123621
124263
  await appendFile(indexPath, `${JSON.stringify(entry)}\n`, "utf-8");
124264
+ invalidateSessionIndexCache();
123622
124265
  }
123623
124266
  async function readSessionIndex(homeDir, sessionsDir) {
124267
+ const indexPath = sessionIndexPath(homeDir);
124268
+ let stats;
124269
+ try {
124270
+ stats = await stat(indexPath);
124271
+ } catch {
124272
+ sessionIndexCache = void 0;
124273
+ return /* @__PURE__ */ new Map();
124274
+ }
124275
+ const cached = sessionIndexCache;
124276
+ if (cached !== void 0 && cached.homeDir === homeDir && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.map;
123624
124277
  let raw;
123625
124278
  try {
123626
- raw = await readFile(sessionIndexPath(homeDir), "utf-8");
124279
+ raw = await readFile(indexPath, "utf-8");
123627
124280
  } catch {
124281
+ sessionIndexCache = void 0;
123628
124282
  return /* @__PURE__ */ new Map();
123629
124283
  }
123630
124284
  const result = /* @__PURE__ */ new Map();
@@ -123644,6 +124298,12 @@ async function readSessionIndex(homeDir, sessionsDir) {
123644
124298
  workDir: resolve$2(entry.workDir)
123645
124299
  });
123646
124300
  }
124301
+ sessionIndexCache = {
124302
+ homeDir,
124303
+ mtimeMs: stats.mtimeMs,
124304
+ size: stats.size,
124305
+ map: result
124306
+ };
123647
124307
  return result;
123648
124308
  }
123649
124309
  async function removeSessionIndexEntry(homeDir, sessionId) {
@@ -123665,6 +124325,7 @@ async function removeSessionIndexEntry(homeDir, sessionId) {
123665
124325
  }
123666
124326
  if (kept.length === 0) await writeFile(indexPath, "", "utf-8");
123667
124327
  else await writeFile(indexPath, kept.join("\n") + "\n", "utf-8");
124328
+ invalidateSessionIndexCache();
123668
124329
  }
123669
124330
  function parseIndexLine(line) {
123670
124331
  try {
@@ -124628,6 +125289,7 @@ var LocalProcess = class {
124628
125289
  return Promise.resolve();
124629
125290
  }
124630
125291
  };
125292
+ let atomicWriteCounter = 0;
124631
125293
  /**
124632
125294
  * A JIAN implementation that directly interacts with the local filesystem.
124633
125295
  *
@@ -124876,6 +125538,14 @@ var LocalJian = class LocalJian {
124876
125538
  else await writeFile(resolved, data, encoding);
124877
125539
  return data.length;
124878
125540
  }
125541
+ async writeTextAtomic(path, data, options) {
125542
+ const resolved = this._resolvePath(path);
125543
+ const encoding = options?.encoding ?? "utf-8";
125544
+ const tmpPath = `${resolved}.${process.pid}.${Date.now().toString(36)}.${(atomicWriteCounter++).toString(36)}.tmp`;
125545
+ await writeFile(tmpPath, data, encoding);
125546
+ await rename(tmpPath, resolved);
125547
+ return data.length;
125548
+ }
124879
125549
  async mkdir(path, options) {
124880
125550
  const resolved = this._resolvePath(path);
124881
125551
  const parents = options?.parents ?? false;
@@ -129564,8 +130234,6 @@ function contentPartsText(parts) {
129564
130234
  * Throws when the file is missing or contains no usable records.
129565
130235
  */
129566
130236
  function buildTraceCells({ wirePath }) {
129567
- const rows = readWireRows(wirePath);
129568
- if (rows.length === 0) throw new Error(`no wire records in ${wirePath}`);
129569
130237
  const cells = [];
129570
130238
  let lastTime;
129571
130239
  let nextIndex = 1;
@@ -129583,6 +130251,11 @@ function buildTraceCells({ wirePath }) {
129583
130251
  startedAt: time,
129584
130252
  ...fields
129585
130253
  };
130254
+ cell.text = truncateDetail(cell.text, MAX_TEXT) ?? "";
130255
+ if (cell.thinkingDetail) cell.thinkingDetail = truncateDetail(cell.thinkingDetail);
130256
+ if (cell.outputDetail) cell.outputDetail = truncateDetail(cell.outputDetail);
130257
+ if (cell.inputDetail) cell.inputDetail = truncateDetail(cell.inputDetail);
130258
+ if (cell.result) cell.result = truncateDetail(cell.result);
129586
130259
  cells.push(cell);
129587
130260
  lastCell = cell;
129588
130261
  return cell;
@@ -129746,99 +130419,102 @@ function buildTraceCells({ wirePath }) {
129746
130419
  default: break;
129747
130420
  }
129748
130421
  };
129749
- for (const { seq, time, record } of rows) switch (asString(record["type"])) {
129750
- case "context.append_loop_event": {
129751
- const event = asRecord(record["event"]);
129752
- if (!event) break;
129753
- handleLoopEvent(event, time, seq);
129754
- break;
129755
- }
129756
- case "turn.prompt": {
129757
- finalizeStep(time);
129758
- turnNo += 1;
129759
- flushPendingSystem(time);
129760
- const input = record["input"];
129761
- const text = contentPartsText(input).trim();
129762
- pushCell("user", text.replaceAll(/\s+/g, " ").slice(0, 80) || "(空输入)", {
129763
- opensTurn: true,
129764
- inputDetail: text || void 0,
129765
- sourceSeq: seq
129766
- }, time);
129767
- currentTurnStart = time;
129768
- break;
129769
- }
129770
- case "turn.steer": {
129771
- const input = record["input"];
129772
- const text = contentPartsText(input).trim();
129773
- pushCell("context", `转向: ${text.replaceAll(/\s+/g, " ").slice(0, 80)}`, {
129774
- inputDetail: text || void 0,
129775
- sourceSeq: seq
129776
- }, time);
129777
- break;
129778
- }
129779
- case "request.header": {
129780
- const provider = asString(record["provider"]) ?? "";
129781
- const model = asString(record["model"]) ?? "";
129782
- const tools = asRecordArray(record["activeTools"]).map((t) => asString(t["name"]) ?? "");
129783
- pushCell("system", `请求 ${provider ? `${provider}/` : ""}${model}`, {
129784
- requestOnly: true,
129785
- inputDetail: tools.length > 0 ? `工具: ${tools.join(", ")}` : void 0,
129786
- sourceSeq: seq
129787
- }, time);
129788
- break;
129789
- }
129790
- case "tools.set_active_tools": {
129791
- const names = asStringArray(record["names"]).length > 0 ? asStringArray(record["names"]) : asRecordArray(record["names"]).map((n) => asString(n["name"]) ?? "");
129792
- pendingSystem.push(`工具集: ${names.join(", ")}`);
129793
- lastSystemTime = time;
129794
- break;
129795
- }
129796
- case "config.update": {
129797
- const cfg = asRecord(record);
129798
- const bits = [];
129799
- if (asString(cfg?.["modelAlias"])) bits.push(`模型别名: ${cfg["modelAlias"]}`);
129800
- if (asString(cfg?.["systemPrompt"])) bits.push("系统提示词已更新");
129801
- if (bits.length === 0) break;
129802
- pendingSystem.push(bits.join(" · "));
129803
- lastSystemTime = time;
129804
- break;
129805
- }
129806
- case "usage.record":
129807
- if (currentStepUuid === void 0) {
129808
- const usage = asRecord(record["usage"]);
129809
- pushCell("context", "usage", {
129810
- input: asNumber(usage?.["inputOther"]),
129811
- cacheRead: asNumber(usage?.["inputCacheRead"]),
129812
- cacheWrite: asNumber(usage?.["inputCacheCreation"]),
129813
- output: asNumber(usage?.["output"]),
130422
+ const consumeRow = ({ seq, time, record }) => {
130423
+ switch (asString(record["type"])) {
130424
+ case "context.append_loop_event": {
130425
+ const event = asRecord(record["event"]);
130426
+ if (!event) break;
130427
+ handleLoopEvent(event, time, seq);
130428
+ break;
130429
+ }
130430
+ case "turn.prompt": {
130431
+ finalizeStep(time);
130432
+ turnNo += 1;
130433
+ flushPendingSystem(time);
130434
+ const input = record["input"];
130435
+ const text = contentPartsText(input).trim();
130436
+ pushCell("user", text.replaceAll(/\s+/g, " ").slice(0, 80) || "(空输入)", {
130437
+ opensTurn: true,
130438
+ inputDetail: text || void 0,
130439
+ sourceSeq: seq
130440
+ }, time);
130441
+ currentTurnStart = time;
130442
+ break;
130443
+ }
130444
+ case "turn.steer": {
130445
+ const input = record["input"];
130446
+ const text = contentPartsText(input).trim();
130447
+ pushCell("context", `转向: ${text.replaceAll(/\s+/g, " ").slice(0, 80)}`, {
130448
+ inputDetail: text || void 0,
129814
130449
  sourceSeq: seq
129815
130450
  }, time);
130451
+ break;
129816
130452
  }
129817
- break;
129818
- case "full_compaction.begin": {
129819
- finalizeStep(time);
129820
- const reason = asString(record["reason"]);
129821
- const instruction = asString(record["instruction"]);
129822
- const source = asString(record["source"]);
129823
- pushCell("compacted", `压缩上下文${reason ? `(${reason})` : ""}`, {
129824
- sourceSeq: seq,
129825
- startedAt: currentTurnStart,
129826
- inputDetail: instruction || void 0,
129827
- result: source ? `来源: ${source}` : void 0
129828
- }, time);
129829
- break;
129830
- }
129831
- case "micro_compaction.apply": {
129832
- finalizeStep(time);
129833
- const reason = asString(record["reason"]);
129834
- pushCell("compacted", `微压缩${reason ? `(${reason})` : ""}`, {
129835
- sourceSeq: seq,
129836
- startedAt: currentTurnStart
129837
- }, time);
129838
- break;
130453
+ case "request.header": {
130454
+ const provider = asString(record["provider"]) ?? "";
130455
+ const model = asString(record["model"]) ?? "";
130456
+ const tools = asRecordArray(record["activeTools"]).map((t) => asString(t["name"]) ?? "");
130457
+ pushCell("system", `请求 ${provider ? `${provider}/` : ""}${model}`, {
130458
+ requestOnly: true,
130459
+ inputDetail: tools.length > 0 ? `工具: ${tools.join(", ")}` : void 0,
130460
+ sourceSeq: seq
130461
+ }, time);
130462
+ break;
130463
+ }
130464
+ case "tools.set_active_tools": {
130465
+ const names = asStringArray(record["names"]).length > 0 ? asStringArray(record["names"]) : asRecordArray(record["names"]).map((n) => asString(n["name"]) ?? "");
130466
+ pendingSystem.push(`工具集: ${names.join(", ")}`);
130467
+ lastSystemTime = time;
130468
+ break;
130469
+ }
130470
+ case "config.update": {
130471
+ const cfg = asRecord(record);
130472
+ const bits = [];
130473
+ if (asString(cfg?.["modelAlias"])) bits.push(`模型别名: ${cfg["modelAlias"]}`);
130474
+ if (asString(cfg?.["systemPrompt"])) bits.push("系统提示词已更新");
130475
+ if (bits.length === 0) break;
130476
+ pendingSystem.push(bits.join(" · "));
130477
+ lastSystemTime = time;
130478
+ break;
130479
+ }
130480
+ case "usage.record":
130481
+ if (currentStepUuid === void 0) {
130482
+ const usage = asRecord(record["usage"]);
130483
+ pushCell("context", "usage", {
130484
+ input: asNumber(usage?.["inputOther"]),
130485
+ cacheRead: asNumber(usage?.["inputCacheRead"]),
130486
+ cacheWrite: asNumber(usage?.["inputCacheCreation"]),
130487
+ output: asNumber(usage?.["output"]),
130488
+ sourceSeq: seq
130489
+ }, time);
130490
+ }
130491
+ break;
130492
+ case "full_compaction.begin": {
130493
+ finalizeStep(time);
130494
+ const reason = asString(record["reason"]);
130495
+ const instruction = asString(record["instruction"]);
130496
+ const source = asString(record["source"]);
130497
+ pushCell("compacted", `压缩上下文${reason ? `(${reason})` : ""}`, {
130498
+ sourceSeq: seq,
130499
+ startedAt: currentTurnStart,
130500
+ inputDetail: instruction || void 0,
130501
+ result: source ? `来源: ${source}` : void 0
130502
+ }, time);
130503
+ break;
130504
+ }
130505
+ case "micro_compaction.apply": {
130506
+ finalizeStep(time);
130507
+ const reason = asString(record["reason"]);
130508
+ pushCell("compacted", `微压缩${reason ? `(${reason})` : ""}`, {
130509
+ sourceSeq: seq,
130510
+ startedAt: currentTurnStart
130511
+ }, time);
130512
+ break;
130513
+ }
130514
+ default: break;
129839
130515
  }
129840
- default: break;
129841
- }
130516
+ };
130517
+ if (forEachWireRow(wirePath, consumeRow) === 0) throw new Error(`no wire records in ${wirePath}`);
129842
130518
  finalizeStep(void 0);
129843
130519
  flushPendingSystem(void 0);
129844
130520
  return capTraceSize(cells);
@@ -129847,25 +130523,20 @@ function buildTraceCells({ wirePath }) {
129847
130523
  * Long sessions produce tens of thousands of cells with multi-MB detail
129848
130524
  * payloads, which previously bloated the trace HTML (up to ~60MB) and froze
129849
130525
  * the browser. Two mitigations, applied at build time:
129850
- * 1. Truncate per-cell detail text (thinking/output/input/result) to a cap.
130526
+ * 1. Truncate per-cell detail text (thinking/output/input/result) to a cap
130527
+ * done in `pushCell`, so the raw payload never outlives the cell.
129851
130528
  * 2. Beyond a cell-count cap, collapse the oldest cells into per-turn
129852
130529
  * summary rows so the document stays bounded while early turns remain
129853
130530
  * visible in the ledger.
129854
130531
  */
129855
130532
  const MAX_DETAIL = 4e3;
130533
+ const MAX_TEXT = 240;
129856
130534
  const MAX_CELLS = 4e3;
129857
130535
  function truncateDetail(value, max = MAX_DETAIL) {
129858
130536
  if (!value || value.length <= max) return value;
129859
130537
  return `${value.slice(0, max)}\n…[已截断 ${value.length - max} 字符]`;
129860
130538
  }
129861
130539
  function capTraceSize(cells) {
129862
- for (const cell of cells) {
129863
- cell.text = truncateDetail(cell.text, 240) ?? "";
129864
- if (cell.thinkingDetail) cell.thinkingDetail = truncateDetail(cell.thinkingDetail);
129865
- if (cell.outputDetail) cell.outputDetail = truncateDetail(cell.outputDetail);
129866
- if (cell.inputDetail) cell.inputDetail = truncateDetail(cell.inputDetail);
129867
- if (cell.result) cell.result = truncateDetail(cell.result);
129868
- }
129869
130540
  if (cells.length <= MAX_CELLS) return cells;
129870
130541
  const keep = cells.slice(-4e3);
129871
130542
  const early = cells.slice(0, cells.length - MAX_CELLS);
@@ -129884,25 +130555,67 @@ function capTraceSize(cells) {
129884
130555
  startedAt: early.find((c) => c.startedAt !== void 0 && c.startedAt !== null)?.startedAt ?? null
129885
130556
  }, ...keep];
129886
130557
  }
129887
- function readWireRows(wirePath) {
129888
- const content = readFileSync(wirePath, "utf8");
129889
- const rows = [];
130558
+ /** Bytes read per `readSync` call while scanning a wire log. */
130559
+ const WIRE_READ_CHUNK_BYTES = 1024 * 1024;
130560
+ /**
130561
+ * Stream a wire log row by row without materializing the file.
130562
+ *
130563
+ * The log is append-only and line-delimited, but a long-lived session can grow
130564
+ * it to gigabytes, so the content is never held as a single string: fixed-size
130565
+ * chunks are read into one reusable buffer and split on "\n" (0x0A) at byte
130566
+ * level. Only the currently open line is carried across reads (as a small
130567
+ * copied buffer, because the chunk buffer is reused), and each parsed row is
130568
+ * handed to `onRow` and then dropped — neither the decoded file nor a
130569
+ * parsed-record array lives beyond the line being processed.
130570
+ *
130571
+ * Returns the number of parsed rows. Throws when the file cannot be opened;
130572
+ * callers rely on that to tell a missing session log apart from an empty one.
130573
+ */
130574
+ function forEachWireRow(wirePath, onRow) {
129890
130575
  let seq = 0;
129891
- for (const line of content.split("\n")) {
129892
- if (!line.trim()) continue;
130576
+ let parsedRows = 0;
130577
+ const pushLine = (line) => {
130578
+ if (!line.trim()) return;
129893
130579
  seq += 1;
130580
+ let parsed;
129894
130581
  try {
129895
- const rec = asRecord(JSON.parse(line));
129896
- if (!rec) continue;
129897
- const time = asNumber(rec["time"]);
129898
- rows.push({
129899
- seq,
129900
- time,
129901
- record: rec
129902
- });
129903
- } catch {}
130582
+ parsed = JSON.parse(line);
130583
+ } catch {
130584
+ return;
130585
+ }
130586
+ const rec = asRecord(parsed);
130587
+ if (!rec) return;
130588
+ onRow({
130589
+ seq,
130590
+ time: asNumber(rec["time"]),
130591
+ record: rec
130592
+ });
130593
+ parsedRows += 1;
130594
+ };
130595
+ const fd = openSync(wirePath, "r");
130596
+ try {
130597
+ const chunk = Buffer.allocUnsafe(WIRE_READ_CHUNK_BYTES);
130598
+ let openChunks = [];
130599
+ for (;;) {
130600
+ const bytesRead = readSync(fd, chunk, 0, chunk.length, null);
130601
+ if (bytesRead <= 0) break;
130602
+ const window = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead);
130603
+ let searchFrom = 0;
130604
+ for (;;) {
130605
+ const newlineIndex = window.indexOf(10, searchFrom);
130606
+ if (newlineIndex === -1) break;
130607
+ const tail = window.subarray(searchFrom, newlineIndex);
130608
+ pushLine(openChunks.length === 0 ? tail.toString("utf8") : Buffer.concat([...openChunks, tail]).toString("utf8"));
130609
+ openChunks = [];
130610
+ searchFrom = newlineIndex + 1;
130611
+ }
130612
+ if (searchFrom < window.length) openChunks.push(Buffer.from(window.subarray(searchFrom)));
130613
+ }
130614
+ if (openChunks.length > 0) pushLine(Buffer.concat(openChunks).toString("utf8"));
130615
+ } finally {
130616
+ closeSync(fd);
129904
130617
  }
129905
- return rows;
130618
+ return parsedRows;
129906
130619
  }
129907
130620
  //#endregion
129908
130621
  //#region src/utils/trace/render-trace-html.ts
@@ -131800,6 +132513,7 @@ const darkColors = {
131800
132513
  mdCodeBlockBorder: "#5C6370",
131801
132514
  mdCodeBlockBg: "#16191f",
131802
132515
  mdQuote: "#7F848E",
132516
+ mdHeading: "#FFC35C",
131803
132517
  border: dark.gray700,
131804
132518
  borderFocus: dark.yellowGreenLight,
131805
132519
  success: dark.yellowGreen,
@@ -131834,6 +132548,7 @@ const lightColors = {
131834
132548
  mdCodeBlockBorder: "#848484",
131835
132549
  mdCodeBlockBg: "#f7f9fb",
131836
132550
  mdQuote: "#616161",
132551
+ mdHeading: "#8F6400",
131837
132552
  border: light.gray500,
131838
132553
  borderFocus: light.yellowGreen700,
131839
132554
  success: light.yellowGreen700,
@@ -132017,16 +132732,16 @@ const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/;
132017
132732
  * its DEFAULT_THEME.
132018
132733
  */
132019
132734
  /**
132020
- * Markdown code-block highlight theme: green-dominant mapping (keyword,
132021
- * function, built_in primary; strings success; numbers → warning;
132022
- * comments → textDim). Kept distinct from the shared preview theme in
132023
- * code-highlight-theme.ts on purpose — markdown code blocks use the green
132735
+ * Markdown code-block highlight theme: the fluorescent brand green is the
132736
+ * only green (keyword, function, built_in, diff additions); strings take the
132737
+ * heading amber; numbers → warning; comments → textDim. Kept distinct from
132738
+ * the shared preview theme in code-highlight-theme.ts on purpose.
132024
132739
  * primary hue, while file-preview panels use the classic blue/red/yellow
132025
132740
  * scheme mapped to the same palette. Both follow the active theme.
132026
132741
  */
132027
132742
  function createMarkdownCodeHighlightTheme(colors) {
132028
132743
  const keyword = chalk.hex(colors.primary);
132029
- const str = chalk.hex(colors.success);
132744
+ const str = chalk.hex(colors.mdHeading);
132030
132745
  const comment = chalk.hex(colors.textDim);
132031
132746
  const num = chalk.hex(colors.warning);
132032
132747
  const fn = chalk.hex(colors.primary);
@@ -132065,7 +132780,7 @@ function createMarkdownCodeHighlightTheme(colors) {
132065
132780
  formula: text,
132066
132781
  link: chalk.hex(colors.mdLink),
132067
132782
  quote: chalk.hex(colors.mdQuote),
132068
- addition: chalk.hex(colors.diffAdded),
132783
+ addition: keyword,
132069
132784
  deletion: chalk.hex(colors.diffRemoved),
132070
132785
  default: text
132071
132786
  };
@@ -132076,7 +132791,7 @@ function createMarkdownTheme(colors) {
132076
132791
  const border = chalk.hex(colors.border);
132077
132792
  const codeTheme = createMarkdownCodeHighlightTheme(colors);
132078
132793
  return {
132079
- heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)),
132794
+ heading: (text) => chalk.bold.hex(colors.mdHeading)(stripHash(text)),
132080
132795
  link: (text) => chalk.hex(colors.mdLink)(text),
132081
132796
  linkUrl: (text) => muted(text),
132082
132797
  code: (text) => chalk.hex(colors.primary)(text),
@@ -134054,7 +134769,7 @@ async function guidedGoalSetup(host) {
134054
134769
  host.showNotice(t("goal.storm_breaker"), t("goal.conflict_loop"));
134055
134770
  return;
134056
134771
  }
134057
- const { TextInputDialogComponent } = await import("./text-input-dialog-DAQfJdHu.mjs");
134772
+ const { TextInputDialogComponent } = await import("./text-input-dialog-CQ6R2lns.mjs");
134058
134773
  const initialDesc = await promptText(host, TextInputDialogComponent, {
134059
134774
  title: t("goal.setup_title_initial"),
134060
134775
  subtitle: t("goal.setup_desc_hint"),
@@ -134075,7 +134790,7 @@ async function guidedGoalSetup(host) {
134075
134790
  await showGoalConfigWizard(host, session, confirmed.trim() || objective, false);
134076
134791
  }
134077
134792
  async function showGoalConfigWizard(host, session, objective, replace) {
134078
- const { TextInputDialogComponent } = await import("./text-input-dialog-DAQfJdHu.mjs");
134793
+ const { TextInputDialogComponent } = await import("./text-input-dialog-CQ6R2lns.mjs");
134079
134794
  const turnInput = await promptNumber(host, TextInputDialogComponent, {
134080
134795
  title: t("goal.wizard_title", { objective }),
134081
134796
  subtitle: t("goal.budget_turns_hint"),
@@ -134989,10 +135704,9 @@ const ACTIVITY_LINE_SETTINGS = [
134989
135704
  key: "activityCollapsedLines",
134990
135705
  label: "blockrows.item_collapsed",
134991
135706
  values: [
134992
- 2,
134993
135707
  3,
134994
- 4,
134995
- 5
135708
+ 5,
135709
+ 8
134996
135710
  ],
134997
135711
  fallback: 3
134998
135712
  },
@@ -135233,10 +135947,6 @@ const CARD_MARKERS = [
135233
135947
  const CARD_MARKER_AT_START_RE = new RegExp(`^(?:\\u001b\\[[0-9;]*m)*(?:${CARD_MARKERS.join("|")}) `);
135234
135948
  const CARD_MARKER_CHAR_RE = new RegExp(`^(?:\\u001b\\[[0-9;]*m)*(${CARD_MARKERS.join("|")}) `);
135235
135949
  const CARD_INDENT_RE = /^(\u001B\[[0-9;]*m)*( {2})/;
135236
- /** Token count for a text block; empty text is zero, unlike the rate estimator. */
135237
- function countTokens(text) {
135238
- return text.trim().length === 0 ? 0 : estimateTokens(text);
135239
- }
135240
135950
  /** Non-empty, trimmed lines of one reasoning run. */
135241
135951
  function thinkingLines(text) {
135242
135952
  return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
@@ -135393,10 +136103,6 @@ var ActivityGroupComponent = class extends Container {
135393
136103
  spinnerFrame = 0;
135394
136104
  lastSpinnerTickAt = 0;
135395
136105
  renderedWidth;
135396
- /** Token estimates per borrowed card, keyed by the result text they counted. */
135397
- toolTokenCache = /* @__PURE__ */ new WeakMap();
135398
- /** Token estimates per reasoning run, keyed by the segment they counted. */
135399
- thinkingTokenCache = /* @__PURE__ */ new WeakMap();
135400
136106
  /** Reasoning text already shown when a notice interrupted the newest run. */
135401
136107
  interruptedThinking;
135402
136108
  constructor(colors, ui) {
@@ -135558,6 +136264,34 @@ var ActivityGroupComponent = class extends Container {
135558
136264
  if (this.expanded) this.buildExpandedRows(width);
135559
136265
  else this.buildCollapsedRows(width);
135560
136266
  }
136267
+ /**
136268
+ * Aggregate diff of the group's finished, successful file mutations
136269
+ * (Edit/Write), shown in the header. Read-only tools contribute nothing;
136270
+ * failed attempts changed nothing, so only successful calls count.
136271
+ * `removed` stays undefined when any contributor could not report its
136272
+ * deletions (replayed records carry no display payload): summing a partial
136273
+ * number would claim a deletion count the group never measured.
136274
+ */
136275
+ diffTotals() {
136276
+ let added = 0;
136277
+ let removed = 0;
136278
+ let removedKnown = true;
136279
+ let seen = false;
136280
+ for (const segment of this.segments) {
136281
+ if (segment.kind !== "tool") continue;
136282
+ const contribution = segment.tc.diffContribution();
136283
+ if (contribution === void 0) continue;
136284
+ seen = true;
136285
+ added += contribution.added;
136286
+ if (contribution.removed === void 0) removedKnown = false;
136287
+ else removed += contribution.removed;
136288
+ }
136289
+ if (!seen) return void 0;
136290
+ return {
136291
+ added,
136292
+ removed: removedKnown ? removed : void 0
136293
+ };
136294
+ }
135561
136295
  buildHeader(width) {
135562
136296
  const colors = this.colors;
135563
136297
  const frame = BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0];
@@ -135567,13 +136301,17 @@ var ActivityGroupComponent = class extends Container {
135567
136301
  const toolCount = this.toolCount();
135568
136302
  if (this.steps.size > 0) parts.push(t("activitygroup.steps", { count: String(this.steps.size) }));
135569
136303
  if (toolCount > 0) parts.push(t("activitygroup.tools", { count: String(toolCount) }));
135570
- parts.push(t("activitygroup.tokens", { tok: this.formatTokens(this.blockTokens()) }));
135571
136304
  const stats = chalk.dim(SEPARATOR + parts.join(SEPARATOR));
136305
+ const diff = this.diffTotals();
136306
+ const diffPart = diff ? SEPARATOR + (diff.removed === void 0 ? t("activitygroup.diffAddedOnly", { added: chalk.hex(colors.diffAdded)(`+${diff.added}`) }) : t("activitygroup.diff", {
136307
+ added: chalk.hex(colors.diffAdded)(`+${diff.added}`),
136308
+ removed: chalk.hex(colors.diffRemoved)(`-${diff.removed}`)
136309
+ })) : "";
135572
136310
  const speed = this.liveThinking() ? getSharedSpeedTracker().getSpeed() : 0;
135573
136311
  const rate = speed > MIN_RATE ? chalk.hex(lerpHex(colors.textDim, colors.primary, easeSpeedRatio(speed / 200)))(t("activitygroup.rate", { rate: speed.toFixed(1) })) : chalk.dim(t("activitygroup.rate", { rate: "-" }));
135574
136312
  const hint = chalk.dim(this.expanded ? t("activitygroup.hint_collapse") : t("activitygroup.hint_expand"));
135575
136313
  const head = `${marker}${chalk.hex(colors.primary).bold(label)}${stats}`;
135576
- const withRate = `${head}${rate}`;
136314
+ const withRate = `${head}${diffPart}${rate}`;
135577
136315
  const full = `${withRate}${hint}`;
135578
136316
  if (visibleWidth(full) <= width) return full;
135579
136317
  if (visibleWidth(withRate) <= width) return withRate;
@@ -135748,53 +136486,10 @@ var ActivityGroupComponent = class extends Container {
135748
136486
  const first = thinkingLines(segment.text)[0] ?? "";
135749
136487
  const label = t("activitygroup.thinking_summary", { summary: "" });
135750
136488
  const used = visibleWidth(isLast ? BRANCH_LAST : BRANCH_FIRST) + visibleWidth(label);
135751
- const cells = Math.max(1, width - used);
136489
+ const cells = Math.max(1, width - used - 1);
135752
136490
  const summary = chalk.hex(this.colors.roleThinking)(t("activitygroup.thinking_summary", { summary: truncateToWidth(first, cells, "…") }));
135753
136491
  return new Text(`${isLast ? BRANCH_LAST : BRANCH_FIRST}${summary}`, 0, 0);
135754
136492
  }
135755
- /**
135756
- * Token estimate for the whole block: the reasoning text plus every result the
135757
- * block owns, i.e. how much material this stretch of work moved through.
135758
- */
135759
- blockTokens() {
135760
- let tokens = 0;
135761
- for (const segment of this.segments) if (segment.kind === "tool") tokens += this.toolTokens(segment.tc);
135762
- else if (segment.kind === "thinking") tokens += this.thinkingTokens(segment);
135763
- return tokens;
135764
- }
135765
- /** Result tokens of one card, counted once per distinct result text. */
135766
- toolTokens(tc) {
135767
- const output = tc.resultView?.output ?? "";
135768
- const cached = this.toolTokenCache.get(tc);
135769
- if (cached !== void 0 && cached.output === output) return cached.tokens;
135770
- const tokens = countTokens(output);
135771
- this.toolTokenCache.set(tc, {
135772
- output,
135773
- tokens
135774
- });
135775
- return tokens;
135776
- }
135777
- /**
135778
- * Token estimate for the block's reasoning. Character-based and script-aware
135779
- * (CJK ≈ one token per char, Latin ≈ a quarter), using the same estimator as
135780
- * the streaming speed gauge so both numbers agree. Empty reasoning counts as
135781
- * zero: the estimator's floor of one token is for per-delta rates.
135782
- */
135783
- thinkingTokens(segment) {
135784
- const cached = this.thinkingTokenCache.get(segment);
135785
- if (cached !== void 0 && cached.text === segment.text) return cached.tokens;
135786
- const tokens = countTokens(segment.text);
135787
- this.thinkingTokenCache.set(segment, {
135788
- text: segment.text,
135789
- tokens
135790
- });
135791
- return tokens;
135792
- }
135793
- formatTokens(tokens) {
135794
- if (tokens < 1e3) return String(tokens);
135795
- if (tokens < 1e6) return `${(tokens / 1e3).toFixed(1)}K`;
135796
- return `${(tokens / 1e6).toFixed(1)}M`;
135797
- }
135798
136493
  scheduleSpinnerTick(delayMs) {
135799
136494
  if (this.ui === void 0) return;
135800
136495
  const timer = setTimeout(() => {
@@ -138018,6 +138713,43 @@ var ToolCallComponent = class ToolCallComponent extends CachedContainer {
138018
138713
  this.rebuildBody();
138019
138714
  this.notifySnapshotChange();
138020
138715
  }
138716
+ /**
138717
+ * File-mutation diff of this call (Edit additions/deletions, Write
138718
+ * additions), for the group-level diff stat in the activity block header.
138719
+ * Undefined for non-mutating tools, unfinished calls, and failed calls: a
138720
+ * failure changed nothing, so it must not count towards the group total.
138721
+ *
138722
+ * The mutation tools report their exact numbers (`display.file_diff`) computed
138723
+ * from the real before/after contents. Groups replayed from a resumed session
138724
+ * carry no such payload (the transcript keeps message text, not the UI
138725
+ * payload), so they fall back to deriving the numbers from the arguments — a
138726
+ * fallback that cannot know how many lines a Write replaced, so it reports
138727
+ * `removed: undefined` ("unknown") instead of claiming zero.
138728
+ */
138729
+ diffContribution() {
138730
+ if (this.result === void 0 || this.result.is_error) return void 0;
138731
+ const reported = this.result.display;
138732
+ if (reported?.kind === "file_diff") {
138733
+ if (reported.added === 0 && reported.removed === 0) return void 0;
138734
+ return {
138735
+ added: reported.added,
138736
+ removed: reported.removed
138737
+ };
138738
+ }
138739
+ if (this.toolCall.name === "Edit") {
138740
+ const stats = computeEditStats(this.toolCall.args);
138741
+ if (stats.added === 0 && stats.removed === 0) return void 0;
138742
+ return stats;
138743
+ }
138744
+ if (this.toolCall.name === "Write") {
138745
+ const stats = computeWriteStats(this.toolCall.args);
138746
+ if (stats.lines === 0) return void 0;
138747
+ return {
138748
+ added: stats.lines,
138749
+ removed: void 0
138750
+ };
138751
+ }
138752
+ }
138021
138753
  updateToolCall(toolCall) {
138022
138754
  this.toolCall = toolCall;
138023
138755
  this.syncStreamingProgressTimer();
@@ -138486,7 +139218,7 @@ var ToolCallComponent = class ToolCallComponent extends CachedContainer {
138486
139218
  if (provider === void 0) return "";
138487
139219
  const text = provider(this.toolCall, result);
138488
139220
  if (text.length === 0) return "";
138489
- return (result.is_error ? chalk.hex(this.colors.error) : chalk.dim)(` · ${text}`);
139221
+ return chalk.dim(` · ${text}`);
138490
139222
  }
138491
139223
  rebuildContent() {
138492
139224
  this.markDirty();