scream-code 0.16.4 → 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--J3ngWFv.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
- });
86459
+ if (!decided && headLength > 0) {
86460
+ decided = true;
86461
+ retaining = keepLine(lineNumber + 1);
86462
+ if (retaining) openChunks.push(Buffer.from(head.subarray(0, headLength)));
86149
86463
  }
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;
86155
- }
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
@@ -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 {
@@ -129573,8 +130234,6 @@ function contentPartsText(parts) {
129573
130234
  * Throws when the file is missing or contains no usable records.
129574
130235
  */
129575
130236
  function buildTraceCells({ wirePath }) {
129576
- const rows = readWireRows(wirePath);
129577
- if (rows.length === 0) throw new Error(`no wire records in ${wirePath}`);
129578
130237
  const cells = [];
129579
130238
  let lastTime;
129580
130239
  let nextIndex = 1;
@@ -129592,6 +130251,11 @@ function buildTraceCells({ wirePath }) {
129592
130251
  startedAt: time,
129593
130252
  ...fields
129594
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);
129595
130259
  cells.push(cell);
129596
130260
  lastCell = cell;
129597
130261
  return cell;
@@ -129755,99 +130419,102 @@ function buildTraceCells({ wirePath }) {
129755
130419
  default: break;
129756
130420
  }
129757
130421
  };
129758
- for (const { seq, time, record } of rows) switch (asString(record["type"])) {
129759
- case "context.append_loop_event": {
129760
- const event = asRecord(record["event"]);
129761
- if (!event) break;
129762
- handleLoopEvent(event, time, seq);
129763
- break;
129764
- }
129765
- case "turn.prompt": {
129766
- finalizeStep(time);
129767
- turnNo += 1;
129768
- flushPendingSystem(time);
129769
- const input = record["input"];
129770
- const text = contentPartsText(input).trim();
129771
- pushCell("user", text.replaceAll(/\s+/g, " ").slice(0, 80) || "(空输入)", {
129772
- opensTurn: true,
129773
- inputDetail: text || void 0,
129774
- sourceSeq: seq
129775
- }, time);
129776
- currentTurnStart = time;
129777
- break;
129778
- }
129779
- case "turn.steer": {
129780
- const input = record["input"];
129781
- const text = contentPartsText(input).trim();
129782
- pushCell("context", `转向: ${text.replaceAll(/\s+/g, " ").slice(0, 80)}`, {
129783
- inputDetail: text || void 0,
129784
- sourceSeq: seq
129785
- }, time);
129786
- break;
129787
- }
129788
- case "request.header": {
129789
- const provider = asString(record["provider"]) ?? "";
129790
- const model = asString(record["model"]) ?? "";
129791
- const tools = asRecordArray(record["activeTools"]).map((t) => asString(t["name"]) ?? "");
129792
- pushCell("system", `请求 ${provider ? `${provider}/` : ""}${model}`, {
129793
- requestOnly: true,
129794
- inputDetail: tools.length > 0 ? `工具: ${tools.join(", ")}` : void 0,
129795
- sourceSeq: seq
129796
- }, time);
129797
- break;
129798
- }
129799
- case "tools.set_active_tools": {
129800
- const names = asStringArray(record["names"]).length > 0 ? asStringArray(record["names"]) : asRecordArray(record["names"]).map((n) => asString(n["name"]) ?? "");
129801
- pendingSystem.push(`工具集: ${names.join(", ")}`);
129802
- lastSystemTime = time;
129803
- break;
129804
- }
129805
- case "config.update": {
129806
- const cfg = asRecord(record);
129807
- const bits = [];
129808
- if (asString(cfg?.["modelAlias"])) bits.push(`模型别名: ${cfg["modelAlias"]}`);
129809
- if (asString(cfg?.["systemPrompt"])) bits.push("系统提示词已更新");
129810
- if (bits.length === 0) break;
129811
- pendingSystem.push(bits.join(" · "));
129812
- lastSystemTime = time;
129813
- break;
129814
- }
129815
- case "usage.record":
129816
- if (currentStepUuid === void 0) {
129817
- const usage = asRecord(record["usage"]);
129818
- pushCell("context", "usage", {
129819
- input: asNumber(usage?.["inputOther"]),
129820
- cacheRead: asNumber(usage?.["inputCacheRead"]),
129821
- cacheWrite: asNumber(usage?.["inputCacheCreation"]),
129822
- 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,
129823
130439
  sourceSeq: seq
129824
130440
  }, time);
130441
+ currentTurnStart = time;
130442
+ break;
129825
130443
  }
129826
- break;
129827
- case "full_compaction.begin": {
129828
- finalizeStep(time);
129829
- const reason = asString(record["reason"]);
129830
- const instruction = asString(record["instruction"]);
129831
- const source = asString(record["source"]);
129832
- pushCell("compacted", `压缩上下文${reason ? `(${reason})` : ""}`, {
129833
- sourceSeq: seq,
129834
- startedAt: currentTurnStart,
129835
- inputDetail: instruction || void 0,
129836
- result: source ? `来源: ${source}` : void 0
129837
- }, time);
129838
- break;
129839
- }
129840
- case "micro_compaction.apply": {
129841
- finalizeStep(time);
129842
- const reason = asString(record["reason"]);
129843
- pushCell("compacted", `微压缩${reason ? `(${reason})` : ""}`, {
129844
- sourceSeq: seq,
129845
- startedAt: currentTurnStart
129846
- }, time);
129847
- break;
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,
130449
+ sourceSeq: seq
130450
+ }, time);
130451
+ break;
130452
+ }
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;
129848
130515
  }
129849
- default: break;
129850
- }
130516
+ };
130517
+ if (forEachWireRow(wirePath, consumeRow) === 0) throw new Error(`no wire records in ${wirePath}`);
129851
130518
  finalizeStep(void 0);
129852
130519
  flushPendingSystem(void 0);
129853
130520
  return capTraceSize(cells);
@@ -129856,25 +130523,20 @@ function buildTraceCells({ wirePath }) {
129856
130523
  * Long sessions produce tens of thousands of cells with multi-MB detail
129857
130524
  * payloads, which previously bloated the trace HTML (up to ~60MB) and froze
129858
130525
  * the browser. Two mitigations, applied at build time:
129859
- * 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.
129860
130528
  * 2. Beyond a cell-count cap, collapse the oldest cells into per-turn
129861
130529
  * summary rows so the document stays bounded while early turns remain
129862
130530
  * visible in the ledger.
129863
130531
  */
129864
130532
  const MAX_DETAIL = 4e3;
130533
+ const MAX_TEXT = 240;
129865
130534
  const MAX_CELLS = 4e3;
129866
130535
  function truncateDetail(value, max = MAX_DETAIL) {
129867
130536
  if (!value || value.length <= max) return value;
129868
130537
  return `${value.slice(0, max)}\n…[已截断 ${value.length - max} 字符]`;
129869
130538
  }
129870
130539
  function capTraceSize(cells) {
129871
- for (const cell of cells) {
129872
- cell.text = truncateDetail(cell.text, 240) ?? "";
129873
- if (cell.thinkingDetail) cell.thinkingDetail = truncateDetail(cell.thinkingDetail);
129874
- if (cell.outputDetail) cell.outputDetail = truncateDetail(cell.outputDetail);
129875
- if (cell.inputDetail) cell.inputDetail = truncateDetail(cell.inputDetail);
129876
- if (cell.result) cell.result = truncateDetail(cell.result);
129877
- }
129878
130540
  if (cells.length <= MAX_CELLS) return cells;
129879
130541
  const keep = cells.slice(-4e3);
129880
130542
  const early = cells.slice(0, cells.length - MAX_CELLS);
@@ -129893,25 +130555,67 @@ function capTraceSize(cells) {
129893
130555
  startedAt: early.find((c) => c.startedAt !== void 0 && c.startedAt !== null)?.startedAt ?? null
129894
130556
  }, ...keep];
129895
130557
  }
129896
- function readWireRows(wirePath) {
129897
- const content = readFileSync(wirePath, "utf8");
129898
- 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) {
129899
130575
  let seq = 0;
129900
- for (const line of content.split("\n")) {
129901
- if (!line.trim()) continue;
130576
+ let parsedRows = 0;
130577
+ const pushLine = (line) => {
130578
+ if (!line.trim()) return;
129902
130579
  seq += 1;
130580
+ let parsed;
129903
130581
  try {
129904
- const rec = asRecord(JSON.parse(line));
129905
- if (!rec) continue;
129906
- const time = asNumber(rec["time"]);
129907
- rows.push({
129908
- seq,
129909
- time,
129910
- record: rec
129911
- });
129912
- } 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);
129913
130617
  }
129914
- return rows;
130618
+ return parsedRows;
129915
130619
  }
129916
130620
  //#endregion
129917
130621
  //#region src/utils/trace/render-trace-html.ts
@@ -134065,7 +134769,7 @@ async function guidedGoalSetup(host) {
134065
134769
  host.showNotice(t("goal.storm_breaker"), t("goal.conflict_loop"));
134066
134770
  return;
134067
134771
  }
134068
- const { TextInputDialogComponent } = await import("./text-input-dialog-CNhL-3wm.mjs");
134772
+ const { TextInputDialogComponent } = await import("./text-input-dialog-CQ6R2lns.mjs");
134069
134773
  const initialDesc = await promptText(host, TextInputDialogComponent, {
134070
134774
  title: t("goal.setup_title_initial"),
134071
134775
  subtitle: t("goal.setup_desc_hint"),
@@ -134086,7 +134790,7 @@ async function guidedGoalSetup(host) {
134086
134790
  await showGoalConfigWizard(host, session, confirmed.trim() || objective, false);
134087
134791
  }
134088
134792
  async function showGoalConfigWizard(host, session, objective, replace) {
134089
- const { TextInputDialogComponent } = await import("./text-input-dialog-CNhL-3wm.mjs");
134793
+ const { TextInputDialogComponent } = await import("./text-input-dialog-CQ6R2lns.mjs");
134090
134794
  const turnInput = await promptNumber(host, TextInputDialogComponent, {
134091
134795
  title: t("goal.wizard_title", { objective }),
134092
134796
  subtitle: t("goal.budget_turns_hint"),
@@ -135564,10 +136268,14 @@ var ActivityGroupComponent = class extends Container {
135564
136268
  * Aggregate diff of the group's finished, successful file mutations
135565
136269
  * (Edit/Write), shown in the header. Read-only tools contribute nothing;
135566
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.
135567
136274
  */
135568
136275
  diffTotals() {
135569
136276
  let added = 0;
135570
136277
  let removed = 0;
136278
+ let removedKnown = true;
135571
136279
  let seen = false;
135572
136280
  for (const segment of this.segments) {
135573
136281
  if (segment.kind !== "tool") continue;
@@ -135575,12 +136283,14 @@ var ActivityGroupComponent = class extends Container {
135575
136283
  if (contribution === void 0) continue;
135576
136284
  seen = true;
135577
136285
  added += contribution.added;
135578
- removed += contribution.removed;
136286
+ if (contribution.removed === void 0) removedKnown = false;
136287
+ else removed += contribution.removed;
135579
136288
  }
135580
- return seen ? {
136289
+ if (!seen) return void 0;
136290
+ return {
135581
136291
  added,
135582
- removed
135583
- } : void 0;
136292
+ removed: removedKnown ? removed : void 0
136293
+ };
135584
136294
  }
135585
136295
  buildHeader(width) {
135586
136296
  const colors = this.colors;
@@ -135593,10 +136303,10 @@ var ActivityGroupComponent = class extends Container {
135593
136303
  if (toolCount > 0) parts.push(t("activitygroup.tools", { count: String(toolCount) }));
135594
136304
  const stats = chalk.dim(SEPARATOR + parts.join(SEPARATOR));
135595
136305
  const diff = this.diffTotals();
135596
- const diffPart = diff ? SEPARATOR + t("activitygroup.diff", {
136306
+ const diffPart = diff ? SEPARATOR + (diff.removed === void 0 ? t("activitygroup.diffAddedOnly", { added: chalk.hex(colors.diffAdded)(`+${diff.added}`) }) : t("activitygroup.diff", {
135597
136307
  added: chalk.hex(colors.diffAdded)(`+${diff.added}`),
135598
136308
  removed: chalk.hex(colors.diffRemoved)(`-${diff.removed}`)
135599
- }) : "";
136309
+ })) : "";
135600
136310
  const speed = this.liveThinking() ? getSharedSpeedTracker().getSpeed() : 0;
135601
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: "-" }));
135602
136312
  const hint = chalk.dim(this.expanded ? t("activitygroup.hint_collapse") : t("activitygroup.hint_expand"));
@@ -138008,9 +138718,24 @@ var ToolCallComponent = class ToolCallComponent extends CachedContainer {
138008
138718
  * additions), for the group-level diff stat in the activity block header.
138009
138719
  * Undefined for non-mutating tools, unfinished calls, and failed calls: a
138010
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.
138011
138728
  */
138012
138729
  diffContribution() {
138013
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
+ }
138014
138739
  if (this.toolCall.name === "Edit") {
138015
138740
  const stats = computeEditStats(this.toolCall.args);
138016
138741
  if (stats.added === 0 && stats.removed === 0) return void 0;
@@ -138021,7 +138746,7 @@ var ToolCallComponent = class ToolCallComponent extends CachedContainer {
138021
138746
  if (stats.lines === 0) return void 0;
138022
138747
  return {
138023
138748
  added: stats.lines,
138024
- removed: 0
138749
+ removed: void 0
138025
138750
  };
138026
138751
  }
138027
138752
  }
@@ -138493,7 +139218,7 @@ var ToolCallComponent = class ToolCallComponent extends CachedContainer {
138493
139218
  if (provider === void 0) return "";
138494
139219
  const text = provider(this.toolCall, result);
138495
139220
  if (text.length === 0) return "";
138496
- return (result.is_error ? chalk.hex(this.colors.error) : chalk.dim)(` · ${text}`);
139221
+ return chalk.dim(` · ${text}`);
138497
139222
  }
138498
139223
  rebuildContent() {
138499
139224
  this.markDirty();