@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
|
@@ -25,7 +25,7 @@ Drives a real Chromium tab with full puppeteer access via JS execution.
|
|
|
25
25
|
- `tab.waitForUrl(pattern, { timeout? })` — pattern is a substring or `RegExp`. Polls `location.href` so it works for SPA pushState navigations, not just real navigations. Returns the matched URL.
|
|
26
26
|
- `tab.waitForResponse(pattern, { timeout? })` — pattern is a substring, `RegExp`, or `(response) => boolean`. Returns the raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
|
|
27
27
|
- `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with the abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
|
|
28
|
-
- `tab.screenshot({ selector?, fullPage?, save?, silent? })` — auto-attaches
|
|
28
|
+
- `tab.screenshot({ selector?, fullPage?, save?, silent? })` — captures a screenshot and **auto-attaches it to the tool output for you to view** (unless `silent: true`). `save` is **strictly optional**: OMIT it when you just want to look at the page — the downscaled image is shown to you regardless, and the full-res capture is written to a temp file automatically. Pass `save` (a path) ONLY when you deliberately need to keep a full-res copy on disk for later use; `browser.screenshotDir` does the same for every shot. Do NOT invent a `save` path for a throwaway/temporal screenshot.
|
|
29
29
|
- `tab.extract(format = "markdown")` — Readability-extracted page content.
|
|
30
30
|
- Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…`, etc. are normalized.
|
|
31
31
|
- Default to `tab.observe()` over `tab.screenshot()` for understanding page state. Screenshot only when visual appearance matters.
|
|
@@ -46,7 +46,10 @@ Drives a real Chromium tab with full puppeteer access via JS execution.
|
|
|
46
46
|
# Click an observed element by id
|
|
47
47
|
`{"action":"run","name":"docs","code":"const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();"}`
|
|
48
48
|
|
|
49
|
-
#
|
|
49
|
+
# Take a transient screenshot just to look at the page — NO save path needed; the image is shown to you
|
|
50
|
+
`{"action":"run","name":"docs","code":"await tab.screenshot();"}`
|
|
51
|
+
|
|
52
|
+
# Persist a full-page screenshot to disk (only when you deliberately need to keep the file)
|
|
50
53
|
`{"action":"run","name":"docs","code":"await tab.screenshot({ fullPage: true, save: 'screenshot.png' });"}`
|
|
51
54
|
|
|
52
55
|
# Fill and submit a form via selectors
|
|
@@ -2,7 +2,7 @@ Searches files using powerful regex matching.
|
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
4
|
- Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)
|
|
5
|
-
- `paths`
|
|
5
|
+
- `paths` accepts either one string or an array of files, directories, globs, or internal URLs. Optional: when omitted or empty it searches the workspace root (`.`). Prefer scoping to specific paths when you know them.
|
|
6
6
|
- For multiple targets, pass an array with one target per element: `["src", "tests"]`.
|
|
7
7
|
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
|
|
8
8
|
</instruction>
|
|
@@ -38,6 +38,8 @@ Subagents have no conversation history. Every fact, file path, and direction the
|
|
|
38
38
|
- Pass large payloads via `local://<path>` URIs, not inline. {{#if contextEnabled}} (other than the context){{/if}}
|
|
39
39
|
{{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
|
|
40
40
|
- Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
|
|
41
|
+
- **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands — they cannot do it and the turn is wasted. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
|
|
42
|
+
- **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
|
|
41
43
|
</rules>
|
|
42
44
|
|
|
43
45
|
<parallelization>
|
|
@@ -71,7 +73,7 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
|
|
|
71
73
|
Agent spawning is disabled for this context.
|
|
72
74
|
{{else}}
|
|
73
75
|
{{#list agents join="\n"}}
|
|
74
|
-
# {{name}}
|
|
76
|
+
# {{name}}{{#if readOnly}} — READ-ONLY (no edit/write/exec tools){{/if}}
|
|
75
77
|
{{description}}
|
|
76
78
|
{{/list}}
|
|
77
79
|
{{/if}}
|
|
@@ -48,3 +48,11 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
|
|
|
48
48
|
# Append tasks to a phase
|
|
49
49
|
`{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
|
|
50
50
|
</examples>
|
|
51
|
+
|
|
52
|
+
<critical>
|
|
53
|
+
When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
|
|
54
|
+
- You MUST `init` the list with EVERY item as its own task before doing the work.
|
|
55
|
+
- Enumerate all of them;
|
|
56
|
+
- NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
|
|
57
|
+
The entire point is to remember every one.
|
|
58
|
+
</critical>
|
package/src/sdk.ts
CHANGED
|
@@ -1259,6 +1259,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1259
1259
|
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
|
1260
1260
|
getActiveModelString,
|
|
1261
1261
|
getPlanModeState: () => session?.getPlanModeState(),
|
|
1262
|
+
getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
|
|
1262
1263
|
getGoalModeState: () => session?.getGoalModeState(),
|
|
1263
1264
|
getGoalRuntime: () => session?.goalRuntime,
|
|
1264
1265
|
getUsageStatistics: () => sessionManager.getUsageStatistics(),
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
shouldCompact,
|
|
56
56
|
} from "@oh-my-pi/pi-agent-core/compaction";
|
|
57
57
|
import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
|
|
58
|
+
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
|
58
59
|
import type {
|
|
59
60
|
AssistantMessage,
|
|
60
61
|
Context,
|
|
@@ -163,6 +164,7 @@ import { parseTurnBudget } from "../modes/turn-budget";
|
|
|
163
164
|
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
|
164
165
|
import { computeNonMessageTokens } from "../modes/utils/context-usage";
|
|
165
166
|
import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
|
167
|
+
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
166
168
|
import type { PlanModeState } from "../plan-mode/state";
|
|
167
169
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
168
170
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
@@ -201,7 +203,7 @@ import type { CheckpointState } from "../tools/checkpoint";
|
|
|
201
203
|
import { outputMeta } from "../tools/output-meta";
|
|
202
204
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
203
205
|
import { isAutoQaEnabled } from "../tools/report-tool-issue";
|
|
204
|
-
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo
|
|
206
|
+
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
|
|
205
207
|
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
|
206
208
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
207
209
|
import { parseCommandArgs } from "../utils/command-args";
|
|
@@ -1805,21 +1807,21 @@ export class AgentSession {
|
|
|
1805
1807
|
if (toolName === "edit" && details?.path) {
|
|
1806
1808
|
this.#invalidateFileCacheForPath(details.path);
|
|
1807
1809
|
}
|
|
1808
|
-
if (toolName === "
|
|
1810
|
+
if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
|
|
1809
1811
|
this.setTodoPhases(details.phases);
|
|
1810
1812
|
}
|
|
1811
|
-
if (toolName === "
|
|
1813
|
+
if (toolName === "todo" && isError) {
|
|
1812
1814
|
const errorText = content?.find(part => part.type === "text")?.text;
|
|
1813
1815
|
const reminderText = [
|
|
1814
1816
|
"<system-reminder>",
|
|
1815
|
-
"
|
|
1816
|
-
errorText ? `Failure: ${errorText}` : "Failure:
|
|
1817
|
-
"Fix the todo payload and call
|
|
1817
|
+
"todo failed, so todo progress is not visible to the user.",
|
|
1818
|
+
errorText ? `Failure: ${errorText}` : "Failure: todo returned an error.",
|
|
1819
|
+
"Fix the todo payload and call todo again before continuing.",
|
|
1818
1820
|
"</system-reminder>",
|
|
1819
1821
|
].join("\n");
|
|
1820
1822
|
await this.sendCustomMessage(
|
|
1821
1823
|
{
|
|
1822
|
-
customType: "todo-
|
|
1824
|
+
customType: "todo-error-reminder",
|
|
1823
1825
|
content: reminderText,
|
|
1824
1826
|
display: false,
|
|
1825
1827
|
details: { toolName, errorText },
|
|
@@ -4992,7 +4994,7 @@ export class AgentSession {
|
|
|
4992
4994
|
// splice mutated canonical `#todoPhases` between tool calls, so the model
|
|
4993
4995
|
// observed phase totals shrinking ("5 → 4") after marking tasks done. The
|
|
4994
4996
|
// `tasks.todoClearDelay` setting is now inert; completed tasks survive
|
|
4995
|
-
// until the next explicit `
|
|
4997
|
+
// until the next explicit `todo` call removes them via `rm`/`drop`.
|
|
4996
4998
|
|
|
4997
4999
|
/**
|
|
4998
5000
|
* Abort current operation and wait for agent to become idle.
|
|
@@ -5658,9 +5660,20 @@ export class AgentSession {
|
|
|
5658
5660
|
// Compaction
|
|
5659
5661
|
// =========================================================================
|
|
5660
5662
|
|
|
5663
|
+
/**
|
|
5664
|
+
* Append plan-read protection to a prune/shake config so the active plan
|
|
5665
|
+
* file survives compaction alongside skill reads (the config defaults
|
|
5666
|
+
* already carry skill protection). The matcher reads the current plan
|
|
5667
|
+
* reference path at match time, so retitled plans are covered.
|
|
5668
|
+
*/
|
|
5669
|
+
#withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
|
|
5670
|
+
const planMatcher = createPlanReadMatcher(() => this.#planReferencePath);
|
|
5671
|
+
return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5661
5674
|
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
5662
5675
|
const branchEntries = this.sessionManager.getBranch();
|
|
5663
|
-
const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
|
|
5676
|
+
const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG));
|
|
5664
5677
|
if (result.prunedCount === 0) {
|
|
5665
5678
|
return undefined;
|
|
5666
5679
|
}
|
|
@@ -5741,7 +5754,7 @@ export class AgentSession {
|
|
|
5741
5754
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
|
|
5742
5755
|
}
|
|
5743
5756
|
|
|
5744
|
-
const config = opts.config ?? AGGRESSIVE_SHAKE_CONFIG;
|
|
5757
|
+
const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
|
|
5745
5758
|
const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
|
|
5746
5759
|
if (regions.length === 0) {
|
|
5747
5760
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
|
|
@@ -6540,16 +6553,16 @@ export class AgentSession {
|
|
|
6540
6553
|
return undefined;
|
|
6541
6554
|
}
|
|
6542
6555
|
|
|
6543
|
-
if (!this.#toolRegistry.has("
|
|
6544
|
-
logger.warn("Eager todo enforcement skipped because
|
|
6556
|
+
if (!this.#toolRegistry.has("todo")) {
|
|
6557
|
+
logger.warn("Eager todo enforcement skipped because todo is unavailable", {
|
|
6545
6558
|
activeToolNames: this.agent.state.tools.map(tool => tool.name),
|
|
6546
6559
|
});
|
|
6547
6560
|
return undefined;
|
|
6548
6561
|
}
|
|
6549
6562
|
|
|
6550
|
-
const
|
|
6551
|
-
if (!
|
|
6552
|
-
logger.warn("Eager todo enforcement skipped because the current model does not support forcing
|
|
6563
|
+
const todoToolChoice = buildNamedToolChoice("todo", this.model);
|
|
6564
|
+
if (!todoToolChoice) {
|
|
6565
|
+
logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo", {
|
|
6553
6566
|
modelApi: this.model?.api,
|
|
6554
6567
|
modelId: this.model?.id,
|
|
6555
6568
|
});
|
|
@@ -6567,7 +6580,7 @@ export class AgentSession {
|
|
|
6567
6580
|
attribution: "agent",
|
|
6568
6581
|
timestamp: Date.now(),
|
|
6569
6582
|
},
|
|
6570
|
-
toolChoice:
|
|
6583
|
+
toolChoice: todoToolChoice,
|
|
6571
6584
|
};
|
|
6572
6585
|
}
|
|
6573
6586
|
/**
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { toError } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
|
|
3
|
+
|
|
4
|
+
export interface SessionStorageIndexEntry {
|
|
5
|
+
path: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionStorageBackend {
|
|
11
|
+
init(): Promise<void>;
|
|
12
|
+
loadIndex(): Promise<Iterable<SessionStorageIndexEntry>>;
|
|
13
|
+
readFull(path: string): Promise<string | null>;
|
|
14
|
+
readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
|
|
15
|
+
writeFull(path: string, content: string, mtimeMs: number): Promise<void>;
|
|
16
|
+
append(path: string, line: string, mtimeMs: number): Promise<void>;
|
|
17
|
+
truncate(path: string, mtimeMs: number): Promise<void>;
|
|
18
|
+
remove(paths: string[]): Promise<void>;
|
|
19
|
+
move(src: string, dst: string, mtimeMs: number): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface IndexEntry {
|
|
23
|
+
size: number;
|
|
24
|
+
mtimeMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface EnqueueOptions {
|
|
28
|
+
trackDrain: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RESOLVED = Promise.resolve();
|
|
32
|
+
|
|
33
|
+
function enoent(p: string): NodeJS.ErrnoException {
|
|
34
|
+
const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
|
|
35
|
+
err.code = "ENOENT";
|
|
36
|
+
err.errno = -2;
|
|
37
|
+
err.path = p;
|
|
38
|
+
err.syscall = "open";
|
|
39
|
+
return err;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchesGlob(name: string, pattern: string): boolean {
|
|
43
|
+
if (pattern === "*") return true;
|
|
44
|
+
if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
|
|
45
|
+
return name === pattern;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function byteLength(text: string): number {
|
|
49
|
+
return Buffer.byteLength(text, "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeByteLimit(maxBytes: number): number {
|
|
53
|
+
if (!(maxBytes > 0)) return 0;
|
|
54
|
+
return Math.trunc(maxBytes);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uniquePaths(paths: readonly string[]): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const path of paths) {
|
|
61
|
+
if (seen.has(path)) continue;
|
|
62
|
+
seen.add(path);
|
|
63
|
+
out.push(path);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class IndexedSessionStorage implements SessionStorage {
|
|
69
|
+
readonly #backend: SessionStorageBackend;
|
|
70
|
+
readonly #index = new Map<string, IndexEntry>();
|
|
71
|
+
readonly #writers = new Set<IndexedSessionStorageWriter>();
|
|
72
|
+
readonly #pathTails = new Map<string, Promise<void>>();
|
|
73
|
+
readonly #pathPending = new Map<string, Promise<void>>();
|
|
74
|
+
readonly #drainPending = new Set<Promise<void>>();
|
|
75
|
+
#nextMtimeMs = 0;
|
|
76
|
+
#firstDrainError: Error | undefined;
|
|
77
|
+
|
|
78
|
+
constructor(backend: SessionStorageBackend) {
|
|
79
|
+
this.#backend = backend;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async initialize(): Promise<void> {
|
|
83
|
+
await this.#backend.init();
|
|
84
|
+
await this.refresh();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async refresh(): Promise<void> {
|
|
88
|
+
await this.drain();
|
|
89
|
+
const rows = await this.#backend.loadIndex();
|
|
90
|
+
this.#index.clear();
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
this.#setIndex(row.path, row.size, row.mtimeMs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async drain(): Promise<void> {
|
|
97
|
+
while (this.#drainPending.size > 0) {
|
|
98
|
+
await Promise.allSettled(this.#drainPending);
|
|
99
|
+
}
|
|
100
|
+
const error = this.#firstDrainError;
|
|
101
|
+
this.#firstDrainError = undefined;
|
|
102
|
+
if (error) throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ensureDirSync(_dir: string): void {
|
|
106
|
+
// Indexed backends are flat: directories are derived from key prefixes.
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
existsSync(path: string): boolean {
|
|
110
|
+
return this.#index.has(path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeTextSync(path: string, content: string): void {
|
|
114
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
115
|
+
this.#setIndex(path, byteLength(content), mtimeMs);
|
|
116
|
+
this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
statSync(path: string): SessionStorageStat {
|
|
120
|
+
const entry = this.#index.get(path);
|
|
121
|
+
if (!entry) throw enoent(path);
|
|
122
|
+
return {
|
|
123
|
+
size: entry.size,
|
|
124
|
+
mtimeMs: entry.mtimeMs,
|
|
125
|
+
mtime: new Date(entry.mtimeMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
listFilesSync(dir: string, pattern: string): string[] {
|
|
130
|
+
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
for (const path of this.#index.keys()) {
|
|
133
|
+
if (!path.startsWith(prefix)) continue;
|
|
134
|
+
const name = path.slice(prefix.length);
|
|
135
|
+
if (name.includes("/") || name.includes("\\")) continue;
|
|
136
|
+
if (!matchesGlob(name, pattern)) continue;
|
|
137
|
+
out.push(path);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exists(path: string): Promise<boolean> {
|
|
143
|
+
return Promise.resolve(this.existsSync(path));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async readText(path: string): Promise<string> {
|
|
147
|
+
if (!this.#index.has(path)) throw enoent(path);
|
|
148
|
+
await this.#awaitPath(path);
|
|
149
|
+
const content = await this.#backend.readFull(path);
|
|
150
|
+
if (content === null) throw enoent(path);
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
|
|
155
|
+
if (!this.#index.has(path)) throw enoent(path);
|
|
156
|
+
const prefixLimit = normalizeByteLimit(prefixBytes);
|
|
157
|
+
const suffixLimit = normalizeByteLimit(suffixBytes);
|
|
158
|
+
if (prefixLimit === 0 && suffixLimit === 0) return ["", ""];
|
|
159
|
+
await this.#awaitPath(path);
|
|
160
|
+
return this.#backend.readSlices(path, prefixLimit, suffixLimit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async writeText(path: string, content: string): Promise<void> {
|
|
164
|
+
await this.#awaitPath(path);
|
|
165
|
+
const previous = this.#index.get(path);
|
|
166
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
167
|
+
this.#setIndex(path, byteLength(content), mtimeMs);
|
|
168
|
+
try {
|
|
169
|
+
await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: false });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
this.#restoreIndex(path, previous);
|
|
172
|
+
throw toError(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async rename(src: string, dst: string): Promise<void> {
|
|
177
|
+
await this.#awaitPath(src);
|
|
178
|
+
await this.#awaitPath(dst);
|
|
179
|
+
const entry = this.#index.get(src);
|
|
180
|
+
if (!entry) throw enoent(src);
|
|
181
|
+
const dstPrevious = this.#index.get(dst);
|
|
182
|
+
this.#index.delete(src);
|
|
183
|
+
this.#index.set(dst, { ...entry });
|
|
184
|
+
try {
|
|
185
|
+
await this.#enqueuePaths([src, dst], () => this.#backend.move(src, dst, entry.mtimeMs), { trackDrain: false });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.#index.delete(dst);
|
|
188
|
+
this.#restoreIndex(dst, dstPrevious);
|
|
189
|
+
this.#index.set(src, entry);
|
|
190
|
+
throw toError(err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async unlink(path: string): Promise<void> {
|
|
195
|
+
await this.#awaitPath(path);
|
|
196
|
+
const previous = this.#index.get(path);
|
|
197
|
+
if (!previous) throw enoent(path);
|
|
198
|
+
this.#index.delete(path);
|
|
199
|
+
try {
|
|
200
|
+
await this.#enqueuePath(path, () => this.#backend.remove([path]), { trackDrain: false });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.#index.set(path, previous);
|
|
203
|
+
throw toError(err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
208
|
+
await this.#awaitPath(sessionPath);
|
|
209
|
+
const sessionEntry = this.#index.get(sessionPath);
|
|
210
|
+
if (!sessionEntry) throw enoent(sessionPath);
|
|
211
|
+
|
|
212
|
+
const artifactsDir = sessionPath.slice(0, -6);
|
|
213
|
+
const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
|
|
214
|
+
const paths = [sessionPath];
|
|
215
|
+
for (const key of this.#index.keys()) {
|
|
216
|
+
if (key.startsWith(prefix)) paths.push(key);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const path of paths) await this.#awaitPath(path);
|
|
220
|
+
|
|
221
|
+
const previous = new Map<string, IndexEntry>();
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
const entry = this.#index.get(path);
|
|
224
|
+
if (entry) previous.set(path, entry);
|
|
225
|
+
this.#index.delete(path);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await this.#enqueuePaths(paths, () => this.#backend.remove(paths), { trackDrain: false });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
for (const [path, entry] of previous) this.#index.set(path, entry);
|
|
232
|
+
throw toError(err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
|
|
237
|
+
const writer = new IndexedSessionStorageWriter(this, path, options);
|
|
238
|
+
this.#writers.add(writer);
|
|
239
|
+
return writer;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_writerClosed(writer: IndexedSessionStorageWriter): void {
|
|
243
|
+
this.#writers.delete(writer);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_truncateForWriter(path: string): number {
|
|
247
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
248
|
+
this.#setIndex(path, 0, mtimeMs);
|
|
249
|
+
return mtimeMs;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_queueTruncate(path: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
|
|
253
|
+
return this.#enqueuePath(
|
|
254
|
+
path,
|
|
255
|
+
async () => {
|
|
256
|
+
const error = getError?.();
|
|
257
|
+
if (error) throw error;
|
|
258
|
+
await this.#backend.truncate(path, mtimeMs);
|
|
259
|
+
},
|
|
260
|
+
{ trackDrain: true },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_appendForWriter(path: string, line: string): number {
|
|
265
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
266
|
+
const existing = this.#index.get(path);
|
|
267
|
+
const size = (existing?.size ?? 0) + byteLength(line);
|
|
268
|
+
this.#setIndex(path, size, mtimeMs);
|
|
269
|
+
return mtimeMs;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_queueAppend(path: string, line: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
|
|
273
|
+
return this.#enqueuePath(
|
|
274
|
+
path,
|
|
275
|
+
async () => {
|
|
276
|
+
const error = getError?.();
|
|
277
|
+
if (error) throw error;
|
|
278
|
+
await this.#backend.append(path, line, mtimeMs);
|
|
279
|
+
},
|
|
280
|
+
{ trackDrain: true },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#restoreIndex(path: string, entry: IndexEntry | undefined): void {
|
|
285
|
+
if (entry) {
|
|
286
|
+
this.#index.set(path, entry);
|
|
287
|
+
} else {
|
|
288
|
+
this.#index.delete(path);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#setIndex(path: string, size: number, mtimeMs: number): void {
|
|
293
|
+
this.#index.set(path, { size, mtimeMs });
|
|
294
|
+
if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#allocMtimeMs(): number {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
|
|
300
|
+
this.#nextMtimeMs = next;
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#enqueuePath(path: string, task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
|
|
305
|
+
return this.#enqueuePaths([path], task, options);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#enqueuePaths(paths: readonly string[], task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
|
|
309
|
+
const unique = uniquePaths(paths);
|
|
310
|
+
const previous = unique.map(path => this.#pathTails.get(path) ?? RESOLVED);
|
|
311
|
+
const operation = Promise.all(previous).then(task);
|
|
312
|
+
const tracked = operation.catch(err => {
|
|
313
|
+
const error = toError(err);
|
|
314
|
+
if (options.trackDrain && !this.#firstDrainError) this.#firstDrainError = error;
|
|
315
|
+
throw error;
|
|
316
|
+
});
|
|
317
|
+
const tail = tracked.catch(() => {});
|
|
318
|
+
for (const path of unique) {
|
|
319
|
+
this.#pathTails.set(path, tail);
|
|
320
|
+
this.#pathPending.set(path, tracked);
|
|
321
|
+
}
|
|
322
|
+
tail.finally(() => {
|
|
323
|
+
for (const path of unique) {
|
|
324
|
+
if (this.#pathTails.get(path) === tail) this.#pathTails.delete(path);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
tracked
|
|
328
|
+
.finally(() => {
|
|
329
|
+
for (const path of unique) {
|
|
330
|
+
if (this.#pathPending.get(path) === tracked) this.#pathPending.delete(path);
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.catch(() => {});
|
|
334
|
+
tracked.catch(() => {});
|
|
335
|
+
if (options.trackDrain) {
|
|
336
|
+
this.#drainPending.add(tracked);
|
|
337
|
+
tracked
|
|
338
|
+
.finally(() => {
|
|
339
|
+
this.#drainPending.delete(tracked);
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
return tracked;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#awaitPath(path: string): Promise<void> {
|
|
347
|
+
return this.#pathPending.get(path) ?? RESOLVED;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
class IndexedSessionStorageWriter implements SessionStorageWriter {
|
|
352
|
+
#storage: IndexedSessionStorage;
|
|
353
|
+
#path: string;
|
|
354
|
+
#closed = false;
|
|
355
|
+
#error: Error | undefined;
|
|
356
|
+
#onError: ((err: Error) => void) | undefined;
|
|
357
|
+
#pendingChain: Promise<void> = Promise.resolve();
|
|
358
|
+
|
|
359
|
+
constructor(
|
|
360
|
+
storage: IndexedSessionStorage,
|
|
361
|
+
path: string,
|
|
362
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
|
|
363
|
+
) {
|
|
364
|
+
this.#storage = storage;
|
|
365
|
+
this.#path = path;
|
|
366
|
+
this.#onError = options?.onError;
|
|
367
|
+
if ((options?.flags ?? "a") === "w") {
|
|
368
|
+
const mtimeMs = storage._truncateForWriter(path);
|
|
369
|
+
this.#trackPromise(storage._queueTruncate(path, mtimeMs, () => this.#error));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#recordError(err: unknown): Error {
|
|
374
|
+
const error = toError(err);
|
|
375
|
+
if (!this.#error) this.#error = error;
|
|
376
|
+
this.#onError?.(error);
|
|
377
|
+
return error;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#trackPromise(promise: Promise<void>): Promise<void> {
|
|
381
|
+
const next = this.#pendingChain.then(async () => {
|
|
382
|
+
if (this.#error) throw this.#error;
|
|
383
|
+
try {
|
|
384
|
+
await promise;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
throw this.#recordError(err);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
this.#pendingChain = next.catch(() => {});
|
|
390
|
+
return next;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
writeLineSync(line: string): void {
|
|
394
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
395
|
+
if (this.#error) throw this.#error;
|
|
396
|
+
const mtimeMs = this.#storage._appendForWriter(this.#path, line);
|
|
397
|
+
this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async writeLine(line: string): Promise<void> {
|
|
401
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
402
|
+
if (this.#error) throw this.#error;
|
|
403
|
+
const mtimeMs = this.#storage._appendForWriter(this.#path, line);
|
|
404
|
+
await this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async flush(): Promise<void> {
|
|
408
|
+
if (this.#error) throw this.#error;
|
|
409
|
+
await this.#pendingChain;
|
|
410
|
+
if (this.#error) throw this.#error;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async fsync(): Promise<void> {
|
|
414
|
+
await this.flush();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async close(): Promise<void> {
|
|
418
|
+
if (this.#closed) return;
|
|
419
|
+
this.#closed = true;
|
|
420
|
+
try {
|
|
421
|
+
await this.flush();
|
|
422
|
+
} finally {
|
|
423
|
+
this.#storage._writerClosed(this);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getError(): Error | undefined {
|
|
428
|
+
return this.#error;
|
|
429
|
+
}
|
|
430
|
+
}
|