@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2
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 +79 -0
- package/examples/extensions/plan-mode.ts +0 -1
- package/package.json +10 -10
- package/scripts/build-binary.ts +5 -0
- package/src/autoresearch/helpers.ts +17 -0
- package/src/autoresearch/tools/log-experiment.ts +9 -17
- package/src/autoresearch/tools/run-experiment.ts +2 -17
- package/src/capability/skill.ts +7 -0
- package/src/cli/list-models.ts +1 -1
- package/src/cli/shell-cli.ts +3 -13
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +10 -29
- package/src/commands/commit.ts +10 -0
- package/src/commit/agentic/tools/propose-changelog.ts +8 -1
- package/src/commit/analysis/conventional.ts +8 -66
- package/src/commit/map-reduce/reduce-phase.ts +6 -65
- package/src/commit/pipeline.ts +2 -2
- package/src/commit/shared-llm.ts +89 -0
- package/src/config/config-file.ts +210 -0
- package/src/config/model-equivalence.ts +8 -11
- package/src/config/model-registry.ts +44 -3
- package/src/config/model-resolver.ts +1 -4
- package/src/config/settings-schema.ts +82 -1
- package/src/config/settings.ts +1 -1
- package/src/config.ts +3 -219
- package/src/discovery/claude-plugins.ts +19 -7
- package/src/edit/renderer.ts +7 -1
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/shared/rewrite-imports.ts +2 -2
- package/src/eval/py/executor.ts +5 -0
- package/src/eval/py/runner.py +42 -11
- package/src/eval/py/runtime.ts +1 -0
- package/src/exa/factory.ts +2 -2
- package/src/exa/mcp-client.ts +74 -1
- package/src/exec/bash-executor.ts +5 -1
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -11
- package/src/extensibility/extensions/get-commands-handler.ts +77 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +89 -223
- package/src/extensibility/hooks/types.ts +89 -314
- package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
- package/src/extensibility/shared-events.ts +343 -0
- package/src/extensibility/skills.ts +9 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/runtime.ts +500 -0
- package/src/goals/state.ts +37 -0
- package/src/goals/tools/goal-tool.ts +237 -0
- package/src/hashline/anchors.ts +2 -2
- package/src/hashline/input.ts +2 -1
- package/src/hashline/parser.ts +27 -3
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/agent-protocol.ts +1 -20
- package/src/internal-urls/artifact-protocol.ts +1 -19
- package/src/internal-urls/docs-index.generated.ts +11 -12
- package/src/internal-urls/registry-helpers.ts +25 -0
- package/src/internal-urls/router.ts +8 -0
- package/src/internal-urls/types.ts +21 -0
- package/src/lsp/config.ts +15 -6
- package/src/lsp/defaults.json +6 -2
- package/src/main.ts +11 -2
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/modes/acp/acp-agent.ts +327 -95
- package/src/modes/components/assistant-message.ts +14 -8
- package/src/modes/components/bash-execution.ts +24 -63
- package/src/modes/components/custom-message.ts +14 -40
- package/src/modes/components/eval-execution.ts +27 -57
- package/src/modes/components/execution-shared.ts +102 -0
- package/src/modes/components/hook-message.ts +17 -49
- package/src/modes/components/mcp-add-wizard.ts +26 -5
- package/src/modes/components/message-frame.ts +88 -0
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +6 -2
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +93 -8
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +28 -10
- package/src/modes/components/tool-execution.ts +7 -8
- package/src/modes/controllers/command-controller-shared.ts +108 -0
- package/src/modes/controllers/command-controller.ts +13 -4
- package/src/modes/controllers/event-controller.ts +36 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +13 -0
- package/src/modes/controllers/mcp-command-controller.ts +56 -61
- package/src/modes/controllers/ssh-command-controller.ts +18 -57
- package/src/modes/interactive-mode.ts +624 -52
- package/src/modes/print-mode.ts +16 -86
- package/src/modes/rpc/host-uris.ts +235 -0
- package/src/modes/rpc/rpc-mode.ts +41 -88
- package/src/modes/rpc/rpc-types.ts +57 -0
- package/src/modes/runtime-init.ts +116 -0
- package/src/modes/theme/defaults/dark-poimandres.json +3 -0
- package/src/modes/theme/defaults/light-poimandres.json +3 -0
- package/src/modes/theme/theme.ts +24 -6
- package/src/modes/types.ts +14 -3
- package/src/modes/utils/context-usage.ts +13 -13
- package/src/modes/utils/ui-helpers.ts +10 -3
- package/src/plan-mode/approved-plan.ts +35 -1
- package/src/prompts/goals/goal-budget-limit.md +16 -0
- package/src/prompts/goals/goal-continuation.md +28 -0
- package/src/prompts/goals/goal-mode-active.md +23 -0
- package/src/prompts/system/plan-mode-active.md +5 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
- package/src/prompts/tools/bash.md +6 -0
- package/src/prompts/tools/github.md +4 -4
- package/src/prompts/tools/goal.md +13 -0
- package/src/prompts/tools/hashline.md +101 -117
- package/src/prompts/tools/read.md +55 -36
- package/src/prompts/tools/resolve.md +6 -5
- package/src/sdk.ts +12 -5
- package/src/session/agent-session.ts +428 -106
- package/src/session/blob-store.ts +36 -3
- package/src/session/messages.ts +67 -2
- package/src/session/session-manager.ts +131 -12
- package/src/session/session-storage.ts +33 -15
- package/src/session/streaming-output.ts +309 -13
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/ssh/ssh-executor.ts +5 -0
- package/src/system-prompt.ts +4 -2
- package/src/task/discovery.ts +5 -2
- package/src/task/executor.ts +19 -8
- package/src/task/index.ts +3 -0
- package/src/task/render.ts +21 -15
- package/src/task/types.ts +4 -0
- package/src/tools/ast-edit.ts +21 -120
- package/src/tools/ast-grep.ts +21 -119
- package/src/tools/bash-command-fixup.ts +47 -0
- package/src/tools/bash-interactive.ts +9 -1
- package/src/tools/bash.ts +66 -19
- package/src/tools/browser/attach.ts +3 -3
- package/src/tools/browser/launch.ts +81 -18
- package/src/tools/browser/registry.ts +1 -5
- package/src/tools/browser/render.ts +2 -2
- package/src/tools/browser/tab-supervisor.ts +51 -14
- package/src/tools/conflict-detect.ts +15 -4
- package/src/tools/eval.ts +12 -2
- package/src/tools/find.ts +20 -38
- package/src/tools/gh.ts +44 -10
- package/src/tools/index.ts +22 -11
- package/src/tools/inspect-image.ts +3 -10
- package/src/tools/job.ts +16 -7
- package/src/tools/output-meta.ts +202 -37
- package/src/tools/path-utils.ts +125 -2
- package/src/tools/read.ts +548 -237
- package/src/tools/render-utils.ts +92 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +72 -44
- package/src/tools/search.ts +120 -186
- package/src/tools/ssh.ts +3 -2
- package/src/tools/write.ts +64 -9
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/image-loading.ts +7 -3
- package/src/utils/image-resize.ts +32 -43
- package/src/vim/parser.ts +0 -17
- package/src/vim/render.ts +1 -1
- package/src/vim/types.ts +1 -1
- package/src/web/search/providers/anthropic.ts +5 -0
- package/src/web/search/providers/exa.ts +3 -0
- package/src/web/search/providers/gemini.ts +40 -95
- package/src/web/search/providers/jina.ts +5 -2
- package/src/web/search/providers/zai.ts +5 -2
- package/src/prompts/tools/exit-plan-mode.md +0 -6
- package/src/tools/exit-plan-mode.ts +0 -97
- package/src/utils/fuzzy.ts +0 -108
- package/src/utils/image-convert.ts +0 -27
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import * as fs from "node:fs
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
5
|
|
|
@@ -25,7 +26,7 @@ export class BlobStore {
|
|
|
25
26
|
* @returns SHA-256 hex hash of the data
|
|
26
27
|
*/
|
|
27
28
|
async put(data: Buffer): Promise<BlobPutResult> {
|
|
28
|
-
const hash = new Bun.
|
|
29
|
+
const hash = new Bun.SHA256().update(data).digest("hex");
|
|
29
30
|
const blobPath = path.join(this.dir, hash);
|
|
30
31
|
const result = {
|
|
31
32
|
hash,
|
|
@@ -39,6 +40,26 @@ export class BlobStore {
|
|
|
39
40
|
return result;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Synchronous variant of {@link put}. Use on persistence hot paths where the caller
|
|
45
|
+
* cannot afford the microtask hops of the async version (e.g. OOM-safe session writes).
|
|
46
|
+
* Returns once the bytes are in the kernel page cache.
|
|
47
|
+
*/
|
|
48
|
+
putSync(data: Buffer): BlobPutResult {
|
|
49
|
+
const hash = new Bun.SHA256().update(data).digest("hex");
|
|
50
|
+
const blobPath = path.join(this.dir, hash);
|
|
51
|
+
const result = {
|
|
52
|
+
hash,
|
|
53
|
+
path: blobPath,
|
|
54
|
+
get ref() {
|
|
55
|
+
return `${BLOB_PREFIX}${hash}`;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(blobPath, data);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
/** Read blob by hash, returns Buffer or null if not found. */
|
|
43
64
|
async get(hash: string): Promise<Buffer | null> {
|
|
44
65
|
const blobPath = path.join(this.dir, hash);
|
|
@@ -55,7 +76,7 @@ export class BlobStore {
|
|
|
55
76
|
/** Check if a blob exists. */
|
|
56
77
|
async has(hash: string): Promise<boolean> {
|
|
57
78
|
try {
|
|
58
|
-
await
|
|
79
|
+
await fsp.access(path.join(this.dir, hash));
|
|
59
80
|
return true;
|
|
60
81
|
} catch {
|
|
61
82
|
return false;
|
|
@@ -89,6 +110,12 @@ export async function externalizeImageDataUrl(blobStore: BlobStore, dataUrl: str
|
|
|
89
110
|
return ref;
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
/** Synchronous variant of {@link externalizeImageDataUrl}. */
|
|
114
|
+
export function externalizeImageDataUrlSync(blobStore: BlobStore, dataUrl: string): string {
|
|
115
|
+
if (isBlobRef(dataUrl)) return dataUrl;
|
|
116
|
+
return blobStore.putSync(Buffer.from(dataUrl, "utf8")).ref;
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/**
|
|
93
120
|
* Externalize an image's base64 data to the blob store, returning a blob reference.
|
|
94
121
|
* If the data is already a blob reference, returns it unchanged.
|
|
@@ -100,6 +127,12 @@ export async function externalizeImageData(blobStore: BlobStore, base64Data: str
|
|
|
100
127
|
return ref;
|
|
101
128
|
}
|
|
102
129
|
|
|
130
|
+
/** Synchronous variant of {@link externalizeImageData}. */
|
|
131
|
+
export function externalizeImageDataSync(blobStore: BlobStore, base64Data: string): string {
|
|
132
|
+
if (isBlobRef(base64Data)) return base64Data;
|
|
133
|
+
return blobStore.putSync(Buffer.from(base64Data, "base64")).ref;
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
/**
|
|
104
137
|
* Resolve an externalized provider image data URL back to its original string.
|
|
105
138
|
* If the data is not a blob reference, returns it unchanged.
|
package/src/session/messages.ts
CHANGED
|
@@ -30,6 +30,72 @@ export interface SkillPromptDetails {
|
|
|
30
30
|
path: string;
|
|
31
31
|
args?: string;
|
|
32
32
|
lineCount: number;
|
|
33
|
+
/** Internal: tag used by AgentSession to remove the pending-display chip
|
|
34
|
+
* from `#steeringMessages` / `#followUpMessages` when the agent consumes
|
|
35
|
+
* this message. Not surfaced to renderers; the `__` prefix signals
|
|
36
|
+
* "private". Optional — non-streaming skill prompts never set it. Stripped
|
|
37
|
+
* from persisted `details` by `SessionManager.appendCustomMessageEntry`
|
|
38
|
+
* via the `INTERNAL_DETAILS_FIELDS` allowlist below. */
|
|
39
|
+
__pendingDisplayTag?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sentinel value for `AssistantMessage.errorMessage` indicating that the abort
|
|
43
|
+
* was an *expected internal transition* (plan-mode → execution compaction)
|
|
44
|
+
* and must NOT surface as a red "Operation aborted" line. Distinct from
|
|
45
|
+
* `undefined` (default) so user-cancel aborts with no errorMessage still
|
|
46
|
+
* render normally. Persists through SessionManager so history replay
|
|
47
|
+
* branches identically.
|
|
48
|
+
*
|
|
49
|
+
* Consumers: `AgentSession.#handleAgentEvent` (stamper) writes this value;
|
|
50
|
+
* `EventController.#handleMessageEnd`, `AssistantMessageComponent`,
|
|
51
|
+
* `ui-helpers.addMessageToChat` (renderers), `SessionObserverOverlay
|
|
52
|
+
* #buildTranscriptLines`, `runPrintMode`, and `AcpAgent#replayAssistantMessage`
|
|
53
|
+
* (fallback error emission) read it via `isSilentAbort`. */
|
|
54
|
+
export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
|
|
55
|
+
|
|
56
|
+
/** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather
|
|
57
|
+
* than string-comparing inline so refactors to the marker constant (e.g.,
|
|
58
|
+
* namespacing changes) propagate through every consumer in lockstep. */
|
|
59
|
+
export function isSilentAbort(errorMessage: string | undefined): boolean {
|
|
60
|
+
return errorMessage === SILENT_ABORT_MARKER;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Extract the optional `__pendingDisplayTag` field from a CustomMessage's
|
|
64
|
+
* `details` blob. Safe over `unknown`; returns undefined when the field is
|
|
65
|
+
* absent or non-string. */
|
|
66
|
+
export function readPendingDisplayTag(details: unknown): string | undefined {
|
|
67
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
68
|
+
const candidate = (details as { __pendingDisplayTag?: unknown }).__pendingDisplayTag;
|
|
69
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Explicit allowlist of `details` field names that are AgentSession-internal
|
|
73
|
+
* transient bookkeeping and MUST be removed before SessionManager persists
|
|
74
|
+
* the CustomMessageEntry to disk. Scoped intentionally narrow: only fields
|
|
75
|
+
* declared here are stripped. Adding a new entry is a deliberate, reviewed
|
|
76
|
+
* change — unrelated future payload fields are never silently dropped. */
|
|
77
|
+
export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag"] as const;
|
|
78
|
+
|
|
79
|
+
/** Return a `details` copy with every key in `INTERNAL_DETAILS_FIELDS`
|
|
80
|
+
* removed. Returns the input unchanged when there is nothing to strip
|
|
81
|
+
* (null/non-object, or no listed fields present) so callers don't pay a
|
|
82
|
+
* clone cost on the common path. */
|
|
83
|
+
export function stripInternalDetailsFields<T>(details: T | undefined): T | undefined {
|
|
84
|
+
if (details == null || typeof details !== "object") return details;
|
|
85
|
+
const obj = details as Record<string, unknown>;
|
|
86
|
+
let hit = false;
|
|
87
|
+
for (const key of INTERNAL_DETAILS_FIELDS) {
|
|
88
|
+
if (key in obj) {
|
|
89
|
+
hit = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!hit) return details;
|
|
94
|
+
const cleaned: Record<string, unknown> = { ...obj };
|
|
95
|
+
for (const key of INTERNAL_DETAILS_FIELDS) {
|
|
96
|
+
delete cleaned[key];
|
|
97
|
+
}
|
|
98
|
+
return cleaned as T;
|
|
33
99
|
}
|
|
34
100
|
|
|
35
101
|
function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
|
|
@@ -364,8 +430,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
364
430
|
attribution: m.attribution ?? "agent",
|
|
365
431
|
};
|
|
366
432
|
default:
|
|
367
|
-
|
|
368
|
-
const _exhaustiveCheck: never = m;
|
|
433
|
+
m satisfies never;
|
|
369
434
|
return undefined;
|
|
370
435
|
}
|
|
371
436
|
})
|
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
type BlobPutResult,
|
|
32
32
|
BlobStore,
|
|
33
33
|
externalizeImageData,
|
|
34
|
+
externalizeImageDataSync,
|
|
34
35
|
externalizeImageDataUrl,
|
|
36
|
+
externalizeImageDataUrlSync,
|
|
35
37
|
isBlobRef,
|
|
36
38
|
isImageDataUrl,
|
|
37
39
|
resolveImageData,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
type HookMessage,
|
|
48
50
|
type PythonExecutionMessage,
|
|
49
51
|
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
52
|
+
stripInternalDetailsFields,
|
|
50
53
|
} from "./messages";
|
|
51
54
|
import type { SessionStorage, SessionStorageWriter } from "./session-storage";
|
|
52
55
|
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
@@ -1127,6 +1130,92 @@ async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore
|
|
|
1127
1130
|
return truncateForPersistence(entry, blobStore);
|
|
1128
1131
|
}
|
|
1129
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Synchronous variant of {@link truncateForPersistence}.
|
|
1135
|
+
*
|
|
1136
|
+
* The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`,
|
|
1137
|
+
* one microtask hop per nested node — is pure waste for entries without image blobs
|
|
1138
|
+
* (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL
|
|
1139
|
+
* landing right after `_persist` returns cannot lose the entry. Image externalization
|
|
1140
|
+
* still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the
|
|
1141
|
+
* blob bytes are in the kernel page cache before the JSONL line referencing them is
|
|
1142
|
+
* written.
|
|
1143
|
+
*/
|
|
1144
|
+
function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
|
|
1145
|
+
if (obj === null || obj === undefined) return obj;
|
|
1146
|
+
|
|
1147
|
+
if (typeof obj === "string") {
|
|
1148
|
+
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1149
|
+
return externalizeImageDataUrlSync(blobStore, obj);
|
|
1150
|
+
}
|
|
1151
|
+
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1152
|
+
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1153
|
+
return "";
|
|
1154
|
+
}
|
|
1155
|
+
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1156
|
+
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1157
|
+
}
|
|
1158
|
+
return obj;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (Array.isArray(obj)) {
|
|
1162
|
+
let changed = false;
|
|
1163
|
+
const result: unknown[] = new Array(obj.length);
|
|
1164
|
+
for (let i = 0; i < obj.length; i++) {
|
|
1165
|
+
const item = obj[i];
|
|
1166
|
+
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
1167
|
+
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
1168
|
+
changed = true;
|
|
1169
|
+
const blobRef = externalizeImageDataSync(blobStore, item.data);
|
|
1170
|
+
result[i] = { ...item, data: blobRef };
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const newItem = truncateForPersistenceSync(item, blobStore, key);
|
|
1175
|
+
if (newItem !== item) changed = true;
|
|
1176
|
+
result[i] = newItem;
|
|
1177
|
+
}
|
|
1178
|
+
return changed ? result : obj;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (typeof obj === "object") {
|
|
1182
|
+
let changed = false;
|
|
1183
|
+
const entries: Array<readonly [string, unknown]> = [];
|
|
1184
|
+
for (const [childKey, value] of Object.entries(obj)) {
|
|
1185
|
+
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1186
|
+
changed = true;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const newValue = truncateForPersistenceSync(value, blobStore, childKey);
|
|
1190
|
+
if (newValue !== value) changed = true;
|
|
1191
|
+
entries.push([childKey, newValue]);
|
|
1192
|
+
}
|
|
1193
|
+
if (!changed) return obj;
|
|
1194
|
+
|
|
1195
|
+
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1196
|
+
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1197
|
+
if (
|
|
1198
|
+
contentEntry &&
|
|
1199
|
+
typeof contentEntry[1] === "string" &&
|
|
1200
|
+
lineCountEntry &&
|
|
1201
|
+
typeof lineCountEntry[1] === "number"
|
|
1202
|
+
) {
|
|
1203
|
+
const content = contentEntry[1];
|
|
1204
|
+
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1205
|
+
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1206
|
+
);
|
|
1207
|
+
return Object.fromEntries(updatedEntries);
|
|
1208
|
+
}
|
|
1209
|
+
return Object.fromEntries(entries);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return obj;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
1216
|
+
return truncateForPersistenceSync(entry, blobStore) as FileEntry;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1130
1219
|
class NdjsonFileWriter {
|
|
1131
1220
|
#writer: SessionStorageWriter;
|
|
1132
1221
|
#closed = false;
|
|
@@ -1180,6 +1269,26 @@ class NdjsonFileWriter {
|
|
|
1180
1269
|
return this.#enqueue(() => this.#writeLine(line));
|
|
1181
1270
|
}
|
|
1182
1271
|
|
|
1272
|
+
/**
|
|
1273
|
+
* Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed
|
|
1274
|
+
* the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync.
|
|
1275
|
+
*
|
|
1276
|
+
* Callers MUST NOT mix this with pending async `write()` calls on the same writer:
|
|
1277
|
+
* the async path is queued through `#pendingWrites`, but this method bypasses the
|
|
1278
|
+
* queue. Use only when no concurrent async write is in flight (the session-manager
|
|
1279
|
+
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
|
|
1280
|
+
*/
|
|
1281
|
+
writeSync(entry: FileEntry): void {
|
|
1282
|
+
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1283
|
+
if (this.#error) throw this.#error;
|
|
1284
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
1285
|
+
try {
|
|
1286
|
+
this.#writer.writeLineSync(line);
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
throw this.#recordError(err);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1183
1292
|
/** Flush all buffered data to disk. Waits for all queued writes. */
|
|
1184
1293
|
async flush(): Promise<void> {
|
|
1185
1294
|
if (this.#closed) return;
|
|
@@ -2332,20 +2441,27 @@ export class SessionManager {
|
|
|
2332
2441
|
}
|
|
2333
2442
|
|
|
2334
2443
|
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
// caller intentionally fires-and-forgets, so swallow the awaited rejection
|
|
2444
|
+
// Cold path: rewrite the whole file atomically. Async — the writer is
|
|
2445
|
+
// closed/reopened and every entry is re-prepared. Errors flow through
|
|
2446
|
+
// `#persistChain` → `#recordPersistError`; we swallow the rejection
|
|
2339
2447
|
// here to avoid an unhandled rejection when the persist dir races with
|
|
2340
2448
|
// test-level tempDir cleanup.
|
|
2341
2449
|
this.#rewriteFile().catch(() => {});
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// Hot path: synchronously truncate + append. `fs.writeSync` returns once the
|
|
2454
|
+
// bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
|
|
2455
|
+
// landing immediately after this call. Image externalization (rare) runs via
|
|
2456
|
+
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
2457
|
+
// line referencing them is written.
|
|
2458
|
+
try {
|
|
2459
|
+
const writer = this.#ensurePersistWriter();
|
|
2460
|
+
if (!writer) return;
|
|
2461
|
+
const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
|
|
2462
|
+
writer.writeSync(persistedEntry);
|
|
2463
|
+
} catch (err) {
|
|
2464
|
+
this.#recordPersistError(err);
|
|
2349
2465
|
}
|
|
2350
2466
|
}
|
|
2351
2467
|
|
|
@@ -2544,7 +2660,10 @@ export class SessionManager {
|
|
|
2544
2660
|
customType,
|
|
2545
2661
|
content,
|
|
2546
2662
|
display,
|
|
2547
|
-
|
|
2663
|
+
// Drop AgentSession-internal transient fields (allowlist in
|
|
2664
|
+
// `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
|
|
2665
|
+
// chokepoint covers every CustomMessage write path.
|
|
2666
|
+
details: stripInternalDetailsFields(details),
|
|
2548
2667
|
attribution,
|
|
2549
2668
|
id: generateId(this.#byId),
|
|
2550
2669
|
parentId: this.#leafId,
|
|
@@ -13,6 +13,14 @@ export interface SessionStorageStat {
|
|
|
13
13
|
|
|
14
14
|
export interface SessionStorageWriter {
|
|
15
15
|
writeLine(line: string): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Synchronously append a single line. Returns once the bytes are handed to the kernel
|
|
18
|
+
* (page cache), so the data survives a non-graceful process death (OOM, SIGKILL, etc.)
|
|
19
|
+
* even though it has not yet been fsynced to the underlying disk.
|
|
20
|
+
*
|
|
21
|
+
* `line` MUST already include the trailing newline. Throws synchronously on I/O error.
|
|
22
|
+
*/
|
|
23
|
+
writeLineSync(line: string): void;
|
|
16
24
|
flush(): Promise<void>;
|
|
17
25
|
fsync(): Promise<void>;
|
|
18
26
|
close(): Promise<void>;
|
|
@@ -23,6 +31,7 @@ export interface SessionStorage {
|
|
|
23
31
|
ensureDirSync(dir: string): void;
|
|
24
32
|
existsSync(path: string): boolean;
|
|
25
33
|
writeTextSync(path: string, content: string): void;
|
|
34
|
+
readTextSync(path: string): string;
|
|
26
35
|
statSync(path: string): SessionStorageStat;
|
|
27
36
|
listFilesSync(dir: string, pattern: string): string[];
|
|
28
37
|
|
|
@@ -72,7 +81,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
72
81
|
return error;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
|
|
84
|
+
writeLineSync(line: string): void {
|
|
76
85
|
if (this.#closed) throw new Error("Writer closed");
|
|
77
86
|
if (this.#error) throw this.#error;
|
|
78
87
|
try {
|
|
@@ -90,6 +99,10 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
async writeLine(line: string): Promise<void> {
|
|
103
|
+
this.writeLineSync(line);
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
async flush(): Promise<void> {
|
|
94
107
|
if (this.#error) throw this.#error;
|
|
95
108
|
// OS buffers are flushed on fsync, nothing to do here
|
|
@@ -138,6 +151,10 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
138
151
|
fs.writeFileSync(fpath, content);
|
|
139
152
|
}
|
|
140
153
|
|
|
154
|
+
readTextSync(fpath: string): string {
|
|
155
|
+
return fs.readFileSync(fpath, "utf-8");
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
statSync(path: string): SessionStorageStat {
|
|
142
159
|
const stats = fs.statSync(path);
|
|
143
160
|
return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
|
|
@@ -230,7 +247,6 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
230
247
|
#closed = false;
|
|
231
248
|
#error: Error | undefined;
|
|
232
249
|
#onError: ((err: Error) => void) | undefined;
|
|
233
|
-
#ready: Promise<void>;
|
|
234
250
|
|
|
235
251
|
constructor(
|
|
236
252
|
storage: MemorySessionStorage,
|
|
@@ -240,12 +256,8 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
240
256
|
this.#storage = storage;
|
|
241
257
|
this.#path = path;
|
|
242
258
|
this.#onError = options?.onError;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
async #initialize(flags: "a" | "w"): Promise<void> {
|
|
247
|
-
if (flags === "w") {
|
|
248
|
-
await this.#storage.writeText(this.#path, "");
|
|
259
|
+
if ((options?.flags ?? "a") === "w") {
|
|
260
|
+
this.#storage.writeTextSync(path, "");
|
|
249
261
|
}
|
|
250
262
|
}
|
|
251
263
|
|
|
@@ -256,32 +268,32 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
256
268
|
return error;
|
|
257
269
|
}
|
|
258
270
|
|
|
259
|
-
|
|
271
|
+
writeLineSync(line: string): void {
|
|
260
272
|
if (this.#closed) throw new Error("Writer closed");
|
|
261
|
-
await this.#ready;
|
|
262
273
|
if (this.#error) throw this.#error;
|
|
263
274
|
try {
|
|
264
|
-
const existing = this.#storage.existsSync(this.#path) ?
|
|
265
|
-
|
|
275
|
+
const existing = this.#storage.existsSync(this.#path) ? this.#storage.readTextSync(this.#path) : "";
|
|
276
|
+
this.#storage.writeTextSync(this.#path, `${existing}${line}`);
|
|
266
277
|
} catch (err) {
|
|
267
278
|
throw this.#recordError(err);
|
|
268
279
|
}
|
|
269
280
|
}
|
|
270
281
|
|
|
282
|
+
async writeLine(line: string): Promise<void> {
|
|
283
|
+
this.writeLineSync(line);
|
|
284
|
+
}
|
|
285
|
+
|
|
271
286
|
async flush(): Promise<void> {
|
|
272
|
-
await this.#ready;
|
|
273
287
|
if (this.#error) throw this.#error;
|
|
274
288
|
}
|
|
275
289
|
|
|
276
290
|
async fsync(): Promise<void> {
|
|
277
291
|
// No-op for in-memory storage
|
|
278
|
-
await this.#ready;
|
|
279
292
|
if (this.#error) throw this.#error;
|
|
280
293
|
}
|
|
281
294
|
|
|
282
295
|
async close(): Promise<void> {
|
|
283
296
|
if (this.#closed) return;
|
|
284
|
-
await this.#ready;
|
|
285
297
|
this.#closed = true;
|
|
286
298
|
}
|
|
287
299
|
|
|
@@ -305,6 +317,12 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
305
317
|
this.#files.set(path, { content, mtimeMs: Date.now() });
|
|
306
318
|
}
|
|
307
319
|
|
|
320
|
+
readTextSync(path: string): string {
|
|
321
|
+
const entry = this.#files.get(path);
|
|
322
|
+
if (!entry) throw new Error(`File not found: ${path}`);
|
|
323
|
+
return entry.content;
|
|
324
|
+
}
|
|
325
|
+
|
|
308
326
|
statSync(path: string): SessionStorageStat {
|
|
309
327
|
const entry = this.#files.get(path);
|
|
310
328
|
if (!entry) throw new Error(`File not found: ${path}`);
|