@cjhyy/code-shell-core 0.9.5 → 0.9.7
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/dist/automation/scheduler.d.ts +3 -0
- package/dist/automation/scheduler.js +21 -0
- package/dist/context/manager.d.ts +8 -2
- package/dist/context/manager.js +20 -4
- package/dist/context/notes.d.ts +39 -0
- package/dist/context/notes.js +314 -0
- package/dist/credentials/access.d.ts +9 -1
- package/dist/credentials/access.js +15 -4
- package/dist/credentials/store.d.ts +11 -0
- package/dist/credentials/store.js +44 -9
- package/dist/credentials/types.d.ts +4 -0
- package/dist/credentials/types.js +4 -0
- package/dist/credentials/use-credential-tool.js +12 -2
- package/dist/engine/engine-workspace-authority.js +10 -3
- package/dist/engine/engine.d.ts +8 -4
- package/dist/engine/engine.js +115 -15
- package/dist/engine/prompt-cache-diagnostics.js +10 -1
- package/dist/engine/run-goal.js +7 -3
- package/dist/engine/run-tooling.d.ts +3 -0
- package/dist/engine/run-tooling.js +25 -23
- package/dist/engine/run-types.d.ts +20 -0
- package/dist/engine/run-workspace.js +5 -21
- package/dist/engine/subagent-spawner.d.ts +3 -0
- package/dist/engine/subagent-spawner.js +48 -17
- package/dist/engine/turn-loop.d.ts +14 -4
- package/dist/engine/turn-loop.js +120 -28
- package/dist/engine/types.d.ts +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/links/index.d.ts +1 -0
- package/dist/links/index.js +1 -0
- package/dist/links/link-action-tool.d.ts +2 -1
- package/dist/links/link-action-tool.js +80 -44
- package/dist/links/status.d.ts +53 -0
- package/dist/links/status.js +175 -0
- package/dist/llm/prompt-cache.d.ts +35 -3
- package/dist/llm/prompt-cache.js +63 -3
- package/dist/llm/providers/openai.d.ts +3 -0
- package/dist/llm/providers/openai.js +57 -14
- package/dist/prompt/section-loader.js +1 -0
- package/dist/prompt/sections/browser.md +4 -2
- package/dist/prompt/sections/context-notes.md +9 -0
- package/dist/protocol/background-result-wakeup.d.ts +8 -1
- package/dist/protocol/background-result-wakeup.js +76 -38
- package/dist/protocol/chat-session-manager.d.ts +7 -1
- package/dist/protocol/chat-session-manager.js +44 -8
- package/dist/protocol/chat-session.d.ts +2 -0
- package/dist/protocol/chat-session.js +4 -1
- package/dist/protocol/server.d.ts +4 -0
- package/dist/protocol/server.js +355 -62
- package/dist/protocol/session-message-result.d.ts +12 -0
- package/dist/protocol/session-message-result.js +42 -0
- package/dist/protocol/session-message-workspace.d.ts +21 -0
- package/dist/protocol/session-message-workspace.js +57 -0
- package/dist/protocol/types.d.ts +4 -0
- package/dist/session/session-manager.js +8 -7
- package/dist/session/session-message.d.ts +17 -2
- package/dist/session/transcript.d.ts +17 -0
- package/dist/session/transcript.js +271 -16
- package/dist/settings/schema.d.ts +9 -0
- package/dist/settings/schema.js +4 -0
- package/dist/themes/paths.js +20 -1
- package/dist/tool-system/browser-bridge.d.ts +21 -1
- package/dist/tool-system/browser-discovery.d.ts +6 -0
- package/dist/tool-system/browser-discovery.js +17 -0
- package/dist/tool-system/builtin/agent.js +23 -9
- package/dist/tool-system/builtin/browser-tools.js +30 -9
- package/dist/tool-system/builtin/context-notes.d.ts +12 -0
- package/dist/tool-system/builtin/context-notes.js +188 -0
- package/dist/tool-system/builtin/index.js +50 -3
- package/dist/tool-system/builtin/mcp-tools.d.ts +5 -3
- package/dist/tool-system/builtin/mcp-tools.js +10 -10
- package/dist/tool-system/builtin/send-message-to-session.js +19 -3
- package/dist/tool-system/builtin/tool-search.js +15 -3
- package/dist/tool-system/context.d.ts +9 -0
- package/dist/tool-system/executor.js +5 -3
- package/dist/tool-system/mcp-compat.d.ts +3 -0
- package/dist/tool-system/mcp-compat.js +51 -0
- package/dist/tool-system/mcp-manager.d.ts +27 -26
- package/dist/tool-system/mcp-manager.js +273 -111
- package/dist/tool-system/mcp-workspace.d.ts +18 -0
- package/dist/tool-system/mcp-workspace.js +56 -0
- package/dist/tool-system/permission.d.ts +6 -0
- package/dist/tool-system/permission.js +45 -9
- package/dist/tool-system/plan-mode-allowlist.js +5 -0
- package/dist/tool-system/sandbox/seatbelt.js +71 -2
- package/dist/tool-system/session-tool-host.js +9 -1
- package/dist/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EngineResult } from "../engine/types.js";
|
|
2
|
+
import type { RouteSessionMessageInput } from "../session/session-message.js";
|
|
3
|
+
import type { ResultEnvelopeDraft } from "../tool-system/builtin/agent-notifications.js";
|
|
4
|
+
export interface SessionMessageOutcome {
|
|
5
|
+
status: "completed" | "failed" | "cancelled";
|
|
6
|
+
text: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function sessionMessageFailure(error: unknown): SessionMessageOutcome;
|
|
10
|
+
/** A resolved Engine promise can still represent a refusal or failed run. */
|
|
11
|
+
export declare function sessionMessageOutcome(result: EngineResult): SessionMessageOutcome;
|
|
12
|
+
export declare function sessionMessageResultNotification(input: RouteSessionMessageInput, messageId: string, outcome: SessionMessageOutcome): ResultEnvelopeDraft;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function sessionMessageFailure(error) {
|
|
2
|
+
return {
|
|
3
|
+
status: error instanceof Error && error.name === "AbortError" ? "cancelled" : "failed",
|
|
4
|
+
text: "",
|
|
5
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
/** A resolved Engine promise can still represent a refusal or failed run. */
|
|
9
|
+
export function sessionMessageOutcome(result) {
|
|
10
|
+
if (result.reason === "aborted_streaming" || result.reason === "aborted_tools") {
|
|
11
|
+
return {
|
|
12
|
+
status: "cancelled",
|
|
13
|
+
text: result.text,
|
|
14
|
+
error: result.text || "Target turn was cancelled.",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (result.reason === "completed" && result.turnCount > 0) {
|
|
18
|
+
return { status: "completed", text: result.text };
|
|
19
|
+
}
|
|
20
|
+
const detail = result.text ||
|
|
21
|
+
(result.turnCount === 0 ? "Target turn did not start." : "Target turn did not complete.");
|
|
22
|
+
return { status: "failed", text: result.text, error: `${detail} (reason: ${result.reason})` };
|
|
23
|
+
}
|
|
24
|
+
export function sessionMessageResultNotification(input, messageId, outcome) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "result",
|
|
27
|
+
from: { sessionId: input.target.sessionId, authority: "agent" },
|
|
28
|
+
to: { sessionId: input.sourceSessionId, authority: "system" },
|
|
29
|
+
correlationId: messageId,
|
|
30
|
+
delivery: "idle-drain",
|
|
31
|
+
payload: {
|
|
32
|
+
workId: messageId,
|
|
33
|
+
name: input.target.title,
|
|
34
|
+
description: `Reply to message ${messageId} from Session ${input.target.sessionId}: ${input.message.slice(0, 240)}`,
|
|
35
|
+
workKind: "agent",
|
|
36
|
+
status: outcome.status,
|
|
37
|
+
finalText: outcome.text,
|
|
38
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
39
|
+
finishedAt: Date.now(),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SessionManager } from "../session/session-manager.js";
|
|
2
|
+
import { type WorkspaceContext } from "../workspace/workspace-context.js";
|
|
3
|
+
export interface SessionMessageWorkspace {
|
|
4
|
+
cwd: string;
|
|
5
|
+
workspaceContext?: WorkspaceContext;
|
|
6
|
+
projectTrusted?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Protocol-only hosts can reuse already trusted mounted roots. Desktop should
|
|
10
|
+
* ask its project registry for fresh authority instead. Persisted Session ids,
|
|
11
|
+
* bindings and runtime roots always take precedence over a caller's catalog.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveSessionMessageWorkspace(input: {
|
|
14
|
+
sessionManager: Pick<SessionManager, "readSessionMainRoot" | "readSessionProjectBinding" | "getSessionWorkspace">;
|
|
15
|
+
sourceSessionId: string;
|
|
16
|
+
targetSessionId: string;
|
|
17
|
+
sourceWorkspace: SessionMessageWorkspace;
|
|
18
|
+
targetWorkspace?: SessionMessageWorkspace;
|
|
19
|
+
}): SessionMessageWorkspace & {
|
|
20
|
+
projectTrusted: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { canonicalKey } from "../workspace/canonical-key.js";
|
|
2
|
+
import { createWorkspaceContext, validateWorkspaceContext, } from "../workspace/workspace-context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Protocol-only hosts can reuse already trusted mounted roots. Desktop should
|
|
5
|
+
* ask its project registry for fresh authority instead. Persisted Session ids,
|
|
6
|
+
* bindings and runtime roots always take precedence over a caller's catalog.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveSessionMessageWorkspace(input) {
|
|
9
|
+
const { sessionManager, sourceSessionId, targetSessionId, sourceWorkspace, targetWorkspace } = input;
|
|
10
|
+
const sourceMain = sessionManager.readSessionMainRoot(sourceSessionId) ?? sourceWorkspace.cwd;
|
|
11
|
+
const targetMain = sessionManager.readSessionMainRoot(targetSessionId);
|
|
12
|
+
const sourceBinding = sessionManager.readSessionProjectBinding(sourceSessionId);
|
|
13
|
+
const targetBinding = sessionManager.readSessionProjectBinding(targetSessionId);
|
|
14
|
+
const cwd = sessionManager.getSessionWorkspace(targetSessionId)?.root ?? targetMain ?? sourceMain;
|
|
15
|
+
const sameMainRoot = !targetMain || canonicalKey(sourceMain) === canonicalKey(targetMain);
|
|
16
|
+
const projectTrusted = targetWorkspace?.projectTrusted ?? (sameMainRoot && sourceWorkspace.projectTrusted === true);
|
|
17
|
+
if (targetMain && !targetBinding) {
|
|
18
|
+
if (!sameMainRoot)
|
|
19
|
+
throw new Error("target Session does not share the source's authorized main root");
|
|
20
|
+
return { cwd, projectTrusted };
|
|
21
|
+
}
|
|
22
|
+
if (!targetBinding && !sourceBinding)
|
|
23
|
+
return { cwd, projectTrusted };
|
|
24
|
+
if (targetBinding && (!sourceBinding || targetBinding.projectId !== sourceBinding.projectId)) {
|
|
25
|
+
throw new Error("target Session project binding is not authorized by the source Session");
|
|
26
|
+
}
|
|
27
|
+
const binding = targetBinding ?? sourceBinding;
|
|
28
|
+
if (!sourceWorkspace.workspaceContext) {
|
|
29
|
+
throw new Error("cross-Session run requires an authoritative WorkspaceContext from the host");
|
|
30
|
+
}
|
|
31
|
+
const context = validateWorkspaceContext(sourceWorkspace.workspaceContext);
|
|
32
|
+
if (context.projectId !== sourceBinding?.projectId ||
|
|
33
|
+
context.sessionMainRootId !== sourceBinding.mainRootId) {
|
|
34
|
+
throw new Error("source WorkspaceContext does not match its persisted project binding");
|
|
35
|
+
}
|
|
36
|
+
// A source worktree is a runtime substitution, not a project mount that may
|
|
37
|
+
// be propagated into another Session's secondary roots.
|
|
38
|
+
const mountedRoots = context.roots.map((root) => root.id === sourceBinding.mainRootId ? { ...root, path: sourceMain } : { ...root });
|
|
39
|
+
const targetRoot = mountedRoots.find((root) => root.id === binding.mainRootId);
|
|
40
|
+
if (!targetRoot || (targetMain && canonicalKey(targetRoot.path) !== canonicalKey(targetMain))) {
|
|
41
|
+
throw new Error("target Session main root is not present in the host-authorized project roots");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
cwd,
|
|
45
|
+
projectTrusted,
|
|
46
|
+
workspaceContext: createWorkspaceContext({
|
|
47
|
+
projectId: context.projectId,
|
|
48
|
+
projectRevision: context.projectRevision,
|
|
49
|
+
sessionMainRootId: binding.mainRootId,
|
|
50
|
+
roots: mountedRoots.map((root) => ({
|
|
51
|
+
...root,
|
|
52
|
+
path: root.id === binding.mainRootId ? cwd : root.path,
|
|
53
|
+
role: root.id === binding.mainRootId ? "primary" : "secondary",
|
|
54
|
+
})),
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/protocol/types.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { SessionMessageTarget } from "../session/session-message.js";
|
|
|
13
13
|
export type PendingApprovalKind = "tool_approval" | "ask_user" | "internal";
|
|
14
14
|
/** Resolver-free metadata retained beside a pending approval callback. */
|
|
15
15
|
export interface PendingApprovalMetadata {
|
|
16
|
+
/** Child task that originated this request; sessionId remains its parent UI route. */
|
|
17
|
+
sourceSessionId?: string;
|
|
16
18
|
sessionId: string;
|
|
17
19
|
requestId: string;
|
|
18
20
|
routeGeneration?: number;
|
|
@@ -198,6 +200,8 @@ export interface RunParams {
|
|
|
198
200
|
* optional token/time budgets). Normalized at the engine run boundary.
|
|
199
201
|
*/
|
|
200
202
|
goal?: string | import("../goal/lifecycle.js").GoalConfig;
|
|
203
|
+
/** Disable explicit, persisted, and configured Goal mode for this standalone turn. */
|
|
204
|
+
disableGoal?: boolean;
|
|
201
205
|
}
|
|
202
206
|
export interface RunResult {
|
|
203
207
|
text: string;
|
|
@@ -160,6 +160,8 @@ const FORK_EVENT_POLICY = {
|
|
|
160
160
|
tool_result: "copy",
|
|
161
161
|
summary: "copy",
|
|
162
162
|
context_transfer: "copy",
|
|
163
|
+
context_note: "copy",
|
|
164
|
+
context_checkpoint: "copy",
|
|
163
165
|
range_archive: "copy",
|
|
164
166
|
content_replace: "copy",
|
|
165
167
|
file_history: "skip",
|
|
@@ -1922,13 +1924,12 @@ export function buildForkTranscript(sourceEvents, state) {
|
|
|
1922
1924
|
forkedFrom: structuredClone(state.forkedFrom),
|
|
1923
1925
|
},
|
|
1924
1926
|
};
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
];
|
|
1927
|
+
const copied = sourceEvents.map((source) => ({
|
|
1928
|
+
...structuredClone(source),
|
|
1929
|
+
id: nanoid(12),
|
|
1930
|
+
}));
|
|
1931
|
+
Transcript.remapContextForkReferences(sourceEvents, copied);
|
|
1932
|
+
return [meta, ...copied];
|
|
1932
1933
|
}
|
|
1933
1934
|
function validateForkToolPairs(events) {
|
|
1934
1935
|
const projectedUses = [];
|
|
@@ -9,14 +9,29 @@ export interface RouteSessionMessageInput {
|
|
|
9
9
|
sourceSessionId: string;
|
|
10
10
|
target: SessionMessageTarget;
|
|
11
11
|
message: string;
|
|
12
|
+
/** Sender tool cancellation; never serialized or supplied by the model. */
|
|
13
|
+
signal?: AbortSignal;
|
|
12
14
|
/** Full same-project catalog so the target can send a later message onward. */
|
|
13
15
|
catalog: readonly SessionMessageTarget[];
|
|
14
16
|
}
|
|
15
|
-
|
|
17
|
+
/** One dispatch acknowledgement, not a subscription to future target turns. */
|
|
18
|
+
export interface SessionMessageReceipt {
|
|
19
|
+
messageId: string;
|
|
20
|
+
status: "queued" | "started" | "completed";
|
|
21
|
+
/** Present when the target finished before the dispatch returned. */
|
|
22
|
+
result?: {
|
|
23
|
+
text: string;
|
|
24
|
+
reason: import("../types.js").TerminalReason;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export type SessionMessageRouter = (input: RouteSessionMessageInput) => Promise<SessionMessageReceipt | void>;
|
|
16
28
|
export interface SessionMessageToolService {
|
|
17
29
|
targets: readonly SessionMessageTarget[];
|
|
18
30
|
send(input: {
|
|
19
31
|
targetSessionId: string;
|
|
20
32
|
message: string;
|
|
21
|
-
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}): Promise<SessionMessageTarget & {
|
|
35
|
+
receipt?: SessionMessageReceipt;
|
|
36
|
+
}>;
|
|
22
37
|
}
|
|
@@ -27,6 +27,16 @@ export interface SelectedContextRange {
|
|
|
27
27
|
messages: Message[];
|
|
28
28
|
sourceEventCount: number;
|
|
29
29
|
}
|
|
30
|
+
/** Host-generated replay state. Models supply the note text, never this snapshot. */
|
|
31
|
+
export interface ContextCheckpointSnapshot {
|
|
32
|
+
version: 1;
|
|
33
|
+
noteId: string;
|
|
34
|
+
coveredThroughEventId: string;
|
|
35
|
+
messages: Message[];
|
|
36
|
+
clientMessageIds: Array<[string, number]>;
|
|
37
|
+
}
|
|
38
|
+
/** Complete, ordered provider pairs are required before a checkpoint can commit. */
|
|
39
|
+
export declare function hasCompleteContextToolPairs(messages: readonly Message[]): boolean;
|
|
30
40
|
export type SummaryAppendMetadata = {
|
|
31
41
|
fromTurn: number;
|
|
32
42
|
toTurn: number;
|
|
@@ -59,8 +69,14 @@ export declare class Transcript {
|
|
|
59
69
|
static inMemory(label: string): Transcript;
|
|
60
70
|
/** Rehydrate a process-local fork without serializing its copied history. */
|
|
61
71
|
static fromMemoryEvents(label: string, events: readonly TranscriptEvent[]): Transcript;
|
|
72
|
+
/** Preserve checkpoint provenance when a fork assigns fresh event ids. */
|
|
73
|
+
static remapContextForkReferences(sourceEvents: readonly TranscriptEvent[], copiedEvents: TranscriptEvent[]): void;
|
|
62
74
|
isPersistent(): boolean;
|
|
63
75
|
append(type: TranscriptEventType, data: Record<string, unknown>): TranscriptEvent;
|
|
76
|
+
/** Failed note/checkpoint writes must never change the active replay. */
|
|
77
|
+
appendContextNote(text: string, coveredThroughEventId: string): TranscriptEvent | undefined;
|
|
78
|
+
appendContextCheckpoint(snapshot: ContextCheckpointSnapshot): TranscriptEvent | undefined;
|
|
79
|
+
private appendDurableContextEvent;
|
|
64
80
|
/**
|
|
65
81
|
* Append a chat message to the transcript.
|
|
66
82
|
*
|
|
@@ -148,6 +164,7 @@ export declare class Transcript {
|
|
|
148
164
|
messages: Message[];
|
|
149
165
|
liveIndexByClientMessageId: Map<string, number>;
|
|
150
166
|
};
|
|
167
|
+
private contextReplayEvents;
|
|
151
168
|
getEvents(type?: TranscriptEventType): TranscriptEvent[];
|
|
152
169
|
get turnNumber(): number;
|
|
153
170
|
get eventCount(): number;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync, } from "node:fs";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { nanoid } from "nanoid";
|
|
8
9
|
import { logger } from "../logging/logger.js";
|
|
9
10
|
const DEFAULT_CONTEXT_TAIL_SCAN_BYTES = 32 * 1024 * 1024;
|
|
@@ -33,6 +34,131 @@ function appendTranscriptLine(filePath, data) {
|
|
|
33
34
|
const defaultTranscriptWriter = (filePath, data) => {
|
|
34
35
|
appendTranscriptLine(filePath, data);
|
|
35
36
|
};
|
|
37
|
+
function checkpointHash(snapshot) {
|
|
38
|
+
return createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
/** Complete, ordered provider pairs are required before a checkpoint can commit. */
|
|
41
|
+
export function hasCompleteContextToolPairs(messages) {
|
|
42
|
+
const pending = new Set();
|
|
43
|
+
const used = new Set();
|
|
44
|
+
for (const message of messages) {
|
|
45
|
+
if (pending.size > 0 &&
|
|
46
|
+
(message.role !== "user" ||
|
|
47
|
+
!Array.isArray(message.content) ||
|
|
48
|
+
message.content.length === 0 ||
|
|
49
|
+
message.content.some((block) => block.type !== "tool_result")))
|
|
50
|
+
return false;
|
|
51
|
+
if (!Array.isArray(message.content))
|
|
52
|
+
continue;
|
|
53
|
+
for (const block of message.content) {
|
|
54
|
+
if (block.type === "tool_use") {
|
|
55
|
+
if (message.role !== "assistant" || !block.id || used.has(block.id))
|
|
56
|
+
return false;
|
|
57
|
+
used.add(block.id);
|
|
58
|
+
pending.add(block.id);
|
|
59
|
+
}
|
|
60
|
+
else if (block.type === "tool_result") {
|
|
61
|
+
if (message.role !== "user" || !block.tool_use_id || !pending.delete(block.tool_use_id)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return pending.size === 0;
|
|
68
|
+
}
|
|
69
|
+
function readCheckpoint(event, precedingEvents) {
|
|
70
|
+
if (event.type !== "context_checkpoint")
|
|
71
|
+
return undefined;
|
|
72
|
+
const data = event.data;
|
|
73
|
+
if (!data ||
|
|
74
|
+
typeof data !== "object" ||
|
|
75
|
+
data.version !== 1 ||
|
|
76
|
+
typeof data.noteId !== "string" ||
|
|
77
|
+
typeof data.coveredThroughEventId !== "string" ||
|
|
78
|
+
!Array.isArray(data.messages) ||
|
|
79
|
+
data.messages.length === 0 ||
|
|
80
|
+
!Array.isArray(data.clientMessageIds))
|
|
81
|
+
return undefined;
|
|
82
|
+
const noteIndex = precedingEvents.findIndex((candidate) => candidate.id === data.noteId);
|
|
83
|
+
const cursorIndex = precedingEvents.findIndex((candidate) => candidate.id === data.coveredThroughEventId);
|
|
84
|
+
const note = precedingEvents[noteIndex];
|
|
85
|
+
if (cursorIndex < 0 ||
|
|
86
|
+
noteIndex <= cursorIndex ||
|
|
87
|
+
note?.type !== "context_note" ||
|
|
88
|
+
!note.data ||
|
|
89
|
+
typeof note.data.text !== "string" ||
|
|
90
|
+
note.data.text.trim().length === 0 ||
|
|
91
|
+
note.data.coveredThroughEventId !== data.coveredThroughEventId)
|
|
92
|
+
return undefined;
|
|
93
|
+
for (const value of data.messages) {
|
|
94
|
+
if (!value || typeof value !== "object")
|
|
95
|
+
return undefined;
|
|
96
|
+
const message = value;
|
|
97
|
+
if (!["user", "assistant", "system", "tool"].includes(message.role))
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!validCheckpointContent(message.content))
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (const pair of data.clientMessageIds) {
|
|
104
|
+
if (!Array.isArray(pair) ||
|
|
105
|
+
pair.length !== 2 ||
|
|
106
|
+
typeof pair[0] !== "string" ||
|
|
107
|
+
seen.has(pair[0]) ||
|
|
108
|
+
!Number.isSafeInteger(pair[1]) ||
|
|
109
|
+
pair[1] < 0 ||
|
|
110
|
+
pair[1] >= data.messages.length)
|
|
111
|
+
return undefined;
|
|
112
|
+
seen.add(pair[0]);
|
|
113
|
+
}
|
|
114
|
+
const snapshot = {
|
|
115
|
+
version: 1,
|
|
116
|
+
noteId: data.noteId,
|
|
117
|
+
coveredThroughEventId: data.coveredThroughEventId,
|
|
118
|
+
messages: data.messages,
|
|
119
|
+
clientMessageIds: data.clientMessageIds,
|
|
120
|
+
};
|
|
121
|
+
if (data.checksum !== checkpointHash(snapshot) ||
|
|
122
|
+
!hasCompleteContextToolPairs(snapshot.messages)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
return snapshot;
|
|
126
|
+
}
|
|
127
|
+
function validCheckpointContent(content, depth = 0) {
|
|
128
|
+
if (typeof content === "string")
|
|
129
|
+
return true;
|
|
130
|
+
if (!Array.isArray(content) || depth > 8)
|
|
131
|
+
return false;
|
|
132
|
+
return content.every((value) => {
|
|
133
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
134
|
+
return false;
|
|
135
|
+
const block = value;
|
|
136
|
+
switch (block.type) {
|
|
137
|
+
case "text":
|
|
138
|
+
case "reasoning":
|
|
139
|
+
return ((block.text === undefined || typeof block.text === "string") &&
|
|
140
|
+
(block.reasoningContent === undefined || typeof block.reasoningContent === "string"));
|
|
141
|
+
case "image":
|
|
142
|
+
return (block.source?.type === "base64" &&
|
|
143
|
+
typeof block.source.media_type === "string" &&
|
|
144
|
+
typeof block.source.data === "string");
|
|
145
|
+
case "tool_use":
|
|
146
|
+
return (depth === 0 &&
|
|
147
|
+
typeof block.id === "string" &&
|
|
148
|
+
(block.name === undefined || typeof block.name === "string") &&
|
|
149
|
+
(block.input === undefined ||
|
|
150
|
+
(block.input !== null &&
|
|
151
|
+
typeof block.input === "object" &&
|
|
152
|
+
!Array.isArray(block.input))));
|
|
153
|
+
case "tool_result":
|
|
154
|
+
return (depth === 0 &&
|
|
155
|
+
typeof block.tool_use_id === "string" &&
|
|
156
|
+
(block.content === undefined || validCheckpointContent(block.content, depth + 1)));
|
|
157
|
+
default:
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
36
162
|
const CONTEXT_EVENT_TYPES = new Set([
|
|
37
163
|
"message",
|
|
38
164
|
"tool_use",
|
|
@@ -47,6 +173,18 @@ function isSyntheticInterruptedToolResult(event) {
|
|
|
47
173
|
event.data.toolName === "unknown" &&
|
|
48
174
|
event.data.error === INTERRUPTED_TOOL_RESULT_ERROR);
|
|
49
175
|
}
|
|
176
|
+
function toolResultContentBlock(event) {
|
|
177
|
+
const { toolCallId, result, error, contentBlocks } = event.data;
|
|
178
|
+
return {
|
|
179
|
+
type: "tool_result",
|
|
180
|
+
tool_use_id: toolCallId,
|
|
181
|
+
content: error
|
|
182
|
+
? `Error: ${error}`
|
|
183
|
+
: Array.isArray(contentBlocks) && contentBlocks.length > 0
|
|
184
|
+
? structuredClone(contentBlocks)
|
|
185
|
+
: (result ?? "(no output)"),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
50
188
|
/**
|
|
51
189
|
* Choose at most one result for every declared tool call. A real late result
|
|
52
190
|
* wins over the legacy synthetic "interrupted" placeholder that an older
|
|
@@ -120,6 +258,40 @@ export class Transcript {
|
|
|
120
258
|
transcript.loadEvents(events);
|
|
121
259
|
return transcript;
|
|
122
260
|
}
|
|
261
|
+
/** Preserve checkpoint provenance when a fork assigns fresh event ids. */
|
|
262
|
+
static remapContextForkReferences(sourceEvents, copiedEvents) {
|
|
263
|
+
if (!sourceEvents.some((event) => event.type === "context_note"))
|
|
264
|
+
return;
|
|
265
|
+
const eventIds = new Map(sourceEvents.map((event, index) => [event.id, copiedEvents[index].id]));
|
|
266
|
+
for (const [index, source] of sourceEvents.entries()) {
|
|
267
|
+
const copied = copiedEvents[index];
|
|
268
|
+
// An inherited note can cite old event ids. Resolve those aliases only
|
|
269
|
+
// against the copied prefix, never by reaching into the parent session.
|
|
270
|
+
copied.data.contextHistorySourceIds = [
|
|
271
|
+
...(Array.isArray(source.data.contextHistorySourceIds)
|
|
272
|
+
? source.data.contextHistorySourceIds.filter((id) => typeof id === "string")
|
|
273
|
+
: []),
|
|
274
|
+
source.id,
|
|
275
|
+
];
|
|
276
|
+
if (source.type === "context_note") {
|
|
277
|
+
const cursor = source.data.coveredThroughEventId;
|
|
278
|
+
if (typeof cursor === "string" && eventIds.has(cursor)) {
|
|
279
|
+
copied.data.coveredThroughEventId = eventIds.get(cursor);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (source.type !== "context_checkpoint")
|
|
283
|
+
continue;
|
|
284
|
+
const snapshot = readCheckpoint(source, sourceEvents.slice(0, index));
|
|
285
|
+
if (!snapshot)
|
|
286
|
+
continue; // Never turn a corrupt source checkpoint into a valid one.
|
|
287
|
+
const noteId = eventIds.get(snapshot.noteId);
|
|
288
|
+
const coveredThroughEventId = eventIds.get(snapshot.coveredThroughEventId);
|
|
289
|
+
if (!noteId || !coveredThroughEventId)
|
|
290
|
+
continue;
|
|
291
|
+
const remapped = { ...snapshot, noteId, coveredThroughEventId };
|
|
292
|
+
Object.assign(copied.data, remapped, { checksum: checkpointHash(remapped) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
123
295
|
isPersistent() {
|
|
124
296
|
return this.persistent;
|
|
125
297
|
}
|
|
@@ -135,6 +307,41 @@ export class Transcript {
|
|
|
135
307
|
this.flush(event);
|
|
136
308
|
return event;
|
|
137
309
|
}
|
|
310
|
+
/** Failed note/checkpoint writes must never change the active replay. */
|
|
311
|
+
appendContextNote(text, coveredThroughEventId) {
|
|
312
|
+
return this.appendDurableContextEvent("context_note", { text, coveredThroughEventId });
|
|
313
|
+
}
|
|
314
|
+
appendContextCheckpoint(snapshot) {
|
|
315
|
+
const frozen = structuredClone(snapshot);
|
|
316
|
+
const data = { ...frozen, checksum: checkpointHash(frozen) };
|
|
317
|
+
const candidate = {
|
|
318
|
+
id: "validation",
|
|
319
|
+
type: "context_checkpoint",
|
|
320
|
+
timestamp: Date.now(),
|
|
321
|
+
turnNumber: this.currentTurn,
|
|
322
|
+
data,
|
|
323
|
+
};
|
|
324
|
+
if (!readCheckpoint(candidate, this.events))
|
|
325
|
+
return undefined;
|
|
326
|
+
return this.appendDurableContextEvent("context_checkpoint", data);
|
|
327
|
+
}
|
|
328
|
+
appendDurableContextEvent(type, data) {
|
|
329
|
+
// A missing earlier event makes a durable cursor unreliable, even when
|
|
330
|
+
// this particular append would succeed.
|
|
331
|
+
if (this.dirty)
|
|
332
|
+
return undefined;
|
|
333
|
+
const event = {
|
|
334
|
+
id: nanoid(12),
|
|
335
|
+
type,
|
|
336
|
+
timestamp: Date.now(),
|
|
337
|
+
turnNumber: this.currentTurn,
|
|
338
|
+
data,
|
|
339
|
+
};
|
|
340
|
+
if (!this.flush(event))
|
|
341
|
+
return undefined;
|
|
342
|
+
this.events.push(event);
|
|
343
|
+
return event;
|
|
344
|
+
}
|
|
138
345
|
/**
|
|
139
346
|
* Append a chat message to the transcript.
|
|
140
347
|
*
|
|
@@ -288,10 +495,11 @@ export class Transcript {
|
|
|
288
495
|
* payloads, and transport metadata must not leak into them.
|
|
289
496
|
*/
|
|
290
497
|
toMessagesWithIndex() {
|
|
498
|
+
const events = this.contextReplayEvents();
|
|
291
499
|
const messages = [];
|
|
292
500
|
const liveIndexByClientMessageId = new Map();
|
|
293
|
-
const selectedToolResults = preferredToolResults(
|
|
294
|
-
const hasRangeArchive =
|
|
501
|
+
const selectedToolResults = preferredToolResults(events);
|
|
502
|
+
const hasRangeArchive = events.some((e) => e.type === "range_archive");
|
|
295
503
|
const spansByFromId = new Map();
|
|
296
504
|
let openingSpan;
|
|
297
505
|
if (hasRangeArchive) {
|
|
@@ -302,7 +510,7 @@ export class Transcript {
|
|
|
302
510
|
// while scanning forward, so it would never close — silently swallowing
|
|
303
511
|
// the rest of the conversation. Fail open instead: ignore the marker.
|
|
304
512
|
const firstIndexByClientId = new Map();
|
|
305
|
-
for (const [index, event] of
|
|
513
|
+
for (const [index, event] of events.entries()) {
|
|
306
514
|
if (event.type === "message" && typeof event.data.clientMessageId === "string") {
|
|
307
515
|
if (!firstIndexByClientId.has(event.data.clientMessageId)) {
|
|
308
516
|
firstIndexByClientId.set(event.data.clientMessageId, index);
|
|
@@ -310,7 +518,7 @@ export class Transcript {
|
|
|
310
518
|
}
|
|
311
519
|
}
|
|
312
520
|
const presentClientIds = new Set(firstIndexByClientId.keys());
|
|
313
|
-
for (const event of
|
|
521
|
+
for (const event of events) {
|
|
314
522
|
if (event.type !== "range_archive")
|
|
315
523
|
continue;
|
|
316
524
|
const { summary, toClientMessageId, fromClientMessageId } = event.data;
|
|
@@ -351,7 +559,7 @@ export class Transcript {
|
|
|
351
559
|
// preferred) real result landed outside it — would be an orphaned block
|
|
352
560
|
// that breaks provider validation; skip it instead of emitting it.
|
|
353
561
|
const emittedToolUseIds = new Set();
|
|
354
|
-
for (const event of
|
|
562
|
+
for (const event of events) {
|
|
355
563
|
// Span bookkeeping runs on message events only: exit before entry so
|
|
356
564
|
// adjacent spans (A.to === B.from) hand over on the boundary message.
|
|
357
565
|
if (event.type === "message") {
|
|
@@ -387,7 +595,9 @@ export class Transcript {
|
|
|
387
595
|
}
|
|
388
596
|
}
|
|
389
597
|
}
|
|
390
|
-
|
|
598
|
+
// Tool results can be merged into this array below. Never mutate
|
|
599
|
+
// the source event (or a checkpoint) during a read-only replay.
|
|
600
|
+
messages.push({ role: role, content: structuredClone(content) });
|
|
391
601
|
break;
|
|
392
602
|
}
|
|
393
603
|
case "tool_use": {
|
|
@@ -402,18 +612,9 @@ export class Transcript {
|
|
|
402
612
|
!emittedToolUseIds.has(eventToolCallId)) {
|
|
403
613
|
break;
|
|
404
614
|
}
|
|
405
|
-
const { toolCallId, result, error, contentBlocks } = event.data;
|
|
406
615
|
// Find if there's already a user message with tool_results to append to
|
|
407
616
|
const lastMsg = messages[messages.length - 1];
|
|
408
|
-
const block =
|
|
409
|
-
type: "tool_result",
|
|
410
|
-
tool_use_id: toolCallId,
|
|
411
|
-
content: error
|
|
412
|
-
? `Error: ${error}`
|
|
413
|
-
: Array.isArray(contentBlocks) && contentBlocks.length > 0
|
|
414
|
-
? contentBlocks
|
|
415
|
-
: (result ?? "(no output)"),
|
|
416
|
-
};
|
|
617
|
+
const block = toolResultContentBlock(event);
|
|
417
618
|
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
|
|
418
619
|
lastMsg.content.push(block);
|
|
419
620
|
}
|
|
@@ -449,6 +650,55 @@ export class Transcript {
|
|
|
449
650
|
}
|
|
450
651
|
return { messages, liveIndexByClientMessageId };
|
|
451
652
|
}
|
|
653
|
+
contextReplayEvents() {
|
|
654
|
+
for (let index = this.events.length - 1; index >= 0; index -= 1) {
|
|
655
|
+
const event = this.events[index];
|
|
656
|
+
if (event.type !== "context_checkpoint")
|
|
657
|
+
continue;
|
|
658
|
+
const snapshot = readCheckpoint(event, this.events.slice(0, index));
|
|
659
|
+
if (!snapshot)
|
|
660
|
+
continue; // Corrupt or truncated checkpoints fail open.
|
|
661
|
+
const clientIdByIndex = new Map(snapshot.clientMessageIds.map(([id, i]) => [i, id]));
|
|
662
|
+
const snapshotEvents = snapshot.messages.map((message, messageIndex) => ({
|
|
663
|
+
id: `${event.id}:${messageIndex}`,
|
|
664
|
+
type: "message",
|
|
665
|
+
timestamp: event.timestamp,
|
|
666
|
+
turnNumber: event.turnNumber,
|
|
667
|
+
data: {
|
|
668
|
+
role: message.role,
|
|
669
|
+
content: structuredClone(message.content),
|
|
670
|
+
...(clientIdByIndex.has(messageIndex)
|
|
671
|
+
? { clientMessageId: clientIdByIndex.get(messageIndex) }
|
|
672
|
+
: {}),
|
|
673
|
+
},
|
|
674
|
+
}));
|
|
675
|
+
const tail = this.events.slice(index + 1);
|
|
676
|
+
const lateResults = preferredToolResults([...snapshotEvents, ...tail]);
|
|
677
|
+
const completedIds = new Set();
|
|
678
|
+
for (const snapshotEvent of snapshotEvents) {
|
|
679
|
+
const content = snapshotEvent.data.content;
|
|
680
|
+
if (!Array.isArray(content))
|
|
681
|
+
continue;
|
|
682
|
+
snapshotEvent.data.content = content.map((block) => {
|
|
683
|
+
if (block.type !== "tool_result" || !block.tool_use_id)
|
|
684
|
+
return block;
|
|
685
|
+
completedIds.add(block.tool_use_id);
|
|
686
|
+
const late = lateResults.get(block.tool_use_id);
|
|
687
|
+
// Reconcile late duplicates in the original result position, so a
|
|
688
|
+
// result cannot become orphaned or split an unrelated later batch.
|
|
689
|
+
return late && !isSyntheticInterruptedToolResult(late)
|
|
690
|
+
? toolResultContentBlock(late)
|
|
691
|
+
: block;
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return [
|
|
695
|
+
...snapshotEvents,
|
|
696
|
+
...tail.filter((candidate) => candidate.type !== "tool_result" ||
|
|
697
|
+
!completedIds.has(candidate.data.toolCallId)),
|
|
698
|
+
];
|
|
699
|
+
}
|
|
700
|
+
return this.events;
|
|
701
|
+
}
|
|
452
702
|
getEvents(type) {
|
|
453
703
|
if (!type)
|
|
454
704
|
return [...this.events];
|
|
@@ -727,6 +977,11 @@ export class Transcript {
|
|
|
727
977
|
return Transcript.loadFromFile(filePath);
|
|
728
978
|
}
|
|
729
979
|
}
|
|
980
|
+
// Checkpoint validity references its note and covered cursor. A bounded
|
|
981
|
+
// range-archive tail may omit either, so use the full loader here.
|
|
982
|
+
if (events.some((event) => event.type === "context_checkpoint")) {
|
|
983
|
+
return Transcript.loadFromFile(filePath);
|
|
984
|
+
}
|
|
730
985
|
let markerIndex = -1;
|
|
731
986
|
let toClientMessageId;
|
|
732
987
|
for (const [index, event] of events.entries()) {
|