@oh-my-pi/pi-coding-agent 15.8.2 → 15.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +82 -29
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/config/settings-schema.d.ts +36 -22
  4. package/dist/types/debug/protocol-probe.d.ts +38 -0
  5. package/dist/types/debug/terminal-info.d.ts +34 -0
  6. package/dist/types/export/html/template.generated.d.ts +1 -1
  7. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -1
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  13. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  14. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  15. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +20 -1
  19. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  20. package/dist/types/plan-mode/plan-protection.d.ts +12 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  23. package/dist/types/session/redis-session-storage.d.ts +12 -85
  24. package/dist/types/session/session-manager.d.ts +20 -0
  25. package/dist/types/session/session-storage.d.ts +5 -7
  26. package/dist/types/session/sql-session-storage.d.ts +16 -85
  27. package/dist/types/task/executor.d.ts +9 -0
  28. package/dist/types/task/index.d.ts +3 -1
  29. package/dist/types/telemetry-export.d.ts +19 -0
  30. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  31. package/dist/types/tools/ask.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -2
  33. package/dist/types/tools/path-utils.d.ts +1 -1
  34. package/dist/types/tools/search.d.ts +2 -2
  35. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  36. package/dist/types/utils/jj.d.ts +1 -1
  37. package/dist/types/utils/session-color.d.ts +7 -2
  38. package/dist/types/web/search/index.d.ts +1 -1
  39. package/dist/types/web/search/provider.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +65 -1
  41. package/package.json +15 -9
  42. package/scripts/build-binary.ts +12 -0
  43. package/src/capability/skill.ts +7 -0
  44. package/src/cli/args.ts +1 -1
  45. package/src/config/settings-schema.ts +22 -55
  46. package/src/debug/index.ts +67 -1
  47. package/src/debug/protocol-probe.ts +267 -0
  48. package/src/debug/terminal-info.ts +127 -0
  49. package/src/export/html/template.generated.ts +1 -1
  50. package/src/export/html/template.js +3 -3
  51. package/src/extensibility/custom-tools/types.ts +1 -1
  52. package/src/extensibility/extensions/types.ts +11 -0
  53. package/src/extensibility/shared-events.ts +1 -1
  54. package/src/extensibility/skills.ts +3 -3
  55. package/src/index.ts +1 -0
  56. package/src/internal-urls/docs-index.generated.ts +7 -7
  57. package/src/main.ts +12 -0
  58. package/src/mcp/transports/stdio.ts +37 -12
  59. package/src/modes/acp/acp-event-mapper.ts +7 -7
  60. package/src/modes/components/assistant-message.ts +3 -2
  61. package/src/modes/components/hook-selector.ts +149 -14
  62. package/src/modes/components/session-selector.ts +36 -5
  63. package/src/modes/components/status-line/segments.ts +2 -1
  64. package/src/modes/components/status-line.ts +1 -1
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/todo-reminder.ts +1 -1
  67. package/src/modes/components/tool-execution.ts +9 -4
  68. package/src/modes/components/transcript-container.ts +91 -0
  69. package/src/modes/controllers/event-controller.ts +12 -7
  70. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  71. package/src/modes/controllers/todo-command-controller.ts +1 -1
  72. package/src/modes/interactive-mode.ts +18 -6
  73. package/src/modes/print-mode.ts +5 -0
  74. package/src/modes/rpc/rpc-types.ts +1 -1
  75. package/src/modes/theme/theme.ts +48 -8
  76. package/src/modes/utils/ui-helpers.ts +1 -0
  77. package/src/plan-mode/plan-handoff.ts +37 -0
  78. package/src/plan-mode/plan-protection.ts +31 -0
  79. package/src/priority.json +4 -0
  80. package/src/prompts/goals/goal-continuation.md +1 -1
  81. package/src/prompts/system/eager-todo.md +3 -3
  82. package/src/prompts/system/orchestrate-notice.md +5 -5
  83. package/src/prompts/system/plan-mode-approved.md +14 -17
  84. package/src/prompts/system/plan-mode-reference.md +3 -6
  85. package/src/prompts/system/subagent-system-prompt.md +11 -0
  86. package/src/prompts/system/workflow-notice.md +1 -1
  87. package/src/prompts/tools/browser.md +5 -2
  88. package/src/prompts/tools/search.md +1 -1
  89. package/src/prompts/tools/task.md +3 -1
  90. package/src/prompts/tools/{todo-write.md → todo.md} +8 -0
  91. package/src/sdk.ts +1 -0
  92. package/src/session/agent-session.ts +29 -16
  93. package/src/session/indexed-session-storage.ts +430 -0
  94. package/src/session/redis-session-storage.ts +66 -377
  95. package/src/session/session-manager.ts +102 -22
  96. package/src/session/session-storage.ts +148 -68
  97. package/src/session/sql-session-storage.ts +131 -382
  98. package/src/slash-commands/helpers/todo.ts +2 -2
  99. package/src/task/executor.ts +9 -1
  100. package/src/task/index.ts +51 -1
  101. package/src/telemetry-export.ts +126 -0
  102. package/src/tiny/compiled-runtime.ts +179 -0
  103. package/src/tiny/worker.ts +24 -2
  104. package/src/tools/ask.ts +133 -87
  105. package/src/tools/fetch.ts +17 -4
  106. package/src/tools/find.ts +2 -2
  107. package/src/tools/index.ts +6 -4
  108. package/src/tools/path-utils.ts +16 -7
  109. package/src/tools/renderers.ts +2 -2
  110. package/src/tools/search.ts +6 -3
  111. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  112. package/src/utils/jj.ts +28 -5
  113. package/src/utils/session-color.ts +39 -14
  114. package/src/web/search/index.ts +1 -1
  115. package/src/web/search/provider.ts +18 -34
  116. package/src/web/search/types.ts +73 -32
@@ -270,6 +270,22 @@ export function getRestorableSessionModels(
270
270
  return [roleModel, defaultModel];
271
271
  }
272
272
 
273
+ /**
274
+ * Coarse lifecycle status of a session, derived from its last persisted message.
275
+ *
276
+ * - `complete` — the last assistant turn ended with no unanswered tool calls, i.e.
277
+ * the agent yielded control back to the user.
278
+ * - `interrupted` — work was cut off mid-flight: a trailing assistant turn with
279
+ * pending tool calls, a trailing tool result the agent never continued from, or
280
+ * a length-truncated turn.
281
+ * - `aborted` — the last assistant turn was cancelled by the user.
282
+ * - `error` — the last assistant turn ended in an error.
283
+ * - `pending` — a trailing user message with no assistant reply persisted after it.
284
+ * - `unknown` — status could not be determined (empty/header-only session, or the
285
+ * final message was larger than the tail window that was read).
286
+ */
287
+ export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
288
+
273
289
  export interface SessionInfo {
274
290
  path: string;
275
291
  id: string;
@@ -285,6 +301,11 @@ export interface SessionInfo {
285
301
  size: number;
286
302
  firstMessage: string;
287
303
  allMessagesText: string;
304
+ /**
305
+ * Coarse lifecycle status from the session's last persisted message. Optional:
306
+ * synthesized {@link SessionInfo}s (cross-project stubs, tests) leave it unset.
307
+ */
308
+ status?: SessionStatus;
288
309
  }
289
310
 
290
311
  export type ReadonlySessionManager = Pick<
@@ -1076,7 +1097,7 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
1076
1097
  await Promise.all(
1077
1098
  files.map(async (path: string) => {
1078
1099
  try {
1079
- const content = await storage.readTextPrefix(path, 4096);
1100
+ const [content] = await storage.readTextSlices(path, 4096, 0);
1080
1101
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
1081
1102
  if (entries.length === 0) return;
1082
1103
  const header = entries[0] as Record<string, unknown>;
@@ -1528,21 +1549,79 @@ function extractTextFromContent(content: Message["content"]): string {
1528
1549
  }
1529
1550
 
1530
1551
  const SESSION_LIST_PREFIX_BYTES = 4096;
1552
+ /**
1553
+ * Tail window read to derive {@link SessionStatus}. Large enough to capture a
1554
+ * typical final assistant turn (thinking + text); when the final message exceeds
1555
+ * it the status falls back to `unknown` rather than misreporting.
1556
+ */
1557
+ const SESSION_LIST_SUFFIX_BYTES = 32_768;
1531
1558
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
1532
1559
  const SESSION_LIST_MAX_WORKERS = 16;
1533
- const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
1534
1560
 
1535
- async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
1536
- if (!(storage instanceof FileSessionStorage)) {
1537
- return storage.readTextPrefix(file, buffer.byteLength);
1561
+ /**
1562
+ * Derive a {@link SessionStatus} from a tail window of a session file. Entries are
1563
+ * newline-terminated on write, so within the window only the first line can be a
1564
+ * partial fragment — it simply fails to parse and is skipped. We walk backwards to
1565
+ * the last `message` entry and classify by its role / stop reason.
1566
+ */
1567
+ function deriveSessionStatus(suffix: string): SessionStatus {
1568
+ if (!suffix) return "unknown";
1569
+ const lines = suffix.split("\n");
1570
+ for (let i = lines.length - 1; i >= 0; i--) {
1571
+ const line = lines[i];
1572
+ // Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
1573
+ // cheaply rejects blank lines and the leading partial fragment without
1574
+ // attempting to parse a multi-KB tail of a truncated line.
1575
+ if (line.charCodeAt(0) !== 123) continue;
1576
+ let entry: { type?: string; message?: TailMessage };
1577
+ try {
1578
+ entry = JSON.parse(line);
1579
+ } catch {
1580
+ continue;
1581
+ }
1582
+ if (entry.type === "message" && entry.message) {
1583
+ return statusFromTailMessage(entry.message);
1584
+ }
1538
1585
  }
1586
+ return "unknown";
1587
+ }
1539
1588
 
1540
- const handle = await fs.promises.open(file, "r");
1541
- try {
1542
- const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
1543
- return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead));
1544
- } finally {
1545
- await handle.close();
1589
+ interface TailMessage {
1590
+ role?: string;
1591
+ stopReason?: string;
1592
+ content?: unknown;
1593
+ }
1594
+
1595
+ function isToolCallBlock(block: unknown): boolean {
1596
+ return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall";
1597
+ }
1598
+
1599
+ function statusFromTailMessage(message: TailMessage): SessionStatus {
1600
+ switch (message.role) {
1601
+ case "assistant": {
1602
+ switch (message.stopReason) {
1603
+ case "error":
1604
+ return "error";
1605
+ case "aborted":
1606
+ return "aborted";
1607
+ case "length":
1608
+ return "interrupted";
1609
+ }
1610
+ // A turn that ends without unanswered tool calls means the agent yielded
1611
+ // control back to the user — complete. Trailing tool calls (no tool
1612
+ // results after) mean the loop was cut off before running them.
1613
+ const content = message.content;
1614
+ if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
1615
+ return "complete";
1616
+ }
1617
+ case "toolResult":
1618
+ // Tools ran but the agent never produced the following assistant turn.
1619
+ return "interrupted";
1620
+ case "user":
1621
+ // User message with no assistant reply persisted after it.
1622
+ return "pending";
1623
+ default:
1624
+ return "unknown";
1546
1625
  }
1547
1626
  }
1548
1627
 
@@ -1677,13 +1756,15 @@ function getSessionListWorkerCount(fileCount: number): number {
1677
1756
  );
1678
1757
  }
1679
1758
 
1680
- async function collectSessionFromFile(
1681
- file: string,
1682
- storage: SessionStorage,
1683
- buffer: Buffer,
1684
- ): Promise<SessionInfo | undefined> {
1759
+ async function collectSessionFromFile(file: string, storage: SessionStorage): Promise<SessionInfo | undefined> {
1685
1760
  try {
1686
- const content = await readSessionListPrefix(file, storage, buffer);
1761
+ const stat = storage.statSync(file);
1762
+ const [content, suffix] = await storage.readTextSlices(
1763
+ file,
1764
+ SESSION_LIST_PREFIX_BYTES,
1765
+ SESSION_LIST_SUFFIX_BYTES,
1766
+ );
1767
+ const { size, mtime } = stat;
1687
1768
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
1688
1769
  const header = parseSessionListHeader(content, entries);
1689
1770
  if (!header) return undefined;
@@ -1719,7 +1800,6 @@ async function collectSessionFromFile(
1719
1800
 
1720
1801
  firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
1721
1802
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
1722
- const stats = storage.statSync(file);
1723
1803
  return {
1724
1804
  path: file,
1725
1805
  id: header.id,
@@ -1727,11 +1807,12 @@ async function collectSessionFromFile(
1727
1807
  title: header.title ?? shortSummary,
1728
1808
  parentSessionPath: header.parentSession,
1729
1809
  created: new Date(header.timestamp ?? ""),
1730
- modified: stats.mtime,
1810
+ modified: mtime,
1731
1811
  messageCount,
1732
- size: stats.size,
1812
+ size,
1733
1813
  firstMessage: firstMessage || "(no messages)",
1734
1814
  allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
1815
+ status: deriveSessionStatus(suffix),
1735
1816
  };
1736
1817
  } catch {
1737
1818
  return undefined;
@@ -1745,10 +1826,9 @@ async function collectSessionsFromFileStride(
1745
1826
  stride: number,
1746
1827
  ): Promise<SessionInfo[]> {
1747
1828
  const sessions: SessionInfo[] = [];
1748
- const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
1749
1829
 
1750
1830
  for (let i = startIndex; i < files.length; i += stride) {
1751
- const session = await collectSessionFromFile(files[i], storage, buffer);
1831
+ const session = await collectSessionFromFile(files[i], storage);
1752
1832
  if (session) sessions.push(session);
1753
1833
  }
1754
1834
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { isEnoent, peekFile, toError } from "@oh-my-pi/pi-utils";
4
+ import { isEnoent, peekFileEnds, toError } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const utf8Decoder = new TextDecoder("utf-8");
7
7
 
@@ -31,13 +31,13 @@ export interface SessionStorage {
31
31
  ensureDirSync(dir: string): void;
32
32
  existsSync(path: string): boolean;
33
33
  writeTextSync(path: string, content: string): void;
34
- readTextSync(path: string): string;
35
34
  statSync(path: string): SessionStorageStat;
36
35
  listFilesSync(dir: string, pattern: string): string[];
37
36
 
38
37
  exists(path: string): Promise<boolean>;
39
38
  readText(path: string): Promise<string>;
40
- readTextPrefix(path: string, maxBytes: number): Promise<string>;
39
+ /** Read the requested UTF-8 byte windows from the head and tail of the file. */
40
+ readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
41
41
  writeText(path: string, content: string): Promise<void>;
42
42
  rename(path: string, nextPath: string): Promise<void>;
43
43
  unlink(path: string): Promise<void>;
@@ -151,10 +151,6 @@ export class FileSessionStorage implements SessionStorage {
151
151
  fs.writeFileSync(fpath, content);
152
152
  }
153
153
 
154
- readTextSync(fpath: string): string {
155
- return fs.readFileSync(fpath, "utf-8");
156
- }
157
-
158
154
  statSync(path: string): SessionStorageStat {
159
155
  const stats = fs.statSync(path);
160
156
  return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
@@ -182,8 +178,11 @@ export class FileSessionStorage implements SessionStorage {
182
178
  return Bun.file(path).text();
183
179
  }
184
180
 
185
- async readTextPrefix(path: string, maxBytes: number): Promise<string> {
186
- return peekFile(path, maxBytes, header => utf8Decoder.decode(header));
181
+ async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
182
+ return peekFileEnds(path, prefixBytes, suffixBytes, (head, tail) => [
183
+ utf8Decoder.decode(head),
184
+ utf8Decoder.decode(tail),
185
+ ]);
187
186
  }
188
187
 
189
188
  async writeText(path: string, content: string): Promise<void> {
@@ -272,8 +271,8 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
272
271
  if (this.#closed) throw new Error("Writer closed");
273
272
  if (this.#error) throw this.#error;
274
273
  try {
275
- // O(1) chunked append — see MemorySessionStorage.appendChunkSync.
276
- this.#storage.appendChunkSync(this.#path, line);
274
+ // O(1) append — push onto the path's indexed in-memory entry.
275
+ this.#storage.appendSync(this.#path, line);
277
276
  } catch (err) {
278
277
  throw this.#recordError(err);
279
278
  }
@@ -302,26 +301,140 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
302
301
  }
303
302
  }
304
303
 
305
- /**
306
- * Mirror entry stored per path. Chunks accumulate via O(1) `push` on
307
- * `appendChunkSync`; readers materialise into a single string lazily.
308
- * `byteLen` is kept in sync so `statSync` is O(1) (and returns true UTF-8
309
- * bytes, not character count).
310
- */
311
- interface MirrorEntry {
304
+ interface MemoryFileEntry {
312
305
  chunks: string[];
313
- byteLen: number;
306
+ cumulativeBytes: number[];
307
+ size: number;
314
308
  mtimeMs: number;
315
309
  }
316
310
 
317
- function materialiseMirror(entry: MirrorEntry): string {
318
- if (entry.chunks.length === 0) return "";
319
- if (entry.chunks.length === 1) return entry.chunks[0];
320
- return entry.chunks.join("");
311
+ function createMemoryFileEntry(content: string, mtimeMs: number): MemoryFileEntry {
312
+ const size = Buffer.byteLength(content, "utf-8");
313
+ return {
314
+ chunks: size === 0 ? [] : [content],
315
+ cumulativeBytes: size === 0 ? [] : [size],
316
+ size,
317
+ mtimeMs,
318
+ };
319
+ }
320
+
321
+ function appendMemoryChunk(entry: MemoryFileEntry, chunk: string): void {
322
+ const chunkSize = Buffer.byteLength(chunk, "utf-8");
323
+ if (chunkSize === 0) return;
324
+ entry.size += chunkSize;
325
+ entry.chunks.push(chunk);
326
+ entry.cumulativeBytes.push(entry.size);
327
+ }
328
+
329
+ function normalizeByteLimit(maxBytes: number, size: number): number {
330
+ if (!(maxBytes > 0) || size === 0) return 0;
331
+ return Math.min(Math.trunc(maxBytes), size);
332
+ }
333
+
334
+ function lowerBound(values: readonly number[], target: number): number {
335
+ let lo = 0;
336
+ let hi = values.length;
337
+ while (lo < hi) {
338
+ const mid = (lo + hi) >>> 1;
339
+ if (values[mid] < target) {
340
+ lo = mid + 1;
341
+ } else {
342
+ hi = mid;
343
+ }
344
+ }
345
+ return lo;
346
+ }
347
+
348
+ function upperBound(values: readonly number[], target: number): number {
349
+ let lo = 0;
350
+ let hi = values.length;
351
+ while (lo < hi) {
352
+ const mid = (lo + hi) >>> 1;
353
+ if (values[mid] <= target) {
354
+ lo = mid + 1;
355
+ } else {
356
+ hi = mid;
357
+ }
358
+ }
359
+ return lo;
360
+ }
361
+
362
+ function joinChunkRange(chunks: readonly string[], start: number, end: number): string {
363
+ const count = end - start;
364
+ if (count <= 0) return "";
365
+ if (count === 1) return chunks[start] ?? "";
366
+
367
+ let content = "";
368
+ for (let i = start; i < end; i++) {
369
+ content += chunks[i];
370
+ }
371
+ return content;
372
+ }
373
+
374
+ function decodeChunkByteRange(chunk: string, startByte: number, endByte: number, chunkSize: number): string {
375
+ if (startByte >= endByte) return "";
376
+ if (startByte === 0 && endByte === chunkSize) return chunk;
377
+ if (chunk.length === chunkSize) return chunk.slice(startByte, endByte);
378
+ const bytes = Buffer.from(chunk, "utf-8");
379
+ return utf8Decoder.decode(bytes.subarray(startByte, endByte));
380
+ }
381
+
382
+ function materializeMemoryEntry(entry: MemoryFileEntry): string {
383
+ const { chunks } = entry;
384
+ if (chunks.length === 0) return "";
385
+ if (chunks.length === 1) return chunks[0];
386
+
387
+ const content = chunks.join("");
388
+ entry.chunks = [content];
389
+ entry.cumulativeBytes = [entry.size];
390
+ return content;
391
+ }
392
+
393
+ function sliceChunksHead(entry: MemoryFileEntry, maxBytes: number): string {
394
+ const limit = normalizeByteLimit(maxBytes, entry.size);
395
+ if (limit === 0) return "";
396
+ if (limit >= entry.size) return materializeMemoryEntry(entry);
397
+
398
+ const boundaryIndex = lowerBound(entry.cumulativeBytes, limit);
399
+ const chunkStart = boundaryIndex === 0 ? 0 : entry.cumulativeBytes[boundaryIndex - 1];
400
+ const chunkEnd = entry.cumulativeBytes[boundaryIndex];
401
+ if (chunkEnd === limit) return joinChunkRange(entry.chunks, 0, boundaryIndex + 1);
402
+
403
+ const chunk = entry.chunks[boundaryIndex];
404
+ const chunkPrefix = decodeChunkByteRange(chunk, 0, limit - chunkStart, chunkEnd - chunkStart);
405
+ return joinChunkRange(entry.chunks, 0, boundaryIndex) + chunkPrefix;
406
+ }
407
+
408
+ function sliceChunksTail(entry: MemoryFileEntry, maxBytes: number): string {
409
+ const limit = normalizeByteLimit(maxBytes, entry.size);
410
+ if (limit === 0) return "";
411
+ if (limit >= entry.size) return materializeMemoryEntry(entry);
412
+
413
+ const startByte = entry.size - limit;
414
+ const boundaryIndex = upperBound(entry.cumulativeBytes, startByte);
415
+ const chunkStart = boundaryIndex === 0 ? 0 : entry.cumulativeBytes[boundaryIndex - 1];
416
+ const chunkEnd = entry.cumulativeBytes[boundaryIndex];
417
+ const chunkOffset = startByte - chunkStart;
418
+ if (chunkOffset === 0) return joinChunkRange(entry.chunks, boundaryIndex, entry.chunks.length);
419
+
420
+ const chunk = entry.chunks[boundaryIndex];
421
+ const chunkSuffix = decodeChunkByteRange(chunk, chunkOffset, chunkEnd - chunkStart, chunkEnd - chunkStart);
422
+ return chunkSuffix + joinChunkRange(entry.chunks, boundaryIndex + 1, entry.chunks.length);
321
423
  }
322
424
 
323
425
  export class MemorySessionStorage implements SessionStorage {
324
- #files = new Map<string, MirrorEntry>();
426
+ // Each path keeps appended string chunks plus cumulative UTF-8 byte offsets.
427
+ // Full reads materialize the chunks into one string chunk, so repeated reads
428
+ // do not re-join stale history. Later appends still stay O(1) by pushing
429
+ // after that materialized chunk. Prefix/suffix reads binary-search byte
430
+ // offsets and join only the requested window.
431
+ #files = new Map<string, MemoryFileEntry>();
432
+
433
+ #requireEntry(path: string): MemoryFileEntry {
434
+ const entry = this.#files.get(path);
435
+ if (!entry) throw new Error(`File not found: ${path}`);
436
+ return entry;
437
+ }
325
438
 
326
439
  ensureDirSync(_dir: string): void {
327
440
  // No-op for in-memory storage.
@@ -332,11 +445,7 @@ export class MemorySessionStorage implements SessionStorage {
332
445
  }
333
446
 
334
447
  writeTextSync(path: string, content: string): void {
335
- this.#files.set(path, {
336
- chunks: content.length === 0 ? [] : [content],
337
- byteLen: Buffer.byteLength(content, "utf-8"),
338
- mtimeMs: Date.now(),
339
- });
448
+ this.#files.set(path, createMemoryFileEntry(content, Date.now()));
340
449
  }
341
450
 
342
451
  /**
@@ -344,28 +453,21 @@ export class MemorySessionStorage implements SessionStorage {
344
453
  * creates the entry. External callers should go through `openWriter()`
345
454
  * rather than touching the mirror directly.
346
455
  */
347
- appendChunkSync(path: string, chunk: string): void {
456
+ appendSync(path: string, chunk: string): void {
457
+ const mtimeMs = Date.now();
348
458
  let entry = this.#files.get(path);
349
459
  if (!entry) {
350
- entry = { chunks: [], byteLen: 0, mtimeMs: Date.now() };
460
+ entry = createMemoryFileEntry("", mtimeMs);
351
461
  this.#files.set(path, entry);
352
462
  }
353
- entry.chunks.push(chunk);
354
- entry.byteLen += Buffer.byteLength(chunk, "utf-8");
355
- entry.mtimeMs = Date.now();
356
- }
357
-
358
- readTextSync(path: string): string {
359
- const entry = this.#files.get(path);
360
- if (!entry) throw new Error(`File not found: ${path}`);
361
- return materialiseMirror(entry);
463
+ appendMemoryChunk(entry, chunk);
464
+ entry.mtimeMs = mtimeMs;
362
465
  }
363
466
 
364
467
  statSync(path: string): SessionStorageStat {
365
- const entry = this.#files.get(path);
366
- if (!entry) throw new Error(`File not found: ${path}`);
468
+ const entry = this.#requireEntry(path);
367
469
  return {
368
- size: entry.byteLen,
470
+ size: entry.size,
369
471
  mtimeMs: entry.mtimeMs,
370
472
  mtime: new Date(entry.mtimeMs),
371
473
  };
@@ -391,35 +493,13 @@ export class MemorySessionStorage implements SessionStorage {
391
493
  readText(path: string): Promise<string> {
392
494
  const entry = this.#files.get(path);
393
495
  if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
394
- return Promise.resolve(materialiseMirror(entry));
496
+ return Promise.resolve(materializeMemoryEntry(entry));
395
497
  }
396
498
 
397
- readTextPrefix(path: string, maxBytes: number): Promise<string> {
499
+ readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
398
500
  const entry = this.#files.get(path);
399
501
  if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
400
- if (entry.chunks.length === 0 || maxBytes <= 0) return Promise.resolve("");
401
-
402
- // Walk chunks until the byte budget is exhausted. Avoids materialising
403
- // the full mirror just to slice a prefix — bounded work for big files.
404
- let accumulatedBytes = 0;
405
- const out: string[] = [];
406
- for (const chunk of entry.chunks) {
407
- const chunkBytes = Buffer.byteLength(chunk, "utf-8");
408
- if (accumulatedBytes + chunkBytes <= maxBytes) {
409
- out.push(chunk);
410
- accumulatedBytes += chunkBytes;
411
- if (accumulatedBytes === maxBytes) break;
412
- continue;
413
- }
414
- // Boundary chunk: slice in byte space and decode. Result MAY be
415
- // shorter than the budget if a multi-byte codepoint straddles the
416
- // boundary — matches `peekFile` semantics (partial decode at cap).
417
- const remainingBytes = maxBytes - accumulatedBytes;
418
- const utf8 = Buffer.from(chunk, "utf-8");
419
- out.push(utf8Decoder.decode(utf8.subarray(0, remainingBytes)));
420
- break;
421
- }
422
- return Promise.resolve(out.join(""));
502
+ return Promise.resolve([sliceChunksHead(entry, prefixBytes), sliceChunksTail(entry, suffixBytes)]);
423
503
  }
424
504
 
425
505
  writeText(path: string, content: string): Promise<void> {