@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1
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 +113 -31
- package/dist/types/capability/skill.d.ts +7 -0
- package/dist/types/cli/update-cli.d.ts +15 -1
- package/dist/types/config/append-only-context-mode.d.ts +8 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +15 -0
- package/dist/types/config/settings-schema.d.ts +38 -24
- package/dist/types/debug/protocol-probe.d.ts +38 -0
- package/dist/types/debug/terminal-info.d.ts +34 -0
- package/dist/types/exa/mcp-client.d.ts +2 -1
- 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/json-rpc.d.ts +6 -1
- package/dist/types/mcp/tool-bridge.d.ts +4 -0
- package/dist/types/mcp/transports/stdio.d.ts +16 -7
- package/dist/types/mnemopi/state.d.ts +2 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/assistant-message.d.ts +3 -2
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/components/plugin-settings.d.ts +40 -8
- package/dist/types/modes/components/session-selector.d.ts +8 -3
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- 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/modes/utils/keybinding-matchers.d.ts +4 -0
- package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +5 -2
- package/dist/types/session/history-storage.d.ts +3 -4
- package/dist/types/session/indexed-session-storage.d.ts +59 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/redis-session-storage.d.ts +12 -85
- package/dist/types/session/session-manager.d.ts +21 -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/slash-commands/types.d.ts +17 -4
- 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/tiny/text.d.ts +17 -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/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/providers/base.d.ts +14 -0
- package/dist/types/web/search/providers/exa.d.ts +9 -0
- package/dist/types/web/search/providers/perplexity.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +66 -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/cli/session-picker.ts +1 -0
- package/src/cli/update-cli.ts +54 -2
- package/src/commands/completions.ts +1 -1
- package/src/config/append-only-context-mode.ts +37 -0
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +24 -57
- 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/exa/mcp-client.ts +11 -5
- 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 +16 -2
- package/src/mcp/json-rpc.ts +8 -0
- package/src/mcp/render.ts +3 -0
- package/src/mcp/tool-bridge.ts +10 -2
- package/src/mcp/transports/http.ts +33 -16
- package/src/mcp/transports/stdio.ts +37 -12
- package/src/mnemopi/state.ts +4 -4
- package/src/modes/acp/acp-agent.ts +168 -3
- package/src/modes/acp/acp-event-mapper.ts +7 -7
- package/src/modes/components/agent-dashboard.ts +103 -31
- package/src/modes/components/assistant-message.ts +3 -2
- package/src/modes/components/extensions/extension-dashboard.ts +56 -10
- package/src/modes/components/history-search.ts +128 -14
- package/src/modes/components/hook-selector.ts +149 -14
- package/src/modes/components/plugin-settings.ts +270 -36
- package/src/modes/components/session-selector.ts +81 -19
- package/src/modes/components/settings-selector.ts +1 -1
- 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 +6 -2
- 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 +109 -0
- package/src/modes/controllers/command-controller.ts +4 -3
- package/src/modes/controllers/event-controller.ts +12 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/input-controller.ts +10 -5
- package/src/modes/controllers/selector-controller.ts +30 -19
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +56 -9
- 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/keybinding-matchers.ts +10 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/plan-mode/plan-handoff.ts +37 -0
- package/src/priority.json +4 -0
- package/src/prompts/goals/goal-continuation.md +1 -1
- package/src/prompts/steering/user-interjection.md +10 -0
- package/src/prompts/system/agent-creation-architect.md +1 -26
- 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/system-prompt.md +143 -145
- package/src/prompts/system/title-system.md +3 -2
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/browser.md +33 -30
- package/src/prompts/tools/render-mermaid.md +2 -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} +5 -5
- package/src/sdk.ts +6 -21
- package/src/session/agent-session.ts +44 -21
- package/src/session/history-storage.ts +11 -18
- package/src/session/indexed-session-storage.ts +430 -0
- package/src/session/messages.ts +80 -0
- package/src/session/redis-session-storage.ts +66 -377
- package/src/session/session-manager.ts +109 -23
- 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/slash-commands/types.ts +27 -10
- 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/text.ts +112 -1
- 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/memory-recall.ts +1 -1
- package/src/tools/memory-reflect.ts +1 -1
- 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/ssh.ts +26 -10
- package/src/tools/{todo-write.ts → todo.ts} +32 -35
- package/src/tools/write.ts +14 -2
- package/src/tui/status-line.ts +15 -4
- package/src/utils/session-color.ts +39 -14
- package/src/utils/title-generator.ts +9 -2
- package/src/web/search/index.ts +4 -2
- package/src/web/search/provider.ts +19 -35
- package/src/web/search/providers/base.ts +17 -0
- package/src/web/search/providers/exa.ts +111 -7
- package/src/web/search/providers/perplexity.ts +8 -4
- package/src/web/search/types.ts +74 -32
|
@@ -254,13 +254,19 @@ export interface SessionContext {
|
|
|
254
254
|
modeData?: Record<string, unknown>;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
|
|
258
|
+
|
|
257
259
|
/** Lists session model strings to try when restoring, in fallback order. */
|
|
258
260
|
export function getRestorableSessionModels(
|
|
259
261
|
models: Readonly<Record<string, string>>,
|
|
260
262
|
lastModelChangeRole: string | undefined,
|
|
261
263
|
): string[] {
|
|
262
264
|
const defaultModel = models.default;
|
|
263
|
-
if (
|
|
265
|
+
if (
|
|
266
|
+
!lastModelChangeRole ||
|
|
267
|
+
lastModelChangeRole === "default" ||
|
|
268
|
+
lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
|
|
269
|
+
) {
|
|
264
270
|
return defaultModel ? [defaultModel] : [];
|
|
265
271
|
}
|
|
266
272
|
|
|
@@ -270,6 +276,22 @@ export function getRestorableSessionModels(
|
|
|
270
276
|
return [roleModel, defaultModel];
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Coarse lifecycle status of a session, derived from its last persisted message.
|
|
281
|
+
*
|
|
282
|
+
* - `complete` — the last assistant turn ended with no unanswered tool calls, i.e.
|
|
283
|
+
* the agent yielded control back to the user.
|
|
284
|
+
* - `interrupted` — work was cut off mid-flight: a trailing assistant turn with
|
|
285
|
+
* pending tool calls, a trailing tool result the agent never continued from, or
|
|
286
|
+
* a length-truncated turn.
|
|
287
|
+
* - `aborted` — the last assistant turn was cancelled by the user.
|
|
288
|
+
* - `error` — the last assistant turn ended in an error.
|
|
289
|
+
* - `pending` — a trailing user message with no assistant reply persisted after it.
|
|
290
|
+
* - `unknown` — status could not be determined (empty/header-only session, or the
|
|
291
|
+
* final message was larger than the tail window that was read).
|
|
292
|
+
*/
|
|
293
|
+
export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
|
|
294
|
+
|
|
273
295
|
export interface SessionInfo {
|
|
274
296
|
path: string;
|
|
275
297
|
id: string;
|
|
@@ -285,6 +307,11 @@ export interface SessionInfo {
|
|
|
285
307
|
size: number;
|
|
286
308
|
firstMessage: string;
|
|
287
309
|
allMessagesText: string;
|
|
310
|
+
/**
|
|
311
|
+
* Coarse lifecycle status from the session's last persisted message. Optional:
|
|
312
|
+
* synthesized {@link SessionInfo}s (cross-project stubs, tests) leave it unset.
|
|
313
|
+
*/
|
|
314
|
+
status?: SessionStatus;
|
|
288
315
|
}
|
|
289
316
|
|
|
290
317
|
export type ReadonlySessionManager = Pick<
|
|
@@ -1076,7 +1103,7 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
|
|
|
1076
1103
|
await Promise.all(
|
|
1077
1104
|
files.map(async (path: string) => {
|
|
1078
1105
|
try {
|
|
1079
|
-
const content = await storage.
|
|
1106
|
+
const [content] = await storage.readTextSlices(path, 4096, 0);
|
|
1080
1107
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1081
1108
|
if (entries.length === 0) return;
|
|
1082
1109
|
const header = entries[0] as Record<string, unknown>;
|
|
@@ -1528,21 +1555,79 @@ function extractTextFromContent(content: Message["content"]): string {
|
|
|
1528
1555
|
}
|
|
1529
1556
|
|
|
1530
1557
|
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
1558
|
+
/**
|
|
1559
|
+
* Tail window read to derive {@link SessionStatus}. Large enough to capture a
|
|
1560
|
+
* typical final assistant turn (thinking + text); when the final message exceeds
|
|
1561
|
+
* it the status falls back to `unknown` rather than misreporting.
|
|
1562
|
+
*/
|
|
1563
|
+
const SESSION_LIST_SUFFIX_BYTES = 32_768;
|
|
1531
1564
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
1532
1565
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
1533
|
-
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
1534
1566
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1567
|
+
/**
|
|
1568
|
+
* Derive a {@link SessionStatus} from a tail window of a session file. Entries are
|
|
1569
|
+
* newline-terminated on write, so within the window only the first line can be a
|
|
1570
|
+
* partial fragment — it simply fails to parse and is skipped. We walk backwards to
|
|
1571
|
+
* the last `message` entry and classify by its role / stop reason.
|
|
1572
|
+
*/
|
|
1573
|
+
function deriveSessionStatus(suffix: string): SessionStatus {
|
|
1574
|
+
if (!suffix) return "unknown";
|
|
1575
|
+
const lines = suffix.split("\n");
|
|
1576
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1577
|
+
const line = lines[i];
|
|
1578
|
+
// Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
|
|
1579
|
+
// cheaply rejects blank lines and the leading partial fragment without
|
|
1580
|
+
// attempting to parse a multi-KB tail of a truncated line.
|
|
1581
|
+
if (line.charCodeAt(0) !== 123) continue;
|
|
1582
|
+
let entry: { type?: string; message?: TailMessage };
|
|
1583
|
+
try {
|
|
1584
|
+
entry = JSON.parse(line);
|
|
1585
|
+
} catch {
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (entry.type === "message" && entry.message) {
|
|
1589
|
+
return statusFromTailMessage(entry.message);
|
|
1590
|
+
}
|
|
1538
1591
|
}
|
|
1592
|
+
return "unknown";
|
|
1593
|
+
}
|
|
1539
1594
|
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1595
|
+
interface TailMessage {
|
|
1596
|
+
role?: string;
|
|
1597
|
+
stopReason?: string;
|
|
1598
|
+
content?: unknown;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function isToolCallBlock(block: unknown): boolean {
|
|
1602
|
+
return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function statusFromTailMessage(message: TailMessage): SessionStatus {
|
|
1606
|
+
switch (message.role) {
|
|
1607
|
+
case "assistant": {
|
|
1608
|
+
switch (message.stopReason) {
|
|
1609
|
+
case "error":
|
|
1610
|
+
return "error";
|
|
1611
|
+
case "aborted":
|
|
1612
|
+
return "aborted";
|
|
1613
|
+
case "length":
|
|
1614
|
+
return "interrupted";
|
|
1615
|
+
}
|
|
1616
|
+
// A turn that ends without unanswered tool calls means the agent yielded
|
|
1617
|
+
// control back to the user — complete. Trailing tool calls (no tool
|
|
1618
|
+
// results after) mean the loop was cut off before running them.
|
|
1619
|
+
const content = message.content;
|
|
1620
|
+
if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
|
|
1621
|
+
return "complete";
|
|
1622
|
+
}
|
|
1623
|
+
case "toolResult":
|
|
1624
|
+
// Tools ran but the agent never produced the following assistant turn.
|
|
1625
|
+
return "interrupted";
|
|
1626
|
+
case "user":
|
|
1627
|
+
// User message with no assistant reply persisted after it.
|
|
1628
|
+
return "pending";
|
|
1629
|
+
default:
|
|
1630
|
+
return "unknown";
|
|
1546
1631
|
}
|
|
1547
1632
|
}
|
|
1548
1633
|
|
|
@@ -1677,13 +1762,15 @@ function getSessionListWorkerCount(fileCount: number): number {
|
|
|
1677
1762
|
);
|
|
1678
1763
|
}
|
|
1679
1764
|
|
|
1680
|
-
async function collectSessionFromFile(
|
|
1681
|
-
file: string,
|
|
1682
|
-
storage: SessionStorage,
|
|
1683
|
-
buffer: Buffer,
|
|
1684
|
-
): Promise<SessionInfo | undefined> {
|
|
1765
|
+
async function collectSessionFromFile(file: string, storage: SessionStorage): Promise<SessionInfo | undefined> {
|
|
1685
1766
|
try {
|
|
1686
|
-
const
|
|
1767
|
+
const stat = storage.statSync(file);
|
|
1768
|
+
const [content, suffix] = await storage.readTextSlices(
|
|
1769
|
+
file,
|
|
1770
|
+
SESSION_LIST_PREFIX_BYTES,
|
|
1771
|
+
SESSION_LIST_SUFFIX_BYTES,
|
|
1772
|
+
);
|
|
1773
|
+
const { size, mtime } = stat;
|
|
1687
1774
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1688
1775
|
const header = parseSessionListHeader(content, entries);
|
|
1689
1776
|
if (!header) return undefined;
|
|
@@ -1719,7 +1806,6 @@ async function collectSessionFromFile(
|
|
|
1719
1806
|
|
|
1720
1807
|
firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
|
|
1721
1808
|
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
|
|
1722
|
-
const stats = storage.statSync(file);
|
|
1723
1809
|
return {
|
|
1724
1810
|
path: file,
|
|
1725
1811
|
id: header.id,
|
|
@@ -1727,11 +1813,12 @@ async function collectSessionFromFile(
|
|
|
1727
1813
|
title: header.title ?? shortSummary,
|
|
1728
1814
|
parentSessionPath: header.parentSession,
|
|
1729
1815
|
created: new Date(header.timestamp ?? ""),
|
|
1730
|
-
modified:
|
|
1816
|
+
modified: mtime,
|
|
1731
1817
|
messageCount,
|
|
1732
|
-
size
|
|
1818
|
+
size,
|
|
1733
1819
|
firstMessage: firstMessage || "(no messages)",
|
|
1734
1820
|
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
1821
|
+
status: deriveSessionStatus(suffix),
|
|
1735
1822
|
};
|
|
1736
1823
|
} catch {
|
|
1737
1824
|
return undefined;
|
|
@@ -1745,10 +1832,9 @@ async function collectSessionsFromFileStride(
|
|
|
1745
1832
|
stride: number,
|
|
1746
1833
|
): Promise<SessionInfo[]> {
|
|
1747
1834
|
const sessions: SessionInfo[] = [];
|
|
1748
|
-
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
|
|
1749
1835
|
|
|
1750
1836
|
for (let i = startIndex; i < files.length; i += stride) {
|
|
1751
|
-
const session = await collectSessionFromFile(files[i], storage
|
|
1837
|
+
const session = await collectSessionFromFile(files[i], storage);
|
|
1752
1838
|
if (session) sessions.push(session);
|
|
1753
1839
|
}
|
|
1754
1840
|
|
|
@@ -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> {
|