@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.
- package/CHANGELOG.md +82 -29
- package/dist/types/capability/skill.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +36 -22
- package/dist/types/debug/protocol-probe.d.ts +38 -0
- package/dist/types/debug/terminal-info.d.ts +34 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mcp/transports/stdio.d.ts +16 -7
- package/dist/types/modes/components/assistant-message.d.ts +3 -2
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/components/todo-reminder.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +36 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +20 -1
- package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
- package/dist/types/plan-mode/plan-protection.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +59 -0
- package/dist/types/session/redis-session-storage.d.ts +12 -85
- package/dist/types/session/session-manager.d.ts +20 -0
- package/dist/types/session/session-storage.d.ts +5 -7
- package/dist/types/session/sql-session-storage.d.ts +16 -85
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/telemetry-export.d.ts +19 -0
- package/dist/types/tiny/compiled-runtime.d.ts +35 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/index.d.ts +4 -2
- package/dist/types/tools/path-utils.d.ts +1 -1
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
- package/dist/types/utils/jj.d.ts +1 -1
- package/dist/types/utils/session-color.d.ts +7 -2
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +65 -1
- package/package.json +15 -9
- package/scripts/build-binary.ts +12 -0
- package/src/capability/skill.ts +7 -0
- package/src/cli/args.ts +1 -1
- package/src/config/settings-schema.ts +22 -55
- package/src/debug/index.ts +67 -1
- package/src/debug/protocol-probe.ts +267 -0
- package/src/debug/terminal-info.ts +127 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +3 -3
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +3 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +12 -0
- package/src/mcp/transports/stdio.ts +37 -12
- package/src/modes/acp/acp-event-mapper.ts +7 -7
- package/src/modes/components/assistant-message.ts +3 -2
- package/src/modes/components/hook-selector.ts +149 -14
- package/src/modes/components/session-selector.ts +36 -5
- package/src/modes/components/status-line/segments.ts +2 -1
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/todo-reminder.ts +1 -1
- package/src/modes/components/tool-execution.ts +9 -4
- package/src/modes/components/transcript-container.ts +91 -0
- package/src/modes/controllers/event-controller.ts +12 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +18 -6
- package/src/modes/print-mode.ts +5 -0
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/theme/theme.ts +48 -8
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/plan-mode/plan-handoff.ts +37 -0
- package/src/plan-mode/plan-protection.ts +31 -0
- package/src/priority.json +4 -0
- package/src/prompts/goals/goal-continuation.md +1 -1
- package/src/prompts/system/eager-todo.md +3 -3
- package/src/prompts/system/orchestrate-notice.md +5 -5
- package/src/prompts/system/plan-mode-approved.md +14 -17
- package/src/prompts/system/plan-mode-reference.md +3 -6
- package/src/prompts/system/subagent-system-prompt.md +11 -0
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/browser.md +5 -2
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +3 -1
- package/src/prompts/tools/{todo-write.md → todo.md} +8 -0
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +29 -16
- package/src/session/indexed-session-storage.ts +430 -0
- package/src/session/redis-session-storage.ts +66 -377
- package/src/session/session-manager.ts +102 -22
- package/src/session/session-storage.ts +148 -68
- package/src/session/sql-session-storage.ts +131 -382
- package/src/slash-commands/helpers/todo.ts +2 -2
- package/src/task/executor.ts +9 -1
- package/src/task/index.ts +51 -1
- package/src/telemetry-export.ts +126 -0
- package/src/tiny/compiled-runtime.ts +179 -0
- package/src/tiny/worker.ts +24 -2
- package/src/tools/ask.ts +133 -87
- package/src/tools/fetch.ts +17 -4
- package/src/tools/find.ts +2 -2
- package/src/tools/index.ts +6 -4
- package/src/tools/path-utils.ts +16 -7
- package/src/tools/renderers.ts +2 -2
- package/src/tools/search.ts +6 -3
- package/src/tools/{todo-write.ts → todo.ts} +32 -35
- package/src/utils/jj.ts +28 -5
- package/src/utils/session-color.ts +39 -14
- package/src/web/search/index.ts +1 -1
- package/src/web/search/provider.ts +18 -34
- 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.
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
|
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:
|
|
1810
|
+
modified: mtime,
|
|
1731
1811
|
messageCount,
|
|
1732
|
-
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
186
|
-
return
|
|
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)
|
|
276
|
-
this.#storage.
|
|
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
|
-
|
|
306
|
+
cumulativeBytes: number[];
|
|
307
|
+
size: number;
|
|
314
308
|
mtimeMs: number;
|
|
315
309
|
}
|
|
316
310
|
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
460
|
+
entry = createMemoryFileEntry("", mtimeMs);
|
|
351
461
|
this.#files.set(path, entry);
|
|
352
462
|
}
|
|
353
|
-
entry
|
|
354
|
-
entry.
|
|
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.#
|
|
366
|
-
if (!entry) throw new Error(`File not found: ${path}`);
|
|
468
|
+
const entry = this.#requireEntry(path);
|
|
367
469
|
return {
|
|
368
|
-
size: entry.
|
|
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(
|
|
496
|
+
return Promise.resolve(materializeMemoryEntry(entry));
|
|
395
497
|
}
|
|
396
498
|
|
|
397
|
-
|
|
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
|
-
|
|
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> {
|