@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.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 +304 -6
- package/dist/cli.js +1015 -881
- package/dist/types/async/job-manager.d.ts +15 -0
- package/dist/types/autolearn/controller.d.ts +25 -0
- package/dist/types/autolearn/managed-skills.d.ts +45 -0
- package/dist/types/autoresearch/state.d.ts +1 -1
- package/dist/types/autoresearch/types.d.ts +1 -1
- package/dist/types/cli/args.d.ts +19 -1
- package/dist/types/cli/session-picker.d.ts +1 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/cli/setup-model-picker.d.ts +14 -0
- package/dist/types/collab/protocol.d.ts +1 -1
- package/dist/types/commands/say.d.ts +24 -0
- package/dist/types/config/keybindings.d.ts +3 -3
- package/dist/types/config/model-registry.d.ts +10 -0
- package/dist/types/config/models-config-schema.d.ts +12 -0
- package/dist/types/config/models-config.d.ts +8 -2
- package/dist/types/config/settings-schema.d.ts +261 -58
- package/dist/types/export/html/index.d.ts +2 -1
- package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +47 -1
- package/dist/types/extensibility/hooks/index.d.ts +2 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
- package/dist/types/extensibility/plugins/loader.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/extensibility/skills.d.ts +10 -0
- package/dist/types/goals/guided-setup.d.ts +18 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/hindsight/transcript.d.ts +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/internal-urls/local-protocol.d.ts +4 -2
- package/dist/types/main.d.ts +4 -3
- package/dist/types/mcp/startup-events.d.ts +11 -0
- package/dist/types/memories/index.d.ts +7 -0
- package/dist/types/memory-backend/local-backend.d.ts +4 -3
- package/dist/types/mnemopi/config.d.ts +4 -4
- package/dist/types/modes/components/agent-hub.d.ts +6 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
- package/dist/types/modes/components/custom-editor.d.ts +39 -1
- package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +26 -16
- package/dist/types/modes/components/transcript-container.d.ts +23 -2
- package/dist/types/modes/components/tree-selector.d.ts +1 -1
- package/dist/types/modes/components/usage-row.d.ts +3 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/gradient-highlight.d.ts +9 -4
- package/dist/types/modes/image-references.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +27 -3
- package/dist/types/modes/magic-keywords.d.ts +13 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
- package/dist/types/modes/runtime-init.d.ts +4 -0
- package/dist/types/modes/theme/theme.d.ts +13 -2
- package/dist/types/modes/types.d.ts +8 -2
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/registry/agent-registry.d.ts +17 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -2
- package/dist/types/session/indexed-session-storage.d.ts +3 -4
- package/dist/types/session/session-context.d.ts +39 -0
- package/dist/types/session/session-entries.d.ts +159 -0
- package/dist/types/session/session-listing.d.ts +69 -0
- package/dist/types/session/session-loader.d.ts +16 -0
- package/dist/types/session/session-manager.d.ts +82 -474
- package/dist/types/session/session-migrations.d.ts +12 -0
- package/dist/types/session/session-paths.d.ts +25 -0
- package/dist/types/session/session-persistence.d.ts +8 -0
- package/dist/types/session/session-storage.d.ts +11 -12
- package/dist/types/session/snapcompact-inline.d.ts +12 -1
- package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
- package/dist/types/session/tool-choice-queue.d.ts +6 -6
- package/dist/types/stt/asr-client.d.ts +90 -0
- package/dist/types/stt/asr-protocol.d.ts +97 -0
- package/dist/types/stt/asr-worker.d.ts +2 -0
- package/dist/types/stt/downloader.d.ts +38 -0
- package/dist/types/stt/endpointer.d.ts +59 -0
- package/dist/types/stt/index.d.ts +5 -1
- package/dist/types/stt/models.d.ts +120 -0
- package/dist/types/stt/recorder.d.ts +17 -0
- package/dist/types/stt/stt-controller.d.ts +6 -0
- package/dist/types/stt/transcriber.d.ts +5 -7
- package/dist/types/stt/wav.d.ts +29 -0
- package/dist/types/system-prompt.d.ts +4 -0
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/task/index.d.ts +9 -1
- package/dist/types/task/types.d.ts +36 -0
- package/dist/types/tools/bash.d.ts +2 -2
- package/dist/types/tools/eval-render.d.ts +1 -1
- package/dist/types/tools/index.d.ts +11 -1
- package/dist/types/tools/irc.d.ts +1 -0
- package/dist/types/tools/learn.d.ts +51 -0
- package/dist/types/tools/manage-skill.d.ts +40 -0
- package/dist/types/tools/plan-mode-guard.d.ts +10 -0
- package/dist/types/tools/renderers.d.ts +7 -11
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +1 -1
- package/dist/types/tools/tts.d.ts +25 -0
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/tts/downloader.d.ts +20 -0
- package/dist/types/tts/index.d.ts +8 -0
- package/dist/types/tts/models.d.ts +82 -0
- package/dist/types/tts/player.d.ts +32 -0
- package/dist/types/tts/runtime.d.ts +6 -0
- package/dist/types/tts/streaming-player.d.ts +41 -0
- package/dist/types/tts/tts-client.d.ts +93 -0
- package/dist/types/tts/tts-protocol.d.ts +95 -0
- package/dist/types/tts/tts-worker.d.ts +2 -0
- package/dist/types/tts/vocalizer.d.ts +41 -0
- package/dist/types/tts/wav.d.ts +8 -0
- package/dist/types/utils/tool-choice.d.ts +8 -0
- package/dist/types/utils/tools-manager.d.ts +2 -1
- package/dist/types/utils/tools-manager.test.d.ts +1 -0
- package/dist/types/web/scrapers/github.d.ts +1 -1
- package/package.json +15 -14
- package/src/async/job-manager.ts +49 -0
- package/src/autolearn/controller.ts +139 -0
- package/src/autolearn/managed-skills.ts +257 -0
- package/src/autoresearch/state.ts +1 -1
- package/src/autoresearch/types.ts +1 -1
- package/src/cli/args.ts +56 -2
- package/src/cli/session-picker.ts +2 -1
- package/src/cli/setup-cli.ts +148 -47
- package/src/cli/setup-model-picker.ts +43 -0
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +45 -13
- package/src/collab/host.ts +1 -1
- package/src/collab/protocol.ts +1 -1
- package/src/commands/say.ts +102 -0
- package/src/commands/setup.ts +1 -1
- package/src/commit/agentic/tools/analyze-file.ts +3 -0
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-discovery.ts +11 -5
- package/src/config/model-registry.ts +64 -9
- package/src/config/models-config-schema.ts +4 -1
- package/src/config/models-config.ts +2 -1
- package/src/config/settings-schema.ts +248 -32
- package/src/config/settings.ts +10 -0
- package/src/discovery/builtin.ts +23 -1
- package/src/discovery/claude-plugins.ts +44 -5
- package/src/discovery/helpers.ts +41 -1
- package/src/eval/__tests__/budget-bridge.test.ts +1 -1
- package/src/eval/js/shared/prelude.txt +69 -17
- package/src/export/html/index.ts +3 -6
- package/src/extensibility/extensions/model-api.ts +41 -0
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +52 -1
- package/src/extensibility/extensions/wrapper.ts +41 -5
- package/src/extensibility/hooks/index.ts +2 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
- package/src/extensibility/plugins/loader.ts +30 -19
- package/src/extensibility/plugins/manager.ts +221 -90
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +96 -15
- package/src/goals/guided-setup.ts +133 -0
- package/src/goals/state.ts +1 -1
- package/src/hindsight/transcript.ts +1 -1
- package/src/index.ts +5 -0
- package/src/internal-urls/docs-index.generated.ts +10 -10
- package/src/internal-urls/history-protocol.ts +1 -1
- package/src/internal-urls/local-protocol.ts +29 -7
- package/src/main.ts +27 -7
- package/src/mcp/startup-events.ts +21 -0
- package/src/mcp/transports/stdio.ts +2 -1
- package/src/memories/index.ts +146 -11
- package/src/memory-backend/local-backend.ts +11 -5
- package/src/mnemopi/backend.ts +1 -0
- package/src/mnemopi/config.ts +26 -10
- package/src/modes/acp/acp-agent.ts +3 -5
- package/src/modes/components/agent-hub.ts +49 -4
- package/src/modes/components/assistant-message.ts +4 -37
- package/src/modes/components/compaction-summary-message.ts +125 -26
- package/src/modes/components/custom-editor.test.ts +96 -0
- package/src/modes/components/custom-editor.ts +164 -8
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/tool-execution.ts +82 -43
- package/src/modes/components/transcript-container.ts +70 -1
- package/src/modes/components/tree-selector.ts +1 -1
- package/src/modes/components/usage-row.ts +18 -0
- package/src/modes/components/user-message.ts +4 -2
- package/src/modes/controllers/command-controller.ts +14 -4
- package/src/modes/controllers/event-controller.ts +78 -11
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/input-controller.ts +258 -27
- package/src/modes/controllers/selector-controller.ts +12 -2
- package/src/modes/gradient-highlight.ts +21 -9
- package/src/modes/image-references.ts +20 -0
- package/src/modes/interactive-mode.ts +286 -40
- package/src/modes/magic-keywords.ts +27 -5
- package/src/modes/rpc/rpc-mode.ts +146 -14
- package/src/modes/rpc/rpc-subagents.ts +2 -2
- package/src/modes/rpc/rpc-types.ts +8 -2
- package/src/modes/runtime-init.ts +28 -3
- package/src/modes/theme/theme.ts +98 -50
- package/src/modes/types.ts +6 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +34 -6
- package/src/priority.json +5 -1
- package/src/prompts/agents/task.md +1 -0
- package/src/prompts/goals/guided-goal-interview.md +8 -0
- package/src/prompts/goals/guided-goal-system.md +12 -0
- package/src/prompts/memories/read-path.md +6 -0
- package/src/prompts/system/autolearn-guidance-learn.md +1 -0
- package/src/prompts/system/autolearn-guidance.md +7 -0
- package/src/prompts/system/autolearn-nudge.md +3 -0
- package/src/prompts/system/eager-task.md +7 -0
- package/src/prompts/system/eager-todo.md +11 -6
- package/src/prompts/system/subagent-system-prompt.md +4 -0
- package/src/prompts/system/system-prompt.md +10 -5
- package/src/prompts/system/title-marker-instruction.md +1 -0
- package/src/prompts/system/title-system-marker.md +16 -0
- package/src/prompts/tools/job.md +1 -0
- package/src/prompts/tools/learn.md +7 -0
- package/src/prompts/tools/manage-skill.md +9 -0
- package/src/prompts/tools/task.md +3 -0
- package/src/registry/agent-registry.ts +30 -0
- package/src/sdk.ts +88 -24
- package/src/secrets/obfuscator.ts +1 -1
- package/src/session/agent-session.ts +209 -87
- package/src/session/history-storage.ts +2 -2
- package/src/session/indexed-session-storage.ts +7 -17
- package/src/session/session-context.ts +352 -0
- package/src/session/session-entries.ts +194 -0
- package/src/session/session-listing.ts +588 -0
- package/src/session/session-loader.ts +106 -0
- package/src/session/session-manager.ts +933 -3145
- package/src/session/session-migrations.ts +78 -0
- package/src/session/session-paths.ts +193 -0
- package/src/session/session-persistence.ts +131 -0
- package/src/session/session-storage.ts +91 -50
- package/src/session/snapcompact-inline.ts +21 -1
- package/src/session/snapcompact-savings-journal.ts +113 -0
- package/src/session/tool-choice-queue.ts +23 -11
- package/src/slash-commands/builtin-registry.ts +25 -3
- package/src/stt/asr-client.ts +520 -0
- package/src/stt/asr-protocol.ts +65 -0
- package/src/stt/asr-worker.ts +790 -0
- package/src/stt/downloader.ts +107 -47
- package/src/stt/endpointer.ts +259 -0
- package/src/stt/index.ts +5 -1
- package/src/stt/models.ts +150 -0
- package/src/stt/recorder.ts +247 -60
- package/src/stt/stt-controller.ts +201 -22
- package/src/stt/transcriber.ts +37 -68
- package/src/stt/wav.ts +173 -0
- package/src/system-prompt.ts +8 -0
- package/src/task/agents.ts +1 -2
- package/src/task/executor.ts +49 -15
- package/src/task/index.ts +60 -6
- package/src/task/render.ts +83 -8
- package/src/task/types.ts +53 -0
- package/src/tools/ask.ts +8 -0
- package/src/tools/bash.ts +4 -3
- package/src/tools/eval-render.ts +4 -3
- package/src/tools/index.ts +40 -4
- package/src/tools/irc.ts +10 -2
- package/src/tools/job.ts +14 -2
- package/src/tools/learn.ts +144 -0
- package/src/tools/manage-skill.ts +104 -0
- package/src/tools/plan-mode-guard.ts +53 -19
- package/src/tools/renderers.ts +7 -11
- package/src/tools/ssh.ts +4 -3
- package/src/tools/todo.ts +1 -1
- package/src/tools/tts.ts +203 -92
- package/src/tools/write.ts +18 -2
- package/src/tts/downloader.ts +64 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/models.ts +137 -0
- package/src/tts/player.ts +137 -0
- package/src/tts/runtime.ts +21 -0
- package/src/tts/streaming-player.ts +266 -0
- package/src/tts/tts-client.ts +647 -0
- package/src/tts/tts-protocol.ts +60 -0
- package/src/tts/tts-worker.ts +497 -0
- package/src/tts/vocalizer.ts +162 -0
- package/src/tts/wav.ts +58 -0
- package/src/utils/title-generator.ts +48 -5
- package/src/utils/tool-choice.ts +16 -0
- package/src/utils/tools-manager.test.ts +25 -0
- package/src/utils/tools-manager.ts +19 -1
- package/src/web/scrapers/github.ts +96 -0
- package/src/web/search/index.ts +13 -0
- package/src/web/search/providers/searxng.ts +13 -1
- package/dist/types/stt/setup.d.ts +0 -18
- package/src/stt/setup.ts +0 -52
- package/src/stt/transcribe.py +0 -70
|
@@ -1,2760 +1,972 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
6
|
-
ImageContent,
|
|
7
|
-
Message,
|
|
8
|
-
MessageAttribution,
|
|
9
|
-
ProviderPayload,
|
|
10
|
-
ServiceTier,
|
|
11
|
-
TextContent,
|
|
12
|
-
Usage,
|
|
13
|
-
} from "@oh-my-pi/pi-ai";
|
|
14
|
-
import { getTerminalId } from "@oh-my-pi/pi-tui";
|
|
15
|
-
import {
|
|
16
|
-
getBlobsDir,
|
|
17
|
-
getAgentDir as getDefaultAgentDir,
|
|
18
|
-
getProjectDir,
|
|
19
|
-
getSessionsDir,
|
|
20
|
-
getTerminalSessionsDir,
|
|
21
|
-
hasFsCode,
|
|
22
|
-
isEnoent,
|
|
23
|
-
logger,
|
|
24
|
-
parseJsonlLenient,
|
|
25
|
-
pathIsWithin,
|
|
26
|
-
resolveEquivalentPath,
|
|
27
|
-
Snowflake,
|
|
28
|
-
toError,
|
|
29
|
-
} from "@oh-my-pi/pi-utils";
|
|
30
|
-
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
3
|
+
import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import { getBlobsDir, getProjectDir, getSessionsDir, isEnoent, logger, toError } from "@oh-my-pi/pi-utils";
|
|
31
5
|
import { ArtifactManager } from "./artifacts";
|
|
32
|
-
import {
|
|
33
|
-
type BlobPutOptions,
|
|
34
|
-
type BlobPutResult,
|
|
35
|
-
BlobStore,
|
|
36
|
-
externalizeImageData,
|
|
37
|
-
externalizeImageDataSync,
|
|
38
|
-
externalizeImageDataUrl,
|
|
39
|
-
externalizeImageDataUrlSync,
|
|
40
|
-
isBlobRef,
|
|
41
|
-
isImageDataUrl,
|
|
42
|
-
resolveImageData,
|
|
43
|
-
resolveImageDataUrl,
|
|
44
|
-
} from "./blob-store";
|
|
6
|
+
import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store";
|
|
45
7
|
import {
|
|
46
8
|
type BashExecutionMessage,
|
|
47
9
|
type CustomMessage,
|
|
48
|
-
createBranchSummaryMessage,
|
|
49
|
-
createCompactionSummaryMessage,
|
|
50
|
-
createCustomMessage,
|
|
51
10
|
type FileMentionMessage,
|
|
52
11
|
type HookMessage,
|
|
53
12
|
type PythonExecutionMessage,
|
|
54
13
|
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
55
14
|
stripInternalDetailsFields,
|
|
56
15
|
} from "./messages";
|
|
57
|
-
import type
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
type
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
export interface ModelChangeEntry extends SessionEntryBase {
|
|
97
|
-
type: "model_change";
|
|
98
|
-
/** Model in "provider/modelId" format */
|
|
99
|
-
model: string;
|
|
100
|
-
/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
|
|
101
|
-
role?: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface ServiceTierChangeEntry extends SessionEntryBase {
|
|
105
|
-
type: "service_tier_change";
|
|
106
|
-
serviceTier: ServiceTier | null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
110
|
-
type: "compaction";
|
|
111
|
-
summary: string;
|
|
112
|
-
shortSummary?: string;
|
|
113
|
-
firstKeptEntryId: string;
|
|
114
|
-
tokensBefore: number;
|
|
115
|
-
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
116
|
-
details?: T;
|
|
117
|
-
/** Hook-provided data to persist across compaction */
|
|
118
|
-
preserveData?: Record<string, unknown>;
|
|
119
|
-
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
|
|
120
|
-
fromExtension?: boolean;
|
|
121
|
-
}
|
|
16
|
+
import { type BuildSessionContextOptions, buildSessionContext, type SessionContext } from "./session-context";
|
|
17
|
+
import {
|
|
18
|
+
type BranchSummaryEntry,
|
|
19
|
+
type CompactionEntry,
|
|
20
|
+
CURRENT_SESSION_VERSION,
|
|
21
|
+
type CustomEntry,
|
|
22
|
+
type CustomMessageEntry,
|
|
23
|
+
type FileEntry,
|
|
24
|
+
type LabelEntry,
|
|
25
|
+
type MCPToolSelectionEntry,
|
|
26
|
+
type ModeChangeEntry,
|
|
27
|
+
type ModelChangeEntry,
|
|
28
|
+
type NewSessionOptions,
|
|
29
|
+
type ServiceTierChangeEntry,
|
|
30
|
+
type SessionEntry,
|
|
31
|
+
type SessionHeader,
|
|
32
|
+
type SessionInitEntry,
|
|
33
|
+
type SessionMessageEntry,
|
|
34
|
+
type SessionTreeNode,
|
|
35
|
+
type ThinkingLevelChangeEntry,
|
|
36
|
+
type TtsrInjectionEntry,
|
|
37
|
+
type UsageStatistics,
|
|
38
|
+
} from "./session-entries";
|
|
39
|
+
import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
|
|
40
|
+
import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
|
|
41
|
+
import { generateId, migrateToCurrentVersion } from "./session-migrations";
|
|
42
|
+
import {
|
|
43
|
+
computeDefaultSessionDir,
|
|
44
|
+
readTerminalBreadcrumbEntry,
|
|
45
|
+
resolveManagedSessionRoot,
|
|
46
|
+
writeTerminalBreadcrumb,
|
|
47
|
+
} from "./session-paths";
|
|
48
|
+
import { prepareEntryForPersistence } from "./session-persistence";
|
|
49
|
+
import {
|
|
50
|
+
FileSessionStorage,
|
|
51
|
+
MemorySessionStorage,
|
|
52
|
+
type SessionStorage,
|
|
53
|
+
type SessionStorageWriter,
|
|
54
|
+
} from "./session-storage";
|
|
122
55
|
|
|
123
|
-
|
|
124
|
-
type: "branch_summary";
|
|
125
|
-
fromId: string;
|
|
126
|
-
summary: string;
|
|
127
|
-
/** Extension-specific data (not sent to LLM) */
|
|
128
|
-
details?: T;
|
|
129
|
-
/** True if generated by an extension, false if pi-generated */
|
|
130
|
-
fromExtension?: boolean;
|
|
131
|
-
}
|
|
56
|
+
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
132
57
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
* Use customType to identify your extension's entries.
|
|
136
|
-
*
|
|
137
|
-
* Purpose: Persist extension state across session reloads. On reload, extensions can
|
|
138
|
-
* scan entries for their customType and reconstruct internal state.
|
|
139
|
-
*
|
|
140
|
-
* Does NOT participate in LLM context (ignored by buildSessionContext).
|
|
141
|
-
* For injecting content into context, see CustomMessageEntry.
|
|
142
|
-
*/
|
|
143
|
-
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
144
|
-
type: "custom";
|
|
145
|
-
customType: string;
|
|
146
|
-
data?: T;
|
|
58
|
+
function mintSessionId(): string {
|
|
59
|
+
return Bun.randomUUIDv7();
|
|
147
60
|
}
|
|
148
61
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
type: "label";
|
|
152
|
-
targetId: string;
|
|
153
|
-
label: string | undefined;
|
|
62
|
+
function nowIso(): string {
|
|
63
|
+
return new Date().toISOString();
|
|
154
64
|
}
|
|
155
65
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
type: "ttsr_injection";
|
|
159
|
-
/** Names of rules that were injected */
|
|
160
|
-
injectedRules: string[];
|
|
66
|
+
function fileSafeTimestamp(iso: string): string {
|
|
67
|
+
return iso.replace(/[:.]/g, "-");
|
|
161
68
|
}
|
|
162
69
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
type: "mcp_tool_selection";
|
|
166
|
-
/** MCP tool names selected for visibility in discovery mode. */
|
|
167
|
-
selectedToolNames: string[];
|
|
70
|
+
function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
|
|
71
|
+
return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
|
|
168
72
|
}
|
|
169
73
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
type: "session_init";
|
|
173
|
-
/** Full system prompt sent to the model */
|
|
174
|
-
systemPrompt: string;
|
|
175
|
-
/** Initial task/user message */
|
|
176
|
-
task: string;
|
|
177
|
-
/** Tools available to the agent */
|
|
178
|
-
tools: string[];
|
|
179
|
-
/** Output schema if structured output was requested */
|
|
180
|
-
outputSchema?: unknown;
|
|
74
|
+
function emptyUsageStatistics(): UsageStatistics {
|
|
75
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
181
76
|
}
|
|
182
77
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
mode: string;
|
|
188
|
-
/** Optional mode-specific data (e.g. plan file path) */
|
|
189
|
-
data?: Record<string, unknown>;
|
|
78
|
+
function taskUsageFrom(details: unknown): Usage | undefined {
|
|
79
|
+
if (details === null || typeof details !== "object") return undefined;
|
|
80
|
+
const maybeUsage = (details as Record<string, unknown>).usage;
|
|
81
|
+
return maybeUsage !== null && typeof maybeUsage === "object" ? (maybeUsage as Usage) : undefined;
|
|
190
82
|
}
|
|
191
83
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
* Use details for extension-specific metadata (not sent to LLM).
|
|
199
|
-
*
|
|
200
|
-
* display controls TUI rendering:
|
|
201
|
-
* - false: hidden entirely
|
|
202
|
-
* - true: rendered with distinct styling (different from user messages)
|
|
203
|
-
*/
|
|
204
|
-
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
205
|
-
type: "custom_message";
|
|
206
|
-
customType: string;
|
|
207
|
-
content: string | (TextContent | ImageContent)[];
|
|
208
|
-
details?: T;
|
|
209
|
-
display: boolean;
|
|
210
|
-
/** Who initiated this message for billing/attribution semantics. */
|
|
211
|
-
attribution?: MessageAttribution;
|
|
84
|
+
function entryUsage(entry: SessionEntry): Usage | undefined {
|
|
85
|
+
if (entry.type !== "message") return undefined;
|
|
86
|
+
const message = entry.message;
|
|
87
|
+
if (message.role === "assistant") return message.usage;
|
|
88
|
+
if (message.role === "toolResult" && message.toolName === "task") return taskUsageFrom(message.details);
|
|
89
|
+
return undefined;
|
|
212
90
|
}
|
|
213
91
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
| CustomEntry
|
|
223
|
-
| CustomMessageEntry
|
|
224
|
-
| LabelEntry
|
|
225
|
-
| TtsrInjectionEntry
|
|
226
|
-
| MCPToolSelectionEntry
|
|
227
|
-
| SessionInitEntry
|
|
228
|
-
| ModeChangeEntry;
|
|
229
|
-
|
|
230
|
-
/** Raw file entry (includes header) */
|
|
231
|
-
export type FileEntry = SessionHeader | SessionEntry;
|
|
232
|
-
|
|
233
|
-
/** Tree node for getTree() - defensive copy of session structure */
|
|
234
|
-
export interface SessionTreeNode {
|
|
235
|
-
entry: SessionEntry;
|
|
236
|
-
children: SessionTreeNode[];
|
|
237
|
-
/** Resolved label for this entry, if any */
|
|
238
|
-
label?: string;
|
|
92
|
+
function addUsage(target: UsageStatistics, usage: Usage | undefined): void {
|
|
93
|
+
if (!usage) return;
|
|
94
|
+
target.input += usage.input;
|
|
95
|
+
target.output += usage.output;
|
|
96
|
+
target.cacheRead += usage.cacheRead;
|
|
97
|
+
target.cacheWrite += usage.cacheWrite;
|
|
98
|
+
target.premiumRequests += usage.premiumRequests ?? 0;
|
|
99
|
+
target.cost += usage.cost.total;
|
|
239
100
|
}
|
|
240
101
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
thinkingLevel?: string;
|
|
244
|
-
serviceTier?: ServiceTier;
|
|
245
|
-
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
246
|
-
models: Record<string, string>;
|
|
247
|
-
/** Names of TTSR rules that have been injected this session */
|
|
248
|
-
injectedTtsrRules: string[];
|
|
249
|
-
/** MCP tool names selected through discovery for this session branch. */
|
|
250
|
-
selectedMCPToolNames: string[];
|
|
251
|
-
/** Whether this branch contains an explicit persisted MCP selection entry. */
|
|
252
|
-
hasPersistedMCPToolSelection: boolean;
|
|
253
|
-
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
254
|
-
mode: string;
|
|
255
|
-
/** Mode-specific data from the last mode_change entry */
|
|
256
|
-
modeData?: Record<string, unknown>;
|
|
102
|
+
function isAssistantEntry(entry: SessionEntry): boolean {
|
|
103
|
+
return entry.type === "message" && entry.message.role === "assistant";
|
|
257
104
|
}
|
|
258
105
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
/** Lists session model strings to try when restoring, in fallback order. */
|
|
262
|
-
export function getRestorableSessionModels(
|
|
263
|
-
models: Readonly<Record<string, string>>,
|
|
264
|
-
lastModelChangeRole: string | undefined,
|
|
265
|
-
): string[] {
|
|
266
|
-
const defaultModel = models.default;
|
|
267
|
-
if (
|
|
268
|
-
!lastModelChangeRole ||
|
|
269
|
-
lastModelChangeRole === "default" ||
|
|
270
|
-
lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
|
|
271
|
-
) {
|
|
272
|
-
return defaultModel ? [defaultModel] : [];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const roleModel = models[lastModelChangeRole];
|
|
276
|
-
if (!roleModel) return defaultModel ? [defaultModel] : [];
|
|
277
|
-
if (!defaultModel || roleModel === defaultModel) return [roleModel];
|
|
278
|
-
return [roleModel, defaultModel];
|
|
106
|
+
function orderedByTimestamp(a: SessionTreeNode, b: SessionTreeNode): number {
|
|
107
|
+
return new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime();
|
|
279
108
|
}
|
|
280
109
|
|
|
281
110
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* - `interrupted` — work was cut off mid-flight: a trailing assistant turn with
|
|
287
|
-
* pending tool calls, a trailing tool result the agent never continued from, or
|
|
288
|
-
* a length-truncated turn.
|
|
289
|
-
* - `aborted` — the last assistant turn was cancelled by the user.
|
|
290
|
-
* - `error` — the last assistant turn ended in an error.
|
|
291
|
-
* - `pending` — a trailing user message with no assistant reply persisted after it.
|
|
292
|
-
* - `unknown` — status could not be determined (empty/header-only session, or the
|
|
293
|
-
* final message was larger than the tail window that was read).
|
|
111
|
+
* Maintains the derived views over a session's entry list: id lookup, the
|
|
112
|
+
* parent→children adjacency, the resolved label map, the active leaf, and the
|
|
113
|
+
* running usage totals. Kept in lockstep with the manager's `#entries` so reads
|
|
114
|
+
* stay O(1)/O(children) instead of rescanning the whole journal.
|
|
294
115
|
*/
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
cwd: string;
|
|
302
|
-
title?: string;
|
|
303
|
-
/** Path to the parent session (if this session was forked). */
|
|
304
|
-
parentSessionPath?: string;
|
|
305
|
-
created: Date;
|
|
306
|
-
modified: Date;
|
|
307
|
-
messageCount: number;
|
|
308
|
-
/** File size in bytes on disk; used for compact list rendering. */
|
|
309
|
-
size: number;
|
|
310
|
-
firstMessage: string;
|
|
311
|
-
allMessagesText: string;
|
|
312
|
-
/**
|
|
313
|
-
* Coarse lifecycle status from the session's last persisted message. Optional:
|
|
314
|
-
* synthesized {@link SessionInfo}s (cross-project stubs, tests) leave it unset.
|
|
315
|
-
*/
|
|
316
|
-
status?: SessionStatus;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export type ReadonlySessionManager = Pick<
|
|
320
|
-
SessionManager,
|
|
321
|
-
| "getCwd"
|
|
322
|
-
| "getSessionDir"
|
|
323
|
-
| "getSessionId"
|
|
324
|
-
| "getSessionFile"
|
|
325
|
-
| "getSessionName"
|
|
326
|
-
| "getArtifactsDir"
|
|
327
|
-
| "getArtifactManager"
|
|
328
|
-
| "allocateArtifactPath"
|
|
329
|
-
| "saveArtifact"
|
|
330
|
-
| "getArtifactPath"
|
|
331
|
-
| "getLeafId"
|
|
332
|
-
| "getLeafEntry"
|
|
333
|
-
| "getEntry"
|
|
334
|
-
| "getLabel"
|
|
335
|
-
| "getBranch"
|
|
336
|
-
| "getHeader"
|
|
337
|
-
| "getEntries"
|
|
338
|
-
| "getTree"
|
|
339
|
-
| "getUsageStatistics"
|
|
340
|
-
| "putBlob"
|
|
341
|
-
| "putBlobSync"
|
|
342
|
-
>;
|
|
343
|
-
|
|
344
|
-
function createSessionId(): string {
|
|
345
|
-
return Bun.randomUUIDv7();
|
|
346
|
-
}
|
|
116
|
+
class SessionEntryIndex {
|
|
117
|
+
#entriesById = new Map<string, SessionEntry>();
|
|
118
|
+
#children = new Map<string | null, SessionEntry[]>();
|
|
119
|
+
#labels = new Map<string, string>();
|
|
120
|
+
#leaf: string | null = null;
|
|
121
|
+
#usage = emptyUsageStatistics();
|
|
347
122
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
123
|
+
clear(): void {
|
|
124
|
+
this.#entriesById.clear();
|
|
125
|
+
this.#children.clear();
|
|
126
|
+
this.#labels.clear();
|
|
127
|
+
this.#leaf = null;
|
|
128
|
+
this.#usage = emptyUsageStatistics();
|
|
353
129
|
}
|
|
354
|
-
return Snowflake.next(); // fallback to full snowflake id
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
|
|
358
|
-
function migrateV1ToV2(entries: FileEntry[]): void {
|
|
359
|
-
const ids = new Set<string>();
|
|
360
|
-
let prevId: string | null = null;
|
|
361
|
-
|
|
362
|
-
for (const entry of entries) {
|
|
363
|
-
if (entry.type === "session") {
|
|
364
|
-
entry.version = 2;
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
entry.id = generateId(ids);
|
|
369
|
-
entry.parentId = prevId;
|
|
370
|
-
prevId = entry.id;
|
|
371
|
-
|
|
372
|
-
// Convert firstKeptEntryIndex to firstKeptEntryId for compaction
|
|
373
|
-
if (entry.type === "compaction") {
|
|
374
|
-
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
|
|
375
|
-
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
376
|
-
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
377
|
-
if (targetEntry && targetEntry.type !== "session") {
|
|
378
|
-
comp.firstKeptEntryId = targetEntry.id;
|
|
379
|
-
}
|
|
380
|
-
delete comp.firstKeptEntryIndex;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
|
|
387
|
-
function migrateV2ToV3(entries: FileEntry[]): void {
|
|
388
|
-
for (const entry of entries) {
|
|
389
|
-
if (entry.type === "session") {
|
|
390
|
-
entry.version = 3;
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
130
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
(entry.message as { role: string }).role = "custom";
|
|
398
|
-
}
|
|
399
|
-
}
|
|
131
|
+
rebuild(entries: readonly SessionEntry[]): void {
|
|
132
|
+
this.clear();
|
|
133
|
+
for (const entry of entries) this.insert(entry);
|
|
400
134
|
}
|
|
401
|
-
}
|
|
402
135
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
*/
|
|
407
|
-
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
408
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
409
|
-
const version = header?.version ?? 1;
|
|
410
|
-
|
|
411
|
-
if (version >= CURRENT_SESSION_VERSION) return false;
|
|
136
|
+
insert(entry: SessionEntry): void {
|
|
137
|
+
this.#entriesById.set(entry.id, entry);
|
|
138
|
+
this.#leaf = entry.id;
|
|
412
139
|
|
|
413
|
-
|
|
414
|
-
|
|
140
|
+
const bucket = this.#children.get(entry.parentId);
|
|
141
|
+
if (bucket) bucket.push(entry);
|
|
142
|
+
else this.#children.set(entry.parentId, [entry]);
|
|
415
143
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
/** Exported for testing */
|
|
420
|
-
export function migrateSessionEntries(entries: FileEntry[]): void {
|
|
421
|
-
migrateToCurrentVersion(entries);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const migratedSessionRoots = new Set<string>();
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Merge or rename a legacy session directory into its canonical target.
|
|
428
|
-
* Best effort: callers decide whether migration failures should surface.
|
|
429
|
-
*/
|
|
430
|
-
function migrateSessionDirPath(oldPath: string, newPath: string): void {
|
|
431
|
-
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
432
|
-
if (existing?.isDirectory()) {
|
|
433
|
-
for (const file of fs.readdirSync(oldPath)) {
|
|
434
|
-
const src = path.join(oldPath, file);
|
|
435
|
-
const dst = path.join(newPath, file);
|
|
436
|
-
if (!fs.existsSync(dst)) {
|
|
437
|
-
fs.renameSync(src, dst);
|
|
438
|
-
}
|
|
144
|
+
if (entry.type === "label") {
|
|
145
|
+
if (entry.label) this.#labels.set(entry.targetId, entry.label);
|
|
146
|
+
else this.#labels.delete(entry.targetId);
|
|
439
147
|
}
|
|
440
|
-
fs.rmSync(oldPath, { recursive: true, force: true });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (existing) {
|
|
444
|
-
fs.rmSync(newPath, { recursive: true, force: true });
|
|
445
|
-
}
|
|
446
|
-
fs.renameSync(oldPath, newPath);
|
|
447
|
-
}
|
|
448
148
|
|
|
449
|
-
|
|
450
|
-
const resolvedCwd = path.resolve(cwd);
|
|
451
|
-
return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
|
|
455
|
-
const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
|
|
456
|
-
return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
|
|
460
|
-
const resolvedCwd = path.resolve(cwd);
|
|
461
|
-
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
|
|
462
|
-
const home = resolveEquivalentPath(os.homedir());
|
|
463
|
-
const tempRoot = resolveEquivalentPath(os.tmpdir());
|
|
464
|
-
const encodedDirName = pathIsWithin(home, canonicalCwd)
|
|
465
|
-
? encodeRelativeSessionDirName("-", home, canonicalCwd)
|
|
466
|
-
: pathIsWithin(tempRoot, canonicalCwd)
|
|
467
|
-
? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
|
|
468
|
-
: encodeLegacyAbsoluteSessionDirName(canonicalCwd);
|
|
469
|
-
return { encodedDirName, resolvedCwd };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
|
|
474
|
-
* Runs once per sessions root on first access, best-effort.
|
|
475
|
-
*/
|
|
476
|
-
function migrateHomeSessionDirs(sessionsRoot: string): void {
|
|
477
|
-
if (migratedSessionRoots.has(sessionsRoot)) return;
|
|
478
|
-
migratedSessionRoots.add(sessionsRoot);
|
|
479
|
-
|
|
480
|
-
const home = os.homedir();
|
|
481
|
-
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
|
|
482
|
-
const oldPrefix = `--${homeEncoded}-`;
|
|
483
|
-
const oldExact = `--${homeEncoded}--`;
|
|
484
|
-
|
|
485
|
-
let entries: string[];
|
|
486
|
-
try {
|
|
487
|
-
entries = fs.readdirSync(sessionsRoot);
|
|
488
|
-
} catch {
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
for (const entry of entries) {
|
|
493
|
-
let remainder: string;
|
|
494
|
-
if (entry === oldExact) {
|
|
495
|
-
remainder = "";
|
|
496
|
-
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
|
|
497
|
-
remainder = entry.slice(oldPrefix.length, -2);
|
|
498
|
-
} else {
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const newName = remainder ? `-${remainder}` : "-";
|
|
503
|
-
const oldPath = path.join(sessionsRoot, entry);
|
|
504
|
-
const newPath = path.join(sessionsRoot, newName);
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
migrateSessionDirPath(oldPath, newPath);
|
|
508
|
-
} catch {
|
|
509
|
-
// Best effort
|
|
510
|
-
}
|
|
149
|
+
addUsage(this.#usage, entryUsage(entry));
|
|
511
150
|
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
|
|
515
|
-
const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
|
|
516
|
-
if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
|
|
517
151
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
} catch {
|
|
521
|
-
// Best effort
|
|
152
|
+
has(id: string): boolean {
|
|
153
|
+
return this.#entriesById.has(id);
|
|
522
154
|
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
|
|
526
|
-
const currentDirName = path.basename(sessionDir);
|
|
527
|
-
const { encodedDirName } = getDefaultSessionDirName(cwd);
|
|
528
|
-
if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) {
|
|
529
|
-
return undefined;
|
|
530
|
-
}
|
|
531
|
-
return path.dirname(sessionDir);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/** Exported for compaction.test.ts */
|
|
535
|
-
export function parseSessionEntries(content: string): FileEntry[] {
|
|
536
|
-
return parseJsonlLenient<FileEntry>(content);
|
|
537
|
-
}
|
|
538
155
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (entries[i].type === "compaction") {
|
|
542
|
-
return entries[i] as CompactionEntry;
|
|
543
|
-
}
|
|
156
|
+
get(id: string): SessionEntry | undefined {
|
|
157
|
+
return this.#entriesById.get(id);
|
|
544
158
|
}
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
159
|
|
|
548
|
-
export interface BuildSessionContextOptions {
|
|
549
160
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* inline as a `compactionSummary` message at the position it fired rather
|
|
553
|
-
* than replacing the history before it. Display-only — never send the
|
|
554
|
-
* result to a provider.
|
|
161
|
+
* The live id→entry map. Read-only for callers (lookups + `generateId`
|
|
162
|
+
* collision checks); never mutate it directly — go through `insert`/`rebuild`.
|
|
555
163
|
*/
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Build the session context from entries using tree traversal.
|
|
561
|
-
* If leafId is provided, walks from that entry to root.
|
|
562
|
-
* Handles compaction and branch summaries along the path.
|
|
563
|
-
*/
|
|
564
|
-
export function buildSessionContext(
|
|
565
|
-
entries: SessionEntry[],
|
|
566
|
-
leafId?: string | null,
|
|
567
|
-
byId?: Map<string, SessionEntry>,
|
|
568
|
-
options?: BuildSessionContextOptions,
|
|
569
|
-
): SessionContext {
|
|
570
|
-
// Build uuid index if not available
|
|
571
|
-
if (!byId) {
|
|
572
|
-
byId = new Map<string, SessionEntry>();
|
|
573
|
-
for (const entry of entries) {
|
|
574
|
-
byId.set(entry.id, entry);
|
|
575
|
-
}
|
|
164
|
+
entriesById(): Map<string, SessionEntry> {
|
|
165
|
+
return this.#entriesById;
|
|
576
166
|
}
|
|
577
167
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (leafId === null) {
|
|
581
|
-
// Explicitly null - return no messages (navigated to before first entry)
|
|
582
|
-
return {
|
|
583
|
-
messages: [],
|
|
584
|
-
thinkingLevel: "off",
|
|
585
|
-
serviceTier: undefined,
|
|
586
|
-
models: {},
|
|
587
|
-
injectedTtsrRules: [],
|
|
588
|
-
selectedMCPToolNames: [],
|
|
589
|
-
hasPersistedMCPToolSelection: false,
|
|
590
|
-
mode: "none",
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
if (leafId) {
|
|
594
|
-
leaf = byId.get(leafId);
|
|
595
|
-
}
|
|
596
|
-
if (!leaf) {
|
|
597
|
-
// Fallback to last entry (when leafId is undefined)
|
|
598
|
-
leaf = entries[entries.length - 1];
|
|
168
|
+
leafId(): string | null {
|
|
169
|
+
return this.#leaf;
|
|
599
170
|
}
|
|
600
171
|
|
|
601
|
-
|
|
602
|
-
return
|
|
603
|
-
messages: [],
|
|
604
|
-
thinkingLevel: "off",
|
|
605
|
-
serviceTier: undefined,
|
|
606
|
-
models: {},
|
|
607
|
-
injectedTtsrRules: [],
|
|
608
|
-
selectedMCPToolNames: [],
|
|
609
|
-
hasPersistedMCPToolSelection: false,
|
|
610
|
-
mode: "none",
|
|
611
|
-
};
|
|
172
|
+
leafEntry(): SessionEntry | undefined {
|
|
173
|
+
return this.#leaf ? this.#entriesById.get(this.#leaf) : undefined;
|
|
612
174
|
}
|
|
613
175
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
let current: SessionEntry | undefined = leaf;
|
|
617
|
-
while (current) {
|
|
618
|
-
path.unshift(current);
|
|
619
|
-
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Extract settings and find compaction
|
|
623
|
-
let thinkingLevel: string | undefined = "off";
|
|
624
|
-
let serviceTier: ServiceTier | undefined;
|
|
625
|
-
const models: Record<string, string> = {};
|
|
626
|
-
let compaction: CompactionEntry | null = null;
|
|
627
|
-
const injectedTtsrRulesSet = new Set<string>();
|
|
628
|
-
let selectedMCPToolNames: string[] = [];
|
|
629
|
-
let hasPersistedMCPToolSelection = false;
|
|
630
|
-
let mode = "none";
|
|
631
|
-
let modeData: Record<string, unknown> | undefined;
|
|
632
|
-
// Track whether an explicit `model_change` with role="default" has been
|
|
633
|
-
// seen on this path. Once a user (or the agent itself) records an
|
|
634
|
-
// explicit default, later assistant-message inference must NOT overwrite
|
|
635
|
-
// it: temporary fallbacks (retry fallback, context promotion) and
|
|
636
|
-
// server-side model downgrades both produce assistant messages tagged
|
|
637
|
-
// with the wrong model id, which previously clobbered the user's pick on
|
|
638
|
-
// resume (issue #849).
|
|
639
|
-
let hasExplicitDefaultModel = false;
|
|
640
|
-
|
|
641
|
-
for (const entry of path) {
|
|
642
|
-
if (entry.type === "thinking_level_change") {
|
|
643
|
-
thinkingLevel = entry.thinkingLevel ?? "off";
|
|
644
|
-
} else if (entry.type === "model_change") {
|
|
645
|
-
// New format: { model: "provider/id", role?: string }
|
|
646
|
-
if (entry.model) {
|
|
647
|
-
const role = entry.role ?? "default";
|
|
648
|
-
models[role] = entry.model;
|
|
649
|
-
if (role === "default") {
|
|
650
|
-
hasExplicitDefaultModel = true;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
} else if (entry.type === "service_tier_change") {
|
|
654
|
-
serviceTier = entry.serviceTier ?? undefined;
|
|
655
|
-
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
656
|
-
// Legacy fallback: infer default model from assistant messages only
|
|
657
|
-
// when no explicit `model_change` (role=default) entry has been
|
|
658
|
-
// recorded yet. Newer sessions always record an explicit default
|
|
659
|
-
// model_change at the start of the conversation, so this branch is
|
|
660
|
-
// only used to keep pre-model_change sessions working.
|
|
661
|
-
if (!hasExplicitDefaultModel) {
|
|
662
|
-
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
663
|
-
}
|
|
664
|
-
} else if (entry.type === "compaction") {
|
|
665
|
-
compaction = entry;
|
|
666
|
-
} else if (entry.type === "ttsr_injection") {
|
|
667
|
-
// Collect injected TTSR rule names
|
|
668
|
-
for (const ruleName of entry.injectedRules) {
|
|
669
|
-
injectedTtsrRulesSet.add(ruleName);
|
|
670
|
-
}
|
|
671
|
-
} else if (entry.type === "mcp_tool_selection") {
|
|
672
|
-
selectedMCPToolNames = [...entry.selectedToolNames];
|
|
673
|
-
hasPersistedMCPToolSelection = true;
|
|
674
|
-
} else if (entry.type === "mode_change") {
|
|
675
|
-
mode = entry.mode;
|
|
676
|
-
modeData = entry.data;
|
|
677
|
-
}
|
|
176
|
+
setLeaf(id: string | null): void {
|
|
177
|
+
this.#leaf = id;
|
|
678
178
|
}
|
|
679
179
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
// Build messages and collect corresponding entries
|
|
683
|
-
// When there's a compaction, we need to:
|
|
684
|
-
// 1. Emit summary first (entry = compaction)
|
|
685
|
-
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
686
|
-
// 3. Emit messages after compaction
|
|
687
|
-
const messages: AgentMessage[] = [];
|
|
688
|
-
|
|
689
|
-
const appendMessage = (entry: SessionEntry) => {
|
|
690
|
-
if (entry.type === "message") {
|
|
691
|
-
messages.push(entry.message);
|
|
692
|
-
} else if (entry.type === "custom_message") {
|
|
693
|
-
messages.push(
|
|
694
|
-
createCustomMessage(
|
|
695
|
-
entry.customType,
|
|
696
|
-
entry.content,
|
|
697
|
-
entry.display,
|
|
698
|
-
entry.details,
|
|
699
|
-
entry.timestamp,
|
|
700
|
-
entry.attribution,
|
|
701
|
-
),
|
|
702
|
-
);
|
|
703
|
-
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
704
|
-
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
705
|
-
}
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
if (options?.transcript) {
|
|
709
|
-
// Display transcript: every entry in chronological order. Compactions do
|
|
710
|
-
// not erase prior history here — each renders inline (as a divider in the
|
|
711
|
-
// TUI) at the point it fired, with any snapcompact frames re-attached so
|
|
712
|
-
// the component can report them.
|
|
713
|
-
for (const entry of path) {
|
|
714
|
-
if (entry.type === "compaction") {
|
|
715
|
-
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
|
|
716
|
-
messages.push(
|
|
717
|
-
createCompactionSummaryMessage(
|
|
718
|
-
entry.summary,
|
|
719
|
-
entry.tokensBefore,
|
|
720
|
-
entry.timestamp,
|
|
721
|
-
entry.shortSummary,
|
|
722
|
-
undefined,
|
|
723
|
-
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
724
|
-
),
|
|
725
|
-
);
|
|
726
|
-
} else {
|
|
727
|
-
appendMessage(entry);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
} else if (compaction) {
|
|
731
|
-
const providerPayload: ProviderPayload | undefined = (() => {
|
|
732
|
-
const candidate = compaction.preserveData?.openaiRemoteCompaction;
|
|
733
|
-
if (!candidate || typeof candidate !== "object") return undefined;
|
|
734
|
-
const remote = candidate as { provider?: unknown; replacementHistory?: unknown };
|
|
735
|
-
if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined;
|
|
736
|
-
if (!Array.isArray(remote.replacementHistory)) return undefined;
|
|
737
|
-
return {
|
|
738
|
-
type: "openaiResponsesHistory",
|
|
739
|
-
provider: remote.provider,
|
|
740
|
-
items: remote.replacementHistory as Array<Record<string, unknown>>,
|
|
741
|
-
};
|
|
742
|
-
})();
|
|
743
|
-
const remoteReplacementHistory = providerPayload?.items;
|
|
744
|
-
|
|
745
|
-
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
746
|
-
// model can keep reading the archived history after every context rebuild.
|
|
747
|
-
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
748
|
-
messages.push(
|
|
749
|
-
createCompactionSummaryMessage(
|
|
750
|
-
compaction.summary,
|
|
751
|
-
compaction.tokensBefore,
|
|
752
|
-
compaction.timestamp,
|
|
753
|
-
compaction.shortSummary,
|
|
754
|
-
providerPayload,
|
|
755
|
-
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
756
|
-
),
|
|
757
|
-
);
|
|
758
|
-
|
|
759
|
-
// Find compaction index in path
|
|
760
|
-
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
|
|
761
|
-
|
|
762
|
-
if (!remoteReplacementHistory) {
|
|
763
|
-
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
|
764
|
-
let foundFirstKept = false;
|
|
765
|
-
for (let i = 0; i < compactionIdx; i++) {
|
|
766
|
-
const entry = path[i];
|
|
767
|
-
if (entry.id === compaction.firstKeptEntryId) {
|
|
768
|
-
foundFirstKept = true;
|
|
769
|
-
}
|
|
770
|
-
if (foundFirstKept) {
|
|
771
|
-
appendMessage(entry);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Emit messages after compaction
|
|
777
|
-
for (let i = compactionIdx + 1; i < path.length; i++) {
|
|
778
|
-
const entry = path[i];
|
|
779
|
-
appendMessage(entry);
|
|
780
|
-
}
|
|
781
|
-
} else {
|
|
782
|
-
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
783
|
-
for (const entry of path) {
|
|
784
|
-
appendMessage(entry);
|
|
785
|
-
}
|
|
180
|
+
childrenOf(parentId: string): SessionEntry[] {
|
|
181
|
+
return [...(this.#children.get(parentId) ?? [])];
|
|
786
182
|
}
|
|
787
183
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
// This happens whenever the leaf (or a branch point) lands such that an assistant
|
|
791
|
-
// turn's tool results are off the selected path: its result children live on a
|
|
792
|
-
// sibling branch, or it is the leaf itself (results are children below it). Left
|
|
793
|
-
// in place, `transformMessages` fabricates one synthetic "aborted"/"No result
|
|
794
|
-
// provided" result per dangling call, which render as phantom failed calls and
|
|
795
|
-
// re-inject the failed batch into the model's
|
|
796
|
-
// context — the rewind/restore loop.
|
|
797
|
-
//
|
|
798
|
-
// Stripping is necessary but not sufficient: a *modified* assistant turn that still
|
|
799
|
-
// carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
|
|
800
|
-
// blocks in the latest assistant message cannot be modified", and signed thinking
|
|
801
|
-
// replayed out of its original turn shape can also fail signature validation (this
|
|
802
|
-
// bites the handoff/branch-summary request). So when we rewrite a turn we also
|
|
803
|
-
// neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
|
|
804
|
-
// plaintext to keep) and clear `thinking` signatures so the provider encoder
|
|
805
|
-
// downgrades them to plain text (verified accepted by the live API), preserving the
|
|
806
|
-
// visible reasoning while removing the immutability/invalid-signature hazard. Drop a
|
|
807
|
-
// turn left with no content. (Live turns never qualify: their results are persisted
|
|
808
|
-
// on the same path before any context rebuild.)
|
|
809
|
-
const pairedToolResultIds = new Set<string>();
|
|
810
|
-
for (const message of messages) {
|
|
811
|
-
if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
|
|
812
|
-
}
|
|
813
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
814
|
-
const message = messages[i];
|
|
815
|
-
if (message.role !== "assistant") continue;
|
|
816
|
-
const hasDangling = message.content.some(
|
|
817
|
-
block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
|
|
818
|
-
);
|
|
819
|
-
if (!hasDangling) continue;
|
|
820
|
-
const normalized = message.content
|
|
821
|
-
.filter(
|
|
822
|
-
block =>
|
|
823
|
-
!(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
|
|
824
|
-
)
|
|
825
|
-
.map(block =>
|
|
826
|
-
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
|
|
827
|
-
);
|
|
828
|
-
if (normalized.length === 0) {
|
|
829
|
-
messages.splice(i, 1);
|
|
830
|
-
} else {
|
|
831
|
-
messages[i] = { ...message, content: normalized };
|
|
832
|
-
}
|
|
184
|
+
labelFor(id: string): string | undefined {
|
|
185
|
+
return this.#labels.get(id);
|
|
833
186
|
}
|
|
834
187
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
thinkingLevel,
|
|
838
|
-
serviceTier,
|
|
839
|
-
models,
|
|
840
|
-
injectedTtsrRules,
|
|
841
|
-
selectedMCPToolNames,
|
|
842
|
-
hasPersistedMCPToolSelection,
|
|
843
|
-
mode,
|
|
844
|
-
modeData,
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Compute the default session directory for a cwd.
|
|
850
|
-
* Classifies cwd by canonical location so symlink/alias paths resolve to the
|
|
851
|
-
* same home-relative or temp-root directory names as their real targets.
|
|
852
|
-
*/
|
|
853
|
-
function computeDefaultSessionDir(
|
|
854
|
-
cwd: string,
|
|
855
|
-
storage: SessionStorage,
|
|
856
|
-
sessionsRoot: string = getSessionsDir(),
|
|
857
|
-
): string {
|
|
858
|
-
const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
|
|
859
|
-
migrateHomeSessionDirs(sessionsRoot);
|
|
860
|
-
const sessionDir = path.join(sessionsRoot, encodedDirName);
|
|
861
|
-
migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
|
|
862
|
-
storage.ensureDirSync(sessionDir);
|
|
863
|
-
return sessionDir;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// =============================================================================
|
|
867
|
-
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
|
|
868
|
-
// =============================================================================
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* Write a breadcrumb linking the current terminal to a session file.
|
|
872
|
-
* The breadcrumb contains the cwd and session path so --continue can
|
|
873
|
-
* find "this terminal's last session" even when running concurrent instances.
|
|
874
|
-
*/
|
|
875
|
-
function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
|
876
|
-
const terminalId = getTerminalId();
|
|
877
|
-
if (!terminalId) return;
|
|
878
|
-
|
|
879
|
-
const breadcrumbDir = getTerminalSessionsDir();
|
|
880
|
-
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
|
|
881
|
-
const content = `${cwd}\n${sessionFile}\n`;
|
|
882
|
-
// Best-effort — don't break session creation if breadcrumb fails
|
|
883
|
-
Bun.write(breadcrumbFile, content).catch(() => {});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
interface TerminalBreadcrumb {
|
|
887
|
-
cwd: string;
|
|
888
|
-
sessionFile: string;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* Read the raw terminal breadcrumb for the current terminal.
|
|
893
|
-
* Returns the recorded cwd + session file (verified to exist) regardless of
|
|
894
|
-
* whether the recorded cwd still matches the current one. Callers decide how
|
|
895
|
-
* to interpret a cwd mismatch (e.g. a moved/renamed worktree).
|
|
896
|
-
*/
|
|
897
|
-
async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
|
|
898
|
-
const terminalId = getTerminalId();
|
|
899
|
-
if (!terminalId) return null;
|
|
900
|
-
|
|
901
|
-
try {
|
|
902
|
-
const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
|
|
903
|
-
const content = await Bun.file(breadcrumbFile).text();
|
|
904
|
-
const lines = content.trim().split("\n");
|
|
905
|
-
if (lines.length < 2) return null;
|
|
906
|
-
|
|
907
|
-
const breadcrumbCwd = lines[0];
|
|
908
|
-
const sessionFile = lines[1];
|
|
909
|
-
|
|
910
|
-
// Verify the session file still exists
|
|
911
|
-
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
912
|
-
if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
|
|
913
|
-
} catch (err) {
|
|
914
|
-
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
915
|
-
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
916
|
-
}
|
|
917
|
-
return null;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/** Exported for testing */
|
|
921
|
-
export async function loadEntriesFromFile(
|
|
922
|
-
filePath: string,
|
|
923
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
924
|
-
): Promise<FileEntry[]> {
|
|
925
|
-
let content: string;
|
|
926
|
-
try {
|
|
927
|
-
content = await storage.readText(filePath);
|
|
928
|
-
} catch (err) {
|
|
929
|
-
if (isEnoent(err)) return [];
|
|
930
|
-
throw err;
|
|
188
|
+
labelsInEffect(): IterableIterator<[string, string]> {
|
|
189
|
+
return this.#labels.entries();
|
|
931
190
|
}
|
|
932
|
-
const entries = parseJsonlLenient<FileEntry>(content);
|
|
933
191
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const header = entries[0] as SessionHeader;
|
|
937
|
-
if (header.type !== "session" || typeof header.id !== "string") {
|
|
938
|
-
return [];
|
|
192
|
+
usageSnapshot(): UsageStatistics {
|
|
193
|
+
return { ...this.#usage };
|
|
939
194
|
}
|
|
940
195
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
* Resolve blob references in loaded entries, restoring both session image blocks and persisted
|
|
946
|
-
* provider image URLs back to the inline data expected by downstream transports. Mutates entries in place.
|
|
947
|
-
*/
|
|
948
|
-
function hasImageUrl(value: unknown): value is { image_url: string } {
|
|
949
|
-
return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise<void> {
|
|
953
|
-
if (Array.isArray(value)) {
|
|
954
|
-
await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore)));
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
196
|
+
pathTo(id: string | null | undefined = this.#leaf): SessionEntry[] {
|
|
197
|
+
const branch: SessionEntry[] = [];
|
|
198
|
+
const seen = new Set<string>();
|
|
199
|
+
let cursor = id ? this.#entriesById.get(id) : undefined;
|
|
957
200
|
|
|
958
|
-
|
|
201
|
+
while (cursor && !seen.has(cursor.id)) {
|
|
202
|
+
seen.add(cursor.id);
|
|
203
|
+
branch.unshift(cursor);
|
|
204
|
+
cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
|
|
205
|
+
}
|
|
959
206
|
|
|
960
|
-
|
|
961
|
-
value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
|
|
207
|
+
return branch;
|
|
962
208
|
}
|
|
963
209
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
|
|
968
|
-
const promises: Promise<void>[] = [];
|
|
969
|
-
|
|
970
|
-
for (const entry of entries) {
|
|
971
|
-
if (entry.type === "session") continue;
|
|
210
|
+
tree(entries: readonly SessionEntry[]): SessionTreeNode[] {
|
|
211
|
+
const nodes = new Map<string, SessionTreeNode>();
|
|
212
|
+
const roots: SessionTreeNode[] = [];
|
|
972
213
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
contentArray = entry.message.content;
|
|
976
|
-
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
|
|
977
|
-
contentArray = entry.content;
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
nodes.set(entry.id, { entry, children: [], label: this.#labels.get(entry.id) });
|
|
978
216
|
}
|
|
979
217
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
}),
|
|
987
|
-
);
|
|
988
|
-
}
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const node = nodes.get(entry.id)!;
|
|
220
|
+
const parentId = entry.parentId;
|
|
221
|
+
if (parentId === null || parentId === entry.id) {
|
|
222
|
+
roots.push(node);
|
|
223
|
+
continue;
|
|
989
224
|
}
|
|
990
|
-
}
|
|
991
225
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
await Promise.all(promises);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Read-only message view of a session file: load entries, migrate to the
|
|
1000
|
-
* current version, resolve blob refs, and build the context along the
|
|
1001
|
-
* persisted leaf path (last entry). Does NOT create a writer or take the
|
|
1002
|
-
* session lock — safe to call against a file another session is writing.
|
|
1003
|
-
*/
|
|
1004
|
-
export async function loadSessionMessagesReadOnly(filePath: string): Promise<AgentMessage[]> {
|
|
1005
|
-
const entries = await loadEntriesFromFile(filePath);
|
|
1006
|
-
if (entries.length === 0) return [];
|
|
1007
|
-
migrateToCurrentVersion(entries);
|
|
1008
|
-
await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
|
|
1009
|
-
const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
|
|
1010
|
-
return buildSessionContext(sessionEntries).messages;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Lightweight metadata for a session file, used in session picker UI.
|
|
1015
|
-
* Uses lazy getters to defer string formatting until actually displayed.
|
|
1016
|
-
*/
|
|
1017
|
-
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
1018
|
-
if (!value) return undefined;
|
|
1019
|
-
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
1020
|
-
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
|
|
1021
|
-
const trimmed = stripped.trim();
|
|
1022
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
class RecentSessionInfo {
|
|
1026
|
-
#fullName: string | undefined;
|
|
1027
|
-
#timeAgo: string | undefined;
|
|
1028
|
-
readonly #headerTimestamp: string | undefined;
|
|
1029
|
-
|
|
1030
|
-
constructor(
|
|
1031
|
-
readonly path: string,
|
|
1032
|
-
readonly mtime: number,
|
|
1033
|
-
header: Record<string, unknown>,
|
|
1034
|
-
firstPrompt?: string,
|
|
1035
|
-
) {
|
|
1036
|
-
// Prefer an explicit title, then the first user prompt. The raw UUID `id` is
|
|
1037
|
-
// intentionally not used as a fallback: showing it as a "name" is unfriendly and
|
|
1038
|
-
// indistinguishable from neighboring sessions in the UI. The friendly fallback is
|
|
1039
|
-
// derived lazily in `fullName` from the session timestamp.
|
|
1040
|
-
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
1041
|
-
this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
|
|
1042
|
-
this.#headerTimestamp = trystr(header.timestamp);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** Display name. Falls back to a timestamp-based label, never the raw UUID. */
|
|
1046
|
-
get fullName(): string {
|
|
1047
|
-
if (this.#fullName) return this.#fullName;
|
|
1048
|
-
const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN;
|
|
1049
|
-
const date = new Date(Number.isFinite(ts) ? ts : this.mtime);
|
|
1050
|
-
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
1051
|
-
this.#fullName = `Untitled · ${time}`;
|
|
1052
|
-
return this.#fullName;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Display name without an arbitrary length cap. The renderer is responsible for
|
|
1057
|
-
* width-aware truncation so adjacent fields (e.g. the relative time) stay visible.
|
|
1058
|
-
*/
|
|
1059
|
-
get name(): string {
|
|
1060
|
-
return this.fullName;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
1064
|
-
get timeAgo(): string {
|
|
1065
|
-
if (this.#timeAgo) return this.#timeAgo;
|
|
1066
|
-
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
|
|
1067
|
-
return this.#timeAgo;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Extracts the text content from a user message entry.
|
|
1073
|
-
* Returns undefined if the entry is not a user message or has no text.
|
|
1074
|
-
*/
|
|
1075
|
-
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
|
|
1076
|
-
for (const entry of entries) {
|
|
1077
|
-
if (entry.type !== "message") continue;
|
|
1078
|
-
const message = entry.message as Record<string, unknown> | undefined;
|
|
1079
|
-
if (message?.role !== "user") continue;
|
|
1080
|
-
const content = message.content;
|
|
1081
|
-
if (typeof content === "string") return content;
|
|
1082
|
-
if (Array.isArray(content)) {
|
|
1083
|
-
for (const block of content) {
|
|
1084
|
-
if (typeof block === "object" && block !== null && "text" in block) {
|
|
1085
|
-
const text = (block as { text: unknown }).text;
|
|
1086
|
-
if (typeof text === "string") return text;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
226
|
+
const parent = nodes.get(parentId);
|
|
227
|
+
if (parent) parent.children.push(node);
|
|
228
|
+
else roots.push(node);
|
|
1089
229
|
}
|
|
1090
|
-
}
|
|
1091
|
-
return undefined;
|
|
1092
|
-
}
|
|
1093
230
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
* leave the user's last good state stranded outside the loader's view.
|
|
1100
|
-
*
|
|
1101
|
-
* Exported for testing.
|
|
1102
|
-
*/
|
|
1103
|
-
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
|
|
1104
|
-
let backups: string[];
|
|
1105
|
-
try {
|
|
1106
|
-
backups = storage.listFilesSync(sessionDir, "*.bak");
|
|
1107
|
-
} catch {
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
if (backups.length === 0) return;
|
|
1111
|
-
// For each primary path, pick the newest backup (highest mtime) as the recovery source.
|
|
1112
|
-
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
|
|
1113
|
-
for (const backup of backups) {
|
|
1114
|
-
const name = path.basename(backup);
|
|
1115
|
-
// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
|
|
1116
|
-
if (!name.endsWith(".bak")) continue;
|
|
1117
|
-
const trimmed = name.slice(0, -".bak".length);
|
|
1118
|
-
const dotIdx = trimmed.lastIndexOf(".");
|
|
1119
|
-
if (dotIdx <= 0) continue;
|
|
1120
|
-
const primaryName = trimmed.slice(0, dotIdx);
|
|
1121
|
-
if (!primaryName.endsWith(".jsonl")) continue;
|
|
1122
|
-
const primaryPath = path.join(sessionDir, primaryName);
|
|
1123
|
-
let mtimeMs = 0;
|
|
1124
|
-
try {
|
|
1125
|
-
mtimeMs = storage.statSync(backup).mtimeMs;
|
|
1126
|
-
} catch {
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1129
|
-
const existing = candidates.get(primaryPath);
|
|
1130
|
-
if (!existing || mtimeMs > existing.mtimeMs) {
|
|
1131
|
-
candidates.set(primaryPath, { backup, mtimeMs });
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
for (const [primaryPath, { backup }] of candidates) {
|
|
1135
|
-
if (storage.existsSync(primaryPath)) continue;
|
|
1136
|
-
try {
|
|
1137
|
-
await storage.rename(backup, primaryPath);
|
|
1138
|
-
logger.warn("Recovered orphaned session backup", {
|
|
1139
|
-
sessionFile: primaryPath,
|
|
1140
|
-
backupPath: backup,
|
|
1141
|
-
});
|
|
1142
|
-
} catch (err) {
|
|
1143
|
-
logger.warn("Failed to recover orphaned session backup", {
|
|
1144
|
-
sessionFile: primaryPath,
|
|
1145
|
-
backupPath: backup,
|
|
1146
|
-
error: toError(err).message,
|
|
1147
|
-
});
|
|
231
|
+
const stack = [...roots];
|
|
232
|
+
while (stack.length > 0) {
|
|
233
|
+
const node = stack.pop()!;
|
|
234
|
+
node.children.sort(orderedByTimestamp);
|
|
235
|
+
stack.push(...node.children);
|
|
1148
236
|
}
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
1154
|
-
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
1155
|
-
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
1156
|
-
*/
|
|
1157
|
-
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
1158
|
-
await recoverOrphanedBackups(sessionDir, storage);
|
|
1159
|
-
try {
|
|
1160
|
-
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
1161
|
-
const sessions: RecentSessionInfo[] = [];
|
|
1162
|
-
await Promise.all(
|
|
1163
|
-
files.map(async (path: string) => {
|
|
1164
|
-
try {
|
|
1165
|
-
const [content] = await storage.readTextSlices(path, 4096, 0);
|
|
1166
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1167
|
-
if (entries.length === 0) return;
|
|
1168
|
-
const header = entries[0] as Record<string, unknown>;
|
|
1169
|
-
if (header.type !== "session" || typeof header.id !== "string") return;
|
|
1170
|
-
const mtime = storage.statSync(path).mtimeMs;
|
|
1171
|
-
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
1172
|
-
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
|
|
1173
|
-
} catch {}
|
|
1174
|
-
}),
|
|
1175
|
-
);
|
|
1176
|
-
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
1177
|
-
} catch {
|
|
1178
|
-
return [];
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
/** Exported for testing */
|
|
1183
|
-
export async function findMostRecentSession(
|
|
1184
|
-
sessionDir: string,
|
|
1185
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1186
|
-
): Promise<string | null> {
|
|
1187
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1188
|
-
return sessions[0]?.path || null;
|
|
1189
|
-
}
|
|
1190
237
|
|
|
1191
|
-
|
|
1192
|
-
function formatTimeAgo(date: Date): string {
|
|
1193
|
-
const now = Date.now();
|
|
1194
|
-
const diffMs = now - date.getTime();
|
|
1195
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
1196
|
-
const diffHours = Math.floor(diffMs / 3600000);
|
|
1197
|
-
const diffDays = Math.floor(diffMs / 86400000);
|
|
1198
|
-
|
|
1199
|
-
if (diffMins < 1) return "just now";
|
|
1200
|
-
if (diffMins < 60) return `${diffMins}m ago`;
|
|
1201
|
-
if (diffHours < 24) return `${diffHours}h ago`;
|
|
1202
|
-
if (diffDays < 7) return `${diffDays}d ago`;
|
|
1203
|
-
return date.toLocaleDateString();
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const MAX_PERSIST_CHARS = 500_000;
|
|
1207
|
-
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
1208
|
-
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
1209
|
-
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
1210
|
-
const TEXT_CONTENT_KEY = "content";
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Recursively truncate large strings in an object for session persistence.
|
|
1214
|
-
* - Truncates any oversized string fields (key-agnostic)
|
|
1215
|
-
* - Replaces oversized image blocks with text notices
|
|
1216
|
-
* - Updates lineCount when content is truncated
|
|
1217
|
-
* - Returns original object if no changes needed (structural sharing)
|
|
1218
|
-
*/
|
|
1219
|
-
function truncateString(value: string, maxLength: number): string {
|
|
1220
|
-
if (value.length <= maxLength) return value;
|
|
1221
|
-
let truncated = value.slice(0, maxLength);
|
|
1222
|
-
if (truncated.length > 0) {
|
|
1223
|
-
const last = truncated.charCodeAt(truncated.length - 1);
|
|
1224
|
-
if (last >= 0xd800 && last <= 0xdbff) {
|
|
1225
|
-
truncated = truncated.slice(0, -1);
|
|
1226
|
-
}
|
|
238
|
+
return roots;
|
|
1227
239
|
}
|
|
1228
|
-
return truncated;
|
|
1229
240
|
}
|
|
1230
241
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1256
|
-
return externalizeImageDataUrl(blobStore, obj);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1260
|
-
// Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
|
|
1261
|
-
// Truncation would produce an invalid signature that the API rejects.
|
|
1262
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1263
|
-
return "";
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1267
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
return obj;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
if (Array.isArray(obj)) {
|
|
1274
|
-
let changed = false;
|
|
1275
|
-
const result = await Promise.all(
|
|
1276
|
-
obj.map(async item => {
|
|
1277
|
-
// Special handling: compress oversized images while preserving shape
|
|
1278
|
-
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
1279
|
-
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
1280
|
-
changed = true;
|
|
1281
|
-
const blobRef = await externalizeImageData(blobStore, item.data, item.mimeType);
|
|
1282
|
-
return { ...item, data: blobRef };
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
const newItem = await truncateForPersistence(item, blobStore, key);
|
|
1287
|
-
if (newItem !== item) changed = true;
|
|
1288
|
-
return newItem;
|
|
1289
|
-
}),
|
|
1290
|
-
);
|
|
1291
|
-
return changed ? result : obj;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (typeof obj === "object") {
|
|
1295
|
-
let changed = false;
|
|
1296
|
-
const entries: Array<readonly [string, unknown]> = await Promise.all(
|
|
1297
|
-
Object.entries(obj).flatMap(([childKey, value]) => {
|
|
1298
|
-
// Strip transient/redundant properties that shouldn't be persisted.
|
|
1299
|
-
// - partialJson: streaming accumulator for tool call JSON parsing
|
|
1300
|
-
// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
|
|
1301
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1302
|
-
changed = true;
|
|
1303
|
-
return [];
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
return [
|
|
1307
|
-
(async () => {
|
|
1308
|
-
const newValue = await truncateForPersistence(value, blobStore, childKey);
|
|
1309
|
-
if (newValue !== value) changed = true;
|
|
1310
|
-
return [childKey, newValue] as const;
|
|
1311
|
-
})(),
|
|
1312
|
-
];
|
|
1313
|
-
}),
|
|
1314
|
-
);
|
|
1315
|
-
|
|
1316
|
-
if (!changed) return obj;
|
|
1317
|
-
|
|
1318
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1319
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1320
|
-
if (
|
|
1321
|
-
contentEntry &&
|
|
1322
|
-
typeof contentEntry[1] === "string" &&
|
|
1323
|
-
lineCountEntry &&
|
|
1324
|
-
typeof lineCountEntry[1] === "number"
|
|
1325
|
-
) {
|
|
1326
|
-
const content = contentEntry[1];
|
|
1327
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1328
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1329
|
-
);
|
|
1330
|
-
return Object.fromEntries(updatedEntries);
|
|
1331
|
-
}
|
|
1332
|
-
return Object.fromEntries(entries);
|
|
1333
|
-
}
|
|
242
|
+
export type ReadonlySessionManager = Pick<
|
|
243
|
+
SessionManager,
|
|
244
|
+
| "getCwd"
|
|
245
|
+
| "getSessionDir"
|
|
246
|
+
| "getSessionId"
|
|
247
|
+
| "getSessionFile"
|
|
248
|
+
| "getSessionName"
|
|
249
|
+
| "getArtifactsDir"
|
|
250
|
+
| "getArtifactManager"
|
|
251
|
+
| "allocateArtifactPath"
|
|
252
|
+
| "saveArtifact"
|
|
253
|
+
| "getArtifactPath"
|
|
254
|
+
| "getLeafId"
|
|
255
|
+
| "getLeafEntry"
|
|
256
|
+
| "getEntry"
|
|
257
|
+
| "getLabel"
|
|
258
|
+
| "getBranch"
|
|
259
|
+
| "getHeader"
|
|
260
|
+
| "getEntries"
|
|
261
|
+
| "getTree"
|
|
262
|
+
| "getUsageStatistics"
|
|
263
|
+
| "putBlob"
|
|
264
|
+
| "putBlobSync"
|
|
265
|
+
>;
|
|
1334
266
|
|
|
1335
|
-
|
|
267
|
+
interface SessionManagerStateSnapshot {
|
|
268
|
+
cwd: string;
|
|
269
|
+
sessionDir: string;
|
|
270
|
+
sessionId: string;
|
|
271
|
+
sessionName: string | undefined;
|
|
272
|
+
titleSource: "auto" | "user" | undefined;
|
|
273
|
+
sessionFile: string | undefined;
|
|
274
|
+
onDisk: boolean;
|
|
275
|
+
needsRewrite: boolean;
|
|
276
|
+
header: SessionHeader;
|
|
277
|
+
entries: SessionEntry[];
|
|
1336
278
|
}
|
|
1337
279
|
|
|
1338
|
-
|
|
1339
|
-
|
|
280
|
+
interface DiskQueueOptions {
|
|
281
|
+
ignorePriorError?: boolean;
|
|
282
|
+
ignoreEpoch?: boolean;
|
|
283
|
+
epoch?: number;
|
|
1340
284
|
}
|
|
1341
285
|
|
|
1342
286
|
/**
|
|
1343
|
-
*
|
|
287
|
+
* Stores and navigates an append-only conversation journal.
|
|
1344
288
|
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
289
|
+
* A session is a JSONL file: one header line followed by entries. Entries form a
|
|
290
|
+
* tree by `(id, parentId)`, and the mutable leaf pointer selects which path is
|
|
291
|
+
* active for future appends and for LLM context construction.
|
|
292
|
+
*
|
|
293
|
+
* Durability is software-crash safe but not power-loss safe: appends are handed
|
|
294
|
+
* to the OS synchronously in-body (so an entry survives an OOM/SIGKILL the
|
|
295
|
+
* instant `appendMessage` returns) but never `fsync`'d. Full-file rewrites go
|
|
296
|
+
* through the storage layer's atomic temp-write+rename so a crash mid-rewrite
|
|
297
|
+
* cannot truncate the prior good file.
|
|
1352
298
|
*/
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
}
|
|
1360
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1361
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1362
|
-
return "";
|
|
1363
|
-
}
|
|
1364
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1365
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1366
|
-
}
|
|
1367
|
-
return obj;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (Array.isArray(obj)) {
|
|
1371
|
-
let changed = false;
|
|
1372
|
-
const result: unknown[] = new Array(obj.length);
|
|
1373
|
-
for (let i = 0; i < obj.length; i++) {
|
|
1374
|
-
const item = obj[i];
|
|
1375
|
-
if (
|
|
1376
|
-
key === TEXT_CONTENT_KEY &&
|
|
1377
|
-
isImageBlock(item) &&
|
|
1378
|
-
!isBlobRef(item.data) &&
|
|
1379
|
-
item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
|
|
1380
|
-
) {
|
|
1381
|
-
changed = true;
|
|
1382
|
-
result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
|
|
1383
|
-
continue;
|
|
1384
|
-
}
|
|
1385
|
-
const newItem = truncateForPersistenceSync(item, blobStore, key);
|
|
1386
|
-
if (newItem !== item) changed = true;
|
|
1387
|
-
result[i] = newItem;
|
|
1388
|
-
}
|
|
1389
|
-
return changed ? result : obj;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (typeof obj === "object") {
|
|
1393
|
-
let changed = false;
|
|
1394
|
-
const entries: Array<readonly [string, unknown]> = [];
|
|
1395
|
-
for (const [childKey, value] of Object.entries(obj)) {
|
|
1396
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1397
|
-
changed = true;
|
|
1398
|
-
continue;
|
|
1399
|
-
}
|
|
1400
|
-
const newValue = truncateForPersistenceSync(value, blobStore, childKey);
|
|
1401
|
-
if (newValue !== value) changed = true;
|
|
1402
|
-
entries.push([childKey, newValue]);
|
|
1403
|
-
}
|
|
1404
|
-
if (!changed) return obj;
|
|
1405
|
-
|
|
1406
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1407
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1408
|
-
if (
|
|
1409
|
-
contentEntry &&
|
|
1410
|
-
typeof contentEntry[1] === "string" &&
|
|
1411
|
-
lineCountEntry &&
|
|
1412
|
-
typeof lineCountEntry[1] === "number"
|
|
1413
|
-
) {
|
|
1414
|
-
const content = contentEntry[1];
|
|
1415
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1416
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1417
|
-
);
|
|
1418
|
-
return Object.fromEntries(updatedEntries);
|
|
1419
|
-
}
|
|
1420
|
-
return Object.fromEntries(entries);
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
return obj;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
1427
|
-
return truncateForPersistenceSync(entry, blobStore) as FileEntry;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
class NdjsonFileWriter {
|
|
1431
|
-
#writer: SessionStorageWriter;
|
|
1432
|
-
#closed = false;
|
|
1433
|
-
#closing = false;
|
|
1434
|
-
#error: Error | undefined;
|
|
1435
|
-
#pendingWrites: Promise<void> = Promise.resolve();
|
|
1436
|
-
#onError: ((err: Error) => void) | undefined;
|
|
1437
|
-
|
|
1438
|
-
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
|
|
1439
|
-
this.#onError = options?.onError;
|
|
1440
|
-
this.#writer = storage.openWriter(path, {
|
|
1441
|
-
flags: options?.flags ?? "a",
|
|
1442
|
-
onError: (err: Error) => this.#recordError(err),
|
|
1443
|
-
});
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
#recordError(err: unknown): Error {
|
|
1447
|
-
const writeErr = toError(err);
|
|
1448
|
-
if (!this.#error) this.#error = writeErr;
|
|
1449
|
-
this.#onError?.(writeErr);
|
|
1450
|
-
return writeErr;
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
#enqueue(task: () => Promise<void>): Promise<void> {
|
|
1454
|
-
const run = async () => {
|
|
1455
|
-
if (this.#error) throw this.#error;
|
|
1456
|
-
await task();
|
|
1457
|
-
};
|
|
1458
|
-
const next = this.#pendingWrites.then(run);
|
|
1459
|
-
void next.catch((err: unknown) => {
|
|
1460
|
-
if (!this.#error) this.#error = toError(err);
|
|
1461
|
-
});
|
|
1462
|
-
this.#pendingWrites = next;
|
|
1463
|
-
return next;
|
|
1464
|
-
}
|
|
299
|
+
export class SessionManager {
|
|
300
|
+
#cwd: string;
|
|
301
|
+
#sessionDir: string;
|
|
302
|
+
readonly #persist: boolean;
|
|
303
|
+
readonly #storage: SessionStorage;
|
|
304
|
+
readonly #blobs: BlobStore;
|
|
1465
305
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
}
|
|
306
|
+
#sessionId = "";
|
|
307
|
+
#sessionName: string | undefined;
|
|
308
|
+
#titleSource: "auto" | "user" | undefined;
|
|
309
|
+
#sessionFile: string | undefined;
|
|
310
|
+
#header!: SessionHeader;
|
|
311
|
+
#entries: SessionEntry[] = [];
|
|
312
|
+
#index = new SessionEntryIndex();
|
|
1474
313
|
|
|
1475
|
-
/**
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
}
|
|
314
|
+
/** File reflects all current entries; appends can go incrementally. */
|
|
315
|
+
#fileIsCurrent = false;
|
|
316
|
+
/** In-memory entries diverged from disk (load-migration/sanitize) → next persist must full-rewrite. */
|
|
317
|
+
#rewriteRequired = false;
|
|
318
|
+
/** Lazy gate crossed (ensureOnDisk / loaded file): every entry must persist from now on. */
|
|
319
|
+
#forceFileCreation = false;
|
|
1482
320
|
|
|
1483
321
|
/**
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
* Callers MUST NOT mix this with pending async `write()` calls on the same writer:
|
|
1488
|
-
* the async path is queued through `#pendingWrites`, but this method bypasses the
|
|
1489
|
-
* queue. Use only when no concurrent async write is in flight (the session-manager
|
|
1490
|
-
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
|
|
322
|
+
* Collab replication tap: invoked for every appended entry with the
|
|
323
|
+
* in-memory (pre-blob-externalization) entry, so inline images survive.
|
|
1491
324
|
*/
|
|
1492
|
-
|
|
1493
|
-
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1494
|
-
if (this.#error) throw this.#error;
|
|
1495
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
1496
|
-
try {
|
|
1497
|
-
this.#writer.writeLineSync(line);
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
throw this.#recordError(err);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
/** Flush all buffered data to disk. Waits for all queued writes. */
|
|
1504
|
-
async flush(): Promise<void> {
|
|
1505
|
-
if (this.#closed) return;
|
|
1506
|
-
if (this.#error) throw this.#error;
|
|
1507
|
-
|
|
1508
|
-
await this.#enqueue(async () => {});
|
|
1509
|
-
|
|
1510
|
-
if (this.#error) throw this.#error;
|
|
1511
|
-
|
|
1512
|
-
try {
|
|
1513
|
-
await this.#writer.flush();
|
|
1514
|
-
} catch (err) {
|
|
1515
|
-
throw this.#recordError(err);
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
/** Sync data to persistent storage. */
|
|
1520
|
-
async fsync(): Promise<void> {
|
|
1521
|
-
if (this.#closed) return;
|
|
1522
|
-
if (this.#error) throw this.#error;
|
|
1523
|
-
try {
|
|
1524
|
-
await this.#writer.fsync();
|
|
1525
|
-
} catch (err) {
|
|
1526
|
-
throw this.#recordError(err);
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
/** Synchronously fsync the underlying file descriptor to physical disk. */
|
|
1531
|
-
fsyncSync(): void {
|
|
1532
|
-
if (this.#closed) return;
|
|
1533
|
-
if (this.#error) throw this.#error;
|
|
1534
|
-
try {
|
|
1535
|
-
this.#writer.fsyncSync();
|
|
1536
|
-
} catch (err) {
|
|
1537
|
-
throw this.#recordError(err);
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
/** Close the writer, flushing all data. */
|
|
1542
|
-
async close(): Promise<void> {
|
|
1543
|
-
if (this.#closed || this.#closing) return;
|
|
1544
|
-
this.#closing = true;
|
|
1545
|
-
|
|
1546
|
-
let closeError: Error | undefined;
|
|
1547
|
-
try {
|
|
1548
|
-
await this.flush();
|
|
1549
|
-
} catch (err) {
|
|
1550
|
-
closeError = toError(err);
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
try {
|
|
1554
|
-
await this.#pendingWrites;
|
|
1555
|
-
} catch (err) {
|
|
1556
|
-
if (!closeError) closeError = toError(err);
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
try {
|
|
1560
|
-
await this.#writer.close();
|
|
1561
|
-
} catch (err) {
|
|
1562
|
-
const endErr = this.#recordError(err);
|
|
1563
|
-
if (!closeError) closeError = endErr;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
this.#closed = true;
|
|
1567
|
-
|
|
1568
|
-
if (!closeError && this.#error) closeError = this.#error;
|
|
1569
|
-
if (closeError) throw closeError;
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/** Check if there's a stored error. */
|
|
1573
|
-
getError(): Error | undefined {
|
|
1574
|
-
return this.#error;
|
|
1575
|
-
}
|
|
325
|
+
onEntryAppended?: (entry: SessionEntry) => void;
|
|
1576
326
|
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
}
|
|
327
|
+
#turnBudgetTotal: number | null = null;
|
|
328
|
+
#turnBudgetHard = false;
|
|
329
|
+
#turnOutputBaseline = 0;
|
|
330
|
+
#turnEvalOutput = 0;
|
|
1582
331
|
|
|
1583
|
-
/**
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
}
|
|
332
|
+
/** The single open append writer; the manager only ever writes one file at a time. */
|
|
333
|
+
#writer: SessionStorageWriter | undefined;
|
|
334
|
+
/** Serializes async disk work (flush/close/atomic rewrite). Appends are synchronous and bypass it. */
|
|
335
|
+
#diskTail: Promise<void> = Promise.resolve();
|
|
336
|
+
#diskFailure: Error | undefined;
|
|
337
|
+
#diskFailureLogged = false;
|
|
338
|
+
/** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
|
|
339
|
+
#diskEpoch = 0;
|
|
1592
340
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
1599
|
-
* modifying history.
|
|
1600
|
-
*
|
|
1601
|
-
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
1602
|
-
* handles compaction summaries and follows the path from root to current leaf.
|
|
1603
|
-
*/
|
|
1604
|
-
export interface UsageStatistics {
|
|
1605
|
-
input: number;
|
|
1606
|
-
output: number;
|
|
1607
|
-
cacheRead: number;
|
|
1608
|
-
cacheWrite: number;
|
|
1609
|
-
premiumRequests: number;
|
|
1610
|
-
cost: number;
|
|
1611
|
-
}
|
|
341
|
+
#artifactManager: ArtifactManager | null = null;
|
|
342
|
+
#artifactManagerSessionFile: string | null = null;
|
|
343
|
+
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
344
|
+
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
345
|
+
#inMemoryArtifactCounter = 0;
|
|
1612
346
|
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
const record = details as Record<string, unknown>;
|
|
1616
|
-
const usage = record.usage;
|
|
1617
|
-
if (!usage || typeof usage !== "object") return undefined;
|
|
1618
|
-
return usage as Usage;
|
|
1619
|
-
}
|
|
347
|
+
#suppressBreadcrumb = false;
|
|
348
|
+
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
1620
349
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
}
|
|
350
|
+
private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
|
|
351
|
+
this.#cwd = cwd;
|
|
352
|
+
this.#sessionDir = sessionDir;
|
|
353
|
+
this.#persist = persist;
|
|
354
|
+
this.#storage = storage;
|
|
355
|
+
this.#blobs = new BlobStore(getBlobsDir());
|
|
1628
356
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
* Tail window read to derive {@link SessionStatus}. Large enough to capture a
|
|
1632
|
-
* typical final assistant turn (thinking + text); when the final message exceeds
|
|
1633
|
-
* it the status falls back to `unknown` rather than misreporting.
|
|
1634
|
-
*/
|
|
1635
|
-
const SESSION_LIST_SUFFIX_BYTES = 32_768;
|
|
1636
|
-
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
1637
|
-
const SESSION_LIST_MAX_WORKERS = 16;
|
|
357
|
+
if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
|
|
358
|
+
}
|
|
1638
359
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
* newline-terminated on write, so within the window only the first line can be a
|
|
1642
|
-
* partial fragment — it simply fails to parse and is skipped. We walk backwards to
|
|
1643
|
-
* the last `message` entry and classify by its role / stop reason.
|
|
1644
|
-
*/
|
|
1645
|
-
function deriveSessionStatus(suffix: string): SessionStatus {
|
|
1646
|
-
if (!suffix) return "unknown";
|
|
1647
|
-
const lines = suffix.split("\n");
|
|
1648
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1649
|
-
const line = lines[i];
|
|
1650
|
-
// Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
|
|
1651
|
-
// cheaply rejects blank lines and the leading partial fragment without
|
|
1652
|
-
// attempting to parse a multi-KB tail of a truncated line.
|
|
1653
|
-
if (line.charCodeAt(0) !== 123) continue;
|
|
1654
|
-
let entry: { type?: string; message?: TailMessage };
|
|
1655
|
-
try {
|
|
1656
|
-
entry = JSON.parse(line);
|
|
1657
|
-
} catch {
|
|
1658
|
-
continue;
|
|
1659
|
-
}
|
|
1660
|
-
if (entry.type === "message" && entry.message) {
|
|
1661
|
-
return statusFromTailMessage(entry.message);
|
|
1662
|
-
}
|
|
360
|
+
#rememberBreadcrumb(cwd: string, sessionFile: string): void {
|
|
361
|
+
if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile);
|
|
1663
362
|
}
|
|
1664
|
-
return "unknown";
|
|
1665
|
-
}
|
|
1666
363
|
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
364
|
+
#clearDiskError(): void {
|
|
365
|
+
this.#diskFailure = undefined;
|
|
366
|
+
this.#diskFailureLogged = false;
|
|
367
|
+
}
|
|
1672
368
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
369
|
+
#noteDiskFailure(errorLike: unknown): Error {
|
|
370
|
+
const error = toError(errorLike);
|
|
371
|
+
if (!this.#diskFailure) this.#diskFailure = error;
|
|
1676
372
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
return "aborted";
|
|
1685
|
-
case "length":
|
|
1686
|
-
return "interrupted";
|
|
1687
|
-
}
|
|
1688
|
-
// A turn that ends without unanswered tool calls means the agent yielded
|
|
1689
|
-
// control back to the user — complete. Trailing tool calls (no tool
|
|
1690
|
-
// results after) mean the loop was cut off before running them.
|
|
1691
|
-
const content = message.content;
|
|
1692
|
-
if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
|
|
1693
|
-
return "complete";
|
|
373
|
+
if (!this.#diskFailureLogged) {
|
|
374
|
+
this.#diskFailureLogged = true;
|
|
375
|
+
logger.error("Session persistence error.", {
|
|
376
|
+
sessionFile: this.#sessionFile,
|
|
377
|
+
error: error.message,
|
|
378
|
+
stack: error.stack,
|
|
379
|
+
});
|
|
1694
380
|
}
|
|
1695
|
-
case "toolResult":
|
|
1696
|
-
// Tools ran but the agent never produced the following assistant turn.
|
|
1697
|
-
return "interrupted";
|
|
1698
|
-
case "user":
|
|
1699
|
-
// User message with no assistant reply persisted after it.
|
|
1700
|
-
return "pending";
|
|
1701
|
-
default:
|
|
1702
|
-
return "unknown";
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
381
|
|
|
1706
|
-
|
|
1707
|
-
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
1708
|
-
try {
|
|
1709
|
-
return JSON.parse(`"${safeValue}"`) as string;
|
|
1710
|
-
} catch {
|
|
1711
|
-
return safeValue
|
|
1712
|
-
.replace(/\\n/g, "\n")
|
|
1713
|
-
.replace(/\\r/g, "\r")
|
|
1714
|
-
.replace(/\\t/g, "\t")
|
|
1715
|
-
.replace(/\\"/g, '"')
|
|
1716
|
-
.replace(/\\\\/g, "\\");
|
|
382
|
+
return this.#diskFailure;
|
|
1717
383
|
}
|
|
1718
|
-
}
|
|
1719
384
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
385
|
+
#scheduleDiskWork(work: () => Promise<void>, options: DiskQueueOptions = {}): Promise<void> {
|
|
386
|
+
const epoch = options.epoch ?? this.#diskEpoch;
|
|
387
|
+
const scheduled = this.#diskTail
|
|
388
|
+
.catch(() => undefined)
|
|
389
|
+
.then(async () => {
|
|
390
|
+
if (!options.ignoreEpoch && epoch !== this.#diskEpoch) return;
|
|
391
|
+
if (this.#diskFailure && !options.ignorePriorError) throw this.#diskFailure;
|
|
392
|
+
await work();
|
|
393
|
+
});
|
|
1726
394
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
395
|
+
const reported = scheduled.catch(err => {
|
|
396
|
+
throw this.#noteDiskFailure(err);
|
|
397
|
+
});
|
|
398
|
+
this.#diskTail = reported.catch(() => undefined);
|
|
399
|
+
return reported;
|
|
1732
400
|
}
|
|
1733
|
-
if (source.charCodeAt(valueIndex) !== 34) return undefined;
|
|
1734
401
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
}
|
|
1747
|
-
if (char === 34) {
|
|
1748
|
-
return decodeJsonStringFragment(source.slice(valueStart, i));
|
|
402
|
+
async #drainAndCloseWriter(): Promise<void> {
|
|
403
|
+
try {
|
|
404
|
+
await this.#scheduleDiskWork(
|
|
405
|
+
async () => {
|
|
406
|
+
await this.#closeWriterHandle();
|
|
407
|
+
},
|
|
408
|
+
{ ignorePriorError: true, ignoreEpoch: true },
|
|
409
|
+
);
|
|
410
|
+
} finally {
|
|
411
|
+
this.#writer = undefined;
|
|
412
|
+
this.#diskTail = Promise.resolve();
|
|
1749
413
|
}
|
|
1750
414
|
}
|
|
1751
415
|
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
let index = 0;
|
|
1758
|
-
while (index < content.length) {
|
|
1759
|
-
const typeIndex = content.indexOf('"type"', index);
|
|
1760
|
-
if (typeIndex === -1) break;
|
|
1761
|
-
const colonIndex = content.indexOf(":", typeIndex + 6);
|
|
1762
|
-
if (colonIndex === -1) break;
|
|
1763
|
-
const type = extractStringProperty(content, "type", typeIndex);
|
|
1764
|
-
if (type === "message") count++;
|
|
1765
|
-
index = colonIndex + 1;
|
|
1766
|
-
}
|
|
1767
|
-
return count;
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
function extractFirstUserMessageFromPrefix(content: string): string | undefined {
|
|
1771
|
-
const roleIndex = content.indexOf('"role"');
|
|
1772
|
-
if (roleIndex === -1) return undefined;
|
|
416
|
+
#closeWriterEventually(): void {
|
|
417
|
+
const writer = this.#writer;
|
|
418
|
+
this.#writer = undefined;
|
|
419
|
+
if (writer) void writer.close().catch(() => undefined);
|
|
420
|
+
}
|
|
1773
421
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
}
|
|
1780
|
-
index = content.indexOf('"role"', index + 6);
|
|
422
|
+
async #closeWriterHandle(): Promise<void> {
|
|
423
|
+
const writer = this.#writer;
|
|
424
|
+
if (!writer) return;
|
|
425
|
+
this.#writer = undefined;
|
|
426
|
+
await writer.close();
|
|
1781
427
|
}
|
|
1782
428
|
|
|
1783
|
-
|
|
1784
|
-
|
|
429
|
+
#appendWriter(): SessionStorageWriter {
|
|
430
|
+
if (!this.#sessionFile) throw new Error("Cannot open a session writer before a session file exists");
|
|
1785
431
|
|
|
1786
|
-
|
|
1787
|
-
type: "session";
|
|
1788
|
-
id: string;
|
|
1789
|
-
cwd?: string;
|
|
1790
|
-
title?: string;
|
|
1791
|
-
parentSession?: string;
|
|
1792
|
-
timestamp?: string;
|
|
1793
|
-
}
|
|
432
|
+
if (this.#writer?.isOpen()) return this.#writer;
|
|
1794
433
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
)
|
|
1799
|
-
|
|
1800
|
-
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
1801
|
-
return {
|
|
1802
|
-
type: "session",
|
|
1803
|
-
id: parsedHeader.id,
|
|
1804
|
-
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
1805
|
-
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
1806
|
-
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
1807
|
-
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
1808
|
-
};
|
|
434
|
+
this.#writer = this.#storage.openWriter(this.#sessionFile, {
|
|
435
|
+
flags: "a",
|
|
436
|
+
onError: err => this.#noteDiskFailure(err),
|
|
437
|
+
});
|
|
438
|
+
return this.#writer;
|
|
1809
439
|
}
|
|
1810
440
|
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
const id = extractStringProperty(firstLine, "id");
|
|
1816
|
-
if (!id) return undefined;
|
|
441
|
+
#lineFor(entry: FileEntry): string {
|
|
442
|
+
return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
|
|
443
|
+
}
|
|
1817
444
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
1824
|
-
timestamp: extractStringProperty(firstLine, "timestamp"),
|
|
1825
|
-
};
|
|
1826
|
-
}
|
|
445
|
+
#fileBody(): string {
|
|
446
|
+
let body = this.#lineFor(this.#header);
|
|
447
|
+
for (const entry of this.#entries) body += this.#lineFor(entry);
|
|
448
|
+
return body;
|
|
449
|
+
}
|
|
1827
450
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
SESSION_LIST_MAX_WORKERS,
|
|
1832
|
-
os.availableParallelism(),
|
|
1833
|
-
Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
|
|
1834
|
-
);
|
|
1835
|
-
}
|
|
451
|
+
#historyContainsAssistantMessage(): boolean {
|
|
452
|
+
return this.#entries.some(isAssistantEntry);
|
|
453
|
+
}
|
|
1836
454
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
const [content, suffix] = await storage.readTextSlices(
|
|
1841
|
-
file,
|
|
1842
|
-
SESSION_LIST_PREFIX_BYTES,
|
|
1843
|
-
SESSION_LIST_SUFFIX_BYTES,
|
|
1844
|
-
);
|
|
1845
|
-
const { size, mtime } = stat;
|
|
1846
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1847
|
-
const header = parseSessionListHeader(content, entries);
|
|
1848
|
-
if (!header) return undefined;
|
|
455
|
+
#shouldHaveSessionFile(): boolean {
|
|
456
|
+
return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
|
|
457
|
+
}
|
|
1849
458
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
459
|
+
/**
|
|
460
|
+
* Synchronously rewrite the whole file (header + entries) and keep no open
|
|
461
|
+
* writer; the next append re-opens one. `writeTextSync` returns with the
|
|
462
|
+
* bytes in the kernel page cache, so the file is software-crash durable.
|
|
463
|
+
*/
|
|
464
|
+
#rewriteSynchronously(): void {
|
|
465
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
1854
466
|
|
|
1855
|
-
|
|
1856
|
-
const
|
|
467
|
+
try {
|
|
468
|
+
const body = this.#fileBody();
|
|
469
|
+
this.#diskEpoch++;
|
|
470
|
+
this.#diskTail = Promise.resolve();
|
|
471
|
+
this.#closeWriterEventually();
|
|
472
|
+
this.#storage.writeTextSync(this.#sessionFile, body);
|
|
473
|
+
this.#fileIsCurrent = true;
|
|
474
|
+
this.#rewriteRequired = false;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.#noteDiskFailure(err);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
1857
479
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Rewrite the whole file atomically (temp-write + rename, EPERM-safe) on the
|
|
482
|
+
* disk chain. The body is serialized inside the task — after the writer is
|
|
483
|
+
* closed — so entries appended before the task runs are included.
|
|
484
|
+
*/
|
|
485
|
+
async #rewriteAtomically(): Promise<void> {
|
|
486
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
1861
487
|
|
|
1862
|
-
|
|
1863
|
-
|
|
488
|
+
const epoch = this.#diskEpoch;
|
|
489
|
+
await this.#scheduleDiskWork(
|
|
490
|
+
async () => {
|
|
491
|
+
await this.#closeWriterHandle();
|
|
492
|
+
const sessionFile = this.#sessionFile;
|
|
493
|
+
if (!sessionFile) return;
|
|
494
|
+
await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
|
|
495
|
+
this.#fileIsCurrent = true;
|
|
496
|
+
this.#rewriteRequired = false;
|
|
497
|
+
},
|
|
498
|
+
{ epoch },
|
|
499
|
+
);
|
|
500
|
+
}
|
|
1864
501
|
|
|
1865
|
-
|
|
1866
|
-
|
|
502
|
+
#appendToSessionFile(entry: SessionEntry): void {
|
|
503
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
504
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
1867
505
|
|
|
1868
|
-
|
|
1869
|
-
|
|
506
|
+
// Lazy gate: a brand-new session is not written until it has an assistant
|
|
507
|
+
// message (or someone forced creation), so sessions that never produce
|
|
508
|
+
// output never create a file.
|
|
509
|
+
if (!this.#shouldHaveSessionFile()) {
|
|
510
|
+
this.#fileIsCurrent = false;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
1870
513
|
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
}
|
|
514
|
+
// Cold/divergent: not on disk yet, or in-memory entries diverged from the
|
|
515
|
+
// file → rewrite the whole file synchronously and keep going.
|
|
516
|
+
if (!this.#fileIsCurrent || this.#rewriteRequired) {
|
|
517
|
+
this.#rewriteSynchronously();
|
|
518
|
+
return;
|
|
1877
519
|
}
|
|
1878
520
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
firstMessage: firstMessage || "(no messages)",
|
|
1892
|
-
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
1893
|
-
status: deriveSessionStatus(suffix),
|
|
1894
|
-
};
|
|
1895
|
-
} catch {
|
|
1896
|
-
return undefined;
|
|
521
|
+
// Hot path: append synchronously so the entry is durable the instant this
|
|
522
|
+
// returns (file/memory writers perform the write in-body). Never routed
|
|
523
|
+
// through the async disk chain — durability must hold without a flush().
|
|
524
|
+
// A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
|
|
525
|
+
// opens a fresh append handle and the entry still lands.
|
|
526
|
+
try {
|
|
527
|
+
void this.#appendWriter()
|
|
528
|
+
.append(this.#lineFor(entry))
|
|
529
|
+
.catch(err => this.#noteDiskFailure(err));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this.#noteDiskFailure(err);
|
|
532
|
+
}
|
|
1897
533
|
}
|
|
1898
|
-
}
|
|
1899
534
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
const sessions: SessionInfo[] = [];
|
|
1907
|
-
|
|
1908
|
-
for (let i = startIndex; i < files.length; i += stride) {
|
|
1909
|
-
const session = await collectSessionFromFile(files[i], storage);
|
|
1910
|
-
if (session) sessions.push(session);
|
|
1911
|
-
}
|
|
535
|
+
#resetToNewSession(options?: NewSessionOptions, forcedSessionFile?: string): string | undefined {
|
|
536
|
+
this.#diskTail = Promise.resolve();
|
|
537
|
+
this.#clearDiskError();
|
|
538
|
+
this.#sessionId = mintSessionId();
|
|
539
|
+
this.#sessionName = undefined;
|
|
540
|
+
this.#titleSource = undefined;
|
|
1912
541
|
|
|
1913
|
-
|
|
1914
|
-
|
|
542
|
+
const timestamp = nowIso();
|
|
543
|
+
this.#header = {
|
|
544
|
+
type: "session",
|
|
545
|
+
version: CURRENT_SESSION_VERSION,
|
|
546
|
+
id: this.#sessionId,
|
|
547
|
+
timestamp,
|
|
548
|
+
cwd: this.#cwd,
|
|
549
|
+
parentSession: options?.parentSession,
|
|
550
|
+
};
|
|
1915
551
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
return sessions;
|
|
1931
|
-
}
|
|
552
|
+
this.#entries = [];
|
|
553
|
+
this.#index.clear();
|
|
554
|
+
this.#fileIsCurrent = false;
|
|
555
|
+
this.#rewriteRequired = false;
|
|
556
|
+
this.#forceFileCreation = false;
|
|
557
|
+
this.#turnBudgetTotal = null;
|
|
558
|
+
this.#turnBudgetHard = false;
|
|
559
|
+
this.#turnOutputBaseline = 0;
|
|
560
|
+
this.#turnEvalOutput = 0;
|
|
561
|
+
this.#artifactManager = null;
|
|
562
|
+
this.#artifactManagerSessionFile = null;
|
|
563
|
+
this.#adoptedArtifactManager = null;
|
|
564
|
+
this.#inMemoryArtifacts = null;
|
|
565
|
+
this.#inMemoryArtifactCounter = 0;
|
|
1932
566
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
}
|
|
567
|
+
if (this.#persist) {
|
|
568
|
+
this.#sessionFile =
|
|
569
|
+
forcedSessionFile ??
|
|
570
|
+
path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
571
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
572
|
+
} else {
|
|
573
|
+
this.#sessionFile = undefined;
|
|
574
|
+
}
|
|
1937
575
|
|
|
1938
|
-
|
|
1939
|
-
const normalizedArg = sessionArg.toLowerCase();
|
|
1940
|
-
const normalizedId = session.id.toLowerCase();
|
|
1941
|
-
if (normalizedId.startsWith(normalizedArg)) {
|
|
1942
|
-
return true;
|
|
576
|
+
return this.#sessionFile;
|
|
1943
577
|
}
|
|
1944
578
|
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
579
|
+
#applyEntries(header: SessionHeader, entries: SessionEntry[]): void {
|
|
580
|
+
this.#header = header;
|
|
581
|
+
this.#entries = entries;
|
|
582
|
+
this.#sessionId = header.id;
|
|
583
|
+
this.#sessionName = header.title;
|
|
584
|
+
this.#titleSource = header.titleSource;
|
|
585
|
+
this.#index.rebuild(entries);
|
|
1948
586
|
}
|
|
1949
587
|
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
588
|
+
#freshEntryFields(): { id: string; parentId: string | null; timestamp: string } {
|
|
589
|
+
return {
|
|
590
|
+
id: generateId(this.#index),
|
|
591
|
+
parentId: this.#index.leafId(),
|
|
592
|
+
timestamp: nowIso(),
|
|
593
|
+
};
|
|
1953
594
|
}
|
|
1954
595
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
596
|
+
#recordEntry(entry: SessionEntry): void {
|
|
597
|
+
this.#entries.push(entry);
|
|
598
|
+
this.#index.insert(entry);
|
|
599
|
+
this.#appendToSessionFile(entry);
|
|
1958
600
|
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
1968
|
-
if (localMatch) {
|
|
1969
|
-
return { session: localMatch, scope: "local" };
|
|
601
|
+
const callback = this.onEntryAppended;
|
|
602
|
+
if (callback) {
|
|
603
|
+
try {
|
|
604
|
+
callback(entry);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logger.warn("collab entry hook failed", { error: String(err) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
1970
609
|
}
|
|
1971
610
|
|
|
1972
|
-
|
|
1973
|
-
|
|
611
|
+
#draftPath(): string | null {
|
|
612
|
+
const artifactsDir = this.getArtifactsDir();
|
|
613
|
+
return artifactsDir ? path.join(artifactsDir, "draft.txt") : null;
|
|
1974
614
|
}
|
|
1975
615
|
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
if (!globalMatch) {
|
|
1979
|
-
return undefined;
|
|
1980
|
-
}
|
|
616
|
+
#artifactManagerForSession(): ArtifactManager | null {
|
|
617
|
+
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
1981
618
|
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
sessionName: string | undefined;
|
|
1989
|
-
titleSource: "auto" | "user" | undefined;
|
|
1990
|
-
sessionFile: string | undefined;
|
|
1991
|
-
flushed: boolean;
|
|
1992
|
-
needsFullRewriteOnNextPersist: boolean;
|
|
1993
|
-
fileEntries: FileEntry[];
|
|
1994
|
-
}
|
|
619
|
+
const sessionFile = this.#sessionFile;
|
|
620
|
+
if (!sessionFile) {
|
|
621
|
+
this.#artifactManager = null;
|
|
622
|
+
this.#artifactManagerSessionFile = null;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
1995
625
|
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
#needsFullRewriteOnNextPersist: boolean = false;
|
|
2003
|
-
#ensuredOnDisk: boolean = false;
|
|
2004
|
-
#fileEntries: FileEntry[] = [];
|
|
2005
|
-
#byId: Map<string, SessionEntry> = new Map();
|
|
2006
|
-
#labelsById: Map<string, string> = new Map();
|
|
2007
|
-
#leafId: string | null = null;
|
|
2008
|
-
/**
|
|
2009
|
-
* Collab replication tap: invoked for every appended entry with the
|
|
2010
|
-
* in-memory (pre-blob-externalization) entry, so inline images survive.
|
|
2011
|
-
* Failures are swallowed — a broadcast error must never break persistence.
|
|
2012
|
-
*/
|
|
2013
|
-
onEntryAppended?: (entry: SessionEntry) => void;
|
|
2014
|
-
#usageStatistics = {
|
|
2015
|
-
input: 0,
|
|
2016
|
-
output: 0,
|
|
2017
|
-
cacheRead: 0,
|
|
2018
|
-
cacheWrite: 0,
|
|
2019
|
-
premiumRequests: 0,
|
|
2020
|
-
cost: 0,
|
|
2021
|
-
} satisfies UsageStatistics;
|
|
2022
|
-
/** Per-turn output-token budget set by a `+Nk` directive (total null when none this turn). */
|
|
2023
|
-
#turnBudget: { total: number | null; hard: boolean } = { total: null, hard: false };
|
|
2024
|
-
/** Cumulative `output` snapshot captured when the current turn budget window opened. */
|
|
2025
|
-
#turnBaselineOutput = 0;
|
|
2026
|
-
/** Output tokens consumed by eval-spawned subagents in the current turn window. */
|
|
2027
|
-
#turnEvalOutput = 0;
|
|
2028
|
-
#persistWriter: NdjsonFileWriter | undefined;
|
|
2029
|
-
#persistWriterPath: string | undefined;
|
|
2030
|
-
#persistChain: Promise<void> = Promise.resolve();
|
|
2031
|
-
#persistError: Error | undefined;
|
|
2032
|
-
#persistErrorReported = false;
|
|
2033
|
-
#artifactManager: ArtifactManager | null = null;
|
|
2034
|
-
#artifactManagerSessionFile: string | null = null;
|
|
2035
|
-
// When set, take precedence over the lazily-derived per-session manager.
|
|
2036
|
-
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
2037
|
-
// whole agent tree and all files land in the parent's artifacts dir.
|
|
2038
|
-
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
2039
|
-
// In-memory artifact fallback for non-persistent sessions (persist=false).
|
|
2040
|
-
// Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
|
|
2041
|
-
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
2042
|
-
#inMemoryArtifactCounter = 0;
|
|
2043
|
-
readonly #blobStore: BlobStore;
|
|
2044
|
-
#suppressBreadcrumb = false;
|
|
2045
|
-
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
626
|
+
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) return this.#artifactManager;
|
|
627
|
+
|
|
628
|
+
this.#artifactManager = new ArtifactManager(sessionFile.slice(0, -JSONL_SUFFIX_LENGTH));
|
|
629
|
+
this.#artifactManagerSessionFile = sessionFile;
|
|
630
|
+
return this.#artifactManager;
|
|
631
|
+
}
|
|
2046
632
|
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
if (persist && sessionDir) {
|
|
2055
|
-
this.storage.ensureDirSync(sessionDir);
|
|
633
|
+
#notifySessionNameListeners(): void {
|
|
634
|
+
for (const callback of [...this.#sessionNameChangedCallbacks]) {
|
|
635
|
+
try {
|
|
636
|
+
callback();
|
|
637
|
+
} catch (err) {
|
|
638
|
+
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
639
|
+
}
|
|
2056
640
|
}
|
|
2057
|
-
// Note: call _initSession() or _initSessionFile() after construction
|
|
2058
641
|
}
|
|
2059
642
|
|
|
2060
|
-
#
|
|
2061
|
-
|
|
2062
|
-
|
|
643
|
+
static #cleanTitle(raw: string): string {
|
|
644
|
+
return raw
|
|
645
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
646
|
+
.replace(/ +/g, " ")
|
|
647
|
+
.trim();
|
|
2063
648
|
}
|
|
2064
649
|
|
|
2065
|
-
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
650
|
+
/** Puts a binary blob into the blob store and returns the blob reference. */
|
|
2066
651
|
async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
|
|
2067
|
-
return this.#
|
|
652
|
+
return this.#blobs.put(data, options);
|
|
2068
653
|
}
|
|
2069
654
|
|
|
2070
655
|
/** Synchronous variant of {@link putBlob} for rebuild-only render paths. */
|
|
2071
656
|
putBlobSync(data: Buffer, options?: BlobPutOptions): BlobPutResult {
|
|
2072
|
-
return this.#
|
|
657
|
+
return this.#blobs.putSync(data, options);
|
|
2073
658
|
}
|
|
2074
659
|
|
|
2075
660
|
captureState(): SessionManagerStateSnapshot {
|
|
2076
661
|
return {
|
|
2077
|
-
cwd: this
|
|
2078
|
-
sessionDir: this
|
|
662
|
+
cwd: this.#cwd,
|
|
663
|
+
sessionDir: this.#sessionDir,
|
|
2079
664
|
sessionId: this.#sessionId,
|
|
2080
665
|
sessionName: this.#sessionName,
|
|
2081
666
|
titleSource: this.#titleSource,
|
|
2082
667
|
sessionFile: this.#sessionFile,
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
// Snapshot
|
|
2086
|
-
//
|
|
2087
|
-
|
|
668
|
+
onDisk: this.#fileIsCurrent,
|
|
669
|
+
needsRewrite: this.#rewriteRequired,
|
|
670
|
+
// Snapshot header + entries by reference: switch/reload replaces the
|
|
671
|
+
// active header/array wholesale, so rollback needs no deep clone.
|
|
672
|
+
header: this.#header,
|
|
673
|
+
entries: [...this.#entries],
|
|
2088
674
|
};
|
|
2089
675
|
}
|
|
2090
676
|
|
|
2091
677
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
2092
|
-
this
|
|
2093
|
-
this
|
|
2094
|
-
this.#
|
|
678
|
+
this.#closeWriterEventually();
|
|
679
|
+
this.#diskTail = Promise.resolve();
|
|
680
|
+
this.#clearDiskError();
|
|
681
|
+
|
|
682
|
+
this.#cwd = snapshot.cwd;
|
|
683
|
+
this.#sessionDir = snapshot.sessionDir;
|
|
684
|
+
this.#sessionFile = snapshot.sessionFile;
|
|
685
|
+
this.#fileIsCurrent = snapshot.onDisk;
|
|
686
|
+
this.#rewriteRequired = snapshot.needsRewrite;
|
|
687
|
+
this.#forceFileCreation = snapshot.onDisk;
|
|
688
|
+
this.#applyEntries(snapshot.header, [...snapshot.entries]);
|
|
2095
689
|
this.#sessionName = snapshot.sessionName;
|
|
2096
690
|
this.#titleSource = snapshot.titleSource;
|
|
2097
|
-
this.#sessionFile = snapshot.sessionFile;
|
|
2098
|
-
this.#flushed = snapshot.flushed;
|
|
2099
|
-
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
2100
|
-
this.#fileEntries = [...snapshot.fileEntries];
|
|
2101
|
-
this.#persistWriter = undefined;
|
|
2102
|
-
this.#persistWriterPath = undefined;
|
|
2103
|
-
this.#persistChain = Promise.resolve();
|
|
2104
|
-
this.#persistError = undefined;
|
|
2105
|
-
this.#persistErrorReported = false;
|
|
2106
691
|
this.#artifactManager = null;
|
|
2107
692
|
this.#artifactManagerSessionFile = null;
|
|
2108
693
|
this.#adoptedArtifactManager = null;
|
|
2109
|
-
this.#buildIndex();
|
|
2110
|
-
if (this.#sessionFile) {
|
|
2111
|
-
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
/** Initialize with a specific session file (used by factory methods) */
|
|
2116
|
-
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
2117
|
-
await this.setSessionFile(sessionFile);
|
|
2118
|
-
}
|
|
2119
694
|
|
|
2120
|
-
|
|
2121
|
-
#initNewSession(): void {
|
|
2122
|
-
this.#newSessionSync();
|
|
695
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
2123
696
|
}
|
|
2124
697
|
|
|
2125
|
-
/** Switch to a different session file (
|
|
698
|
+
/** Switch to a different session file (resume / branch). */
|
|
2126
699
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
2127
|
-
await this.#
|
|
2128
|
-
this.#
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
this.#
|
|
2132
|
-
this.#
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
|
|
2145
|
-
if (headerCwd && headerCwd !== this.cwd) {
|
|
2146
|
-
this.cwd = headerCwd;
|
|
2147
|
-
this.sessionDir = path.resolve(this.#sessionFile, "..");
|
|
2148
|
-
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
700
|
+
await this.#drainAndCloseWriter();
|
|
701
|
+
this.#clearDiskError();
|
|
702
|
+
|
|
703
|
+
const resolvedSessionFile = path.resolve(sessionFile);
|
|
704
|
+
this.#sessionFile = resolvedSessionFile;
|
|
705
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
706
|
+
|
|
707
|
+
const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
|
|
708
|
+
if (fileEntries.length === 0) {
|
|
709
|
+
// Explicit but empty/missing path (e.g. --session flag): start fresh but
|
|
710
|
+
// keep the requested path and materialize the header immediately.
|
|
711
|
+
this.#resetToNewSession(undefined, resolvedSessionFile);
|
|
712
|
+
this.#forceFileCreation = true;
|
|
713
|
+
await this.#rewriteAtomically();
|
|
714
|
+
this.#fileIsCurrent = true;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
2152
717
|
|
|
2153
|
-
|
|
2154
|
-
|
|
718
|
+
const migrated = migrateToCurrentVersion(fileEntries);
|
|
719
|
+
await resolveBlobRefsInEntries(fileEntries, this.#blobs);
|
|
720
|
+
// loadEntriesFromFile guarantees entries[0] is a valid session header.
|
|
721
|
+
const header = fileEntries[0] as SessionHeader;
|
|
2155
722
|
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
this.#
|
|
2162
|
-
this.#
|
|
2163
|
-
|
|
2164
|
-
this.#flushed = true;
|
|
2165
|
-
this.#ensuredOnDisk = true;
|
|
2166
|
-
return;
|
|
723
|
+
// Adopt the loaded session's working directory. Sessions live in a dir
|
|
724
|
+
// keyed by their cwd, so resuming a session from another project must
|
|
725
|
+
// re-point cwd/sessionDir at that project.
|
|
726
|
+
const headerCwd = header.cwd ? path.resolve(header.cwd) : undefined;
|
|
727
|
+
if (headerCwd && headerCwd !== path.resolve(this.#cwd)) {
|
|
728
|
+
this.#cwd = headerCwd;
|
|
729
|
+
this.#sessionDir = path.dirname(resolvedSessionFile);
|
|
730
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
2167
731
|
}
|
|
732
|
+
|
|
733
|
+
this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
|
|
734
|
+
this.#fileIsCurrent = true;
|
|
735
|
+
this.#rewriteRequired = migrated;
|
|
736
|
+
this.#forceFileCreation = true;
|
|
737
|
+
this.#artifactManager = null;
|
|
738
|
+
this.#artifactManagerSessionFile = null;
|
|
739
|
+
|
|
740
|
+
if (this.sanitizeLoadedOpenAIResponsesReplayMetadata()) this.#rewriteRequired = true;
|
|
2168
741
|
}
|
|
2169
742
|
|
|
2170
|
-
/** Start a new session.
|
|
743
|
+
/** Start a new session. Drains and closes any existing writer first. */
|
|
2171
744
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
2172
|
-
await this.#
|
|
2173
|
-
return this.#
|
|
745
|
+
await this.#drainAndCloseWriter();
|
|
746
|
+
return this.#resetToNewSession(options);
|
|
2174
747
|
}
|
|
2175
748
|
|
|
2176
|
-
/** Delete a session file and its
|
|
749
|
+
/** Delete a session file and its artifact directory. ENOENT is treated as success. */
|
|
2177
750
|
async dropSession(sessionPath: string): Promise<void> {
|
|
2178
|
-
await this.#
|
|
751
|
+
await this.#drainAndCloseWriter();
|
|
2179
752
|
try {
|
|
2180
|
-
await this
|
|
753
|
+
await this.#storage.deleteSessionWithArtifacts(sessionPath);
|
|
2181
754
|
} catch (err) {
|
|
2182
|
-
if (isEnoent(err))
|
|
2183
|
-
throw err;
|
|
755
|
+
if (!isEnoent(err)) throw err;
|
|
2184
756
|
}
|
|
2185
757
|
}
|
|
2186
758
|
|
|
2187
759
|
/**
|
|
2188
|
-
* Fork the current session
|
|
2189
|
-
*
|
|
2190
|
-
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
760
|
+
* Fork the current session into a new file with the same entries.
|
|
761
|
+
* @returns the old and new session file paths, or undefined when not persisting.
|
|
2191
762
|
*/
|
|
2192
763
|
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
2193
|
-
if (!this
|
|
2194
|
-
return undefined;
|
|
2195
|
-
}
|
|
764
|
+
if (!this.#persist || !this.#sessionFile) return undefined;
|
|
2196
765
|
|
|
2197
766
|
const oldSessionFile = this.#sessionFile;
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
this.#
|
|
2204
|
-
this.#
|
|
2205
|
-
|
|
2206
|
-
// Create new session ID and header
|
|
2207
|
-
this.#sessionId = createSessionId();
|
|
2208
|
-
const timestamp = new Date().toISOString();
|
|
2209
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2210
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
2211
|
-
|
|
2212
|
-
// Update the header with new ID but keep all entries
|
|
2213
|
-
const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2214
|
-
const newHeader: SessionHeader = {
|
|
767
|
+
const parentSessionId = this.#sessionId;
|
|
768
|
+
await this.#drainAndCloseWriter();
|
|
769
|
+
this.#clearDiskError();
|
|
770
|
+
|
|
771
|
+
const timestamp = nowIso();
|
|
772
|
+
this.#sessionId = mintSessionId();
|
|
773
|
+
this.#sessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
774
|
+
this.#header = {
|
|
2215
775
|
type: "session",
|
|
2216
776
|
version: CURRENT_SESSION_VERSION,
|
|
2217
777
|
id: this.#sessionId,
|
|
2218
|
-
title:
|
|
2219
|
-
titleSource:
|
|
778
|
+
title: this.#header.title ?? this.#sessionName,
|
|
779
|
+
titleSource: this.#header.titleSource ?? this.#titleSource,
|
|
2220
780
|
timestamp,
|
|
2221
|
-
cwd: this
|
|
2222
|
-
parentSession:
|
|
781
|
+
cwd: this.#cwd,
|
|
782
|
+
parentSession: parentSessionId,
|
|
2223
783
|
};
|
|
2224
|
-
this.#sessionName =
|
|
2225
|
-
this.#titleSource =
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
this.#
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
this.#flushed = false;
|
|
2233
|
-
await this.#rewriteFile();
|
|
784
|
+
this.#sessionName = this.#header.title;
|
|
785
|
+
this.#titleSource = this.#header.titleSource;
|
|
786
|
+
this.#fileIsCurrent = false;
|
|
787
|
+
this.#rewriteRequired = false;
|
|
788
|
+
this.#forceFileCreation = true;
|
|
789
|
+
this.#artifactManager = null;
|
|
790
|
+
this.#artifactManagerSessionFile = null;
|
|
791
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
2234
792
|
|
|
793
|
+
await this.#rewriteAtomically();
|
|
2235
794
|
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
2236
795
|
}
|
|
2237
796
|
|
|
2238
797
|
/**
|
|
2239
|
-
* Move the session to a new working directory
|
|
2240
|
-
*
|
|
2241
|
-
* and rewrites the session header with the new cwd. When provided,
|
|
2242
|
-
* `targetSessionDir` is used instead of deriving the default directory for
|
|
2243
|
-
* the new cwd (for `--continue --session-dir` / `--resume --session-dir`).
|
|
798
|
+
* Move the session to a new working directory: relocate the session file and
|
|
799
|
+
* artifacts on disk, update internal references, and rewrite the header cwd.
|
|
2244
800
|
*/
|
|
2245
801
|
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
|
2246
802
|
const resolvedCwd = path.resolve(newCwd);
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
803
|
+
const resolvedTargetDir = targetSessionDir ? path.resolve(targetSessionDir) : undefined;
|
|
804
|
+
if (
|
|
805
|
+
resolvedCwd === path.resolve(this.#cwd) &&
|
|
806
|
+
(!resolvedTargetDir || resolvedTargetDir === path.resolve(this.#sessionDir))
|
|
807
|
+
) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const managedRoot = resolveManagedSessionRoot(this.#sessionDir, this.#cwd);
|
|
812
|
+
const nextSessionDir =
|
|
813
|
+
resolvedTargetDir ??
|
|
814
|
+
(managedRoot
|
|
815
|
+
? computeDefaultSessionDir(resolvedCwd, this.#storage, managedRoot)
|
|
816
|
+
: computeDefaultSessionDir(resolvedCwd, this.#storage));
|
|
817
|
+
|
|
818
|
+
let sessionFileExisted = false;
|
|
819
|
+
|
|
820
|
+
if (this.#persist && this.#sessionFile) {
|
|
821
|
+
this.#storage.ensureDirSync(nextSessionDir);
|
|
822
|
+
await this.#drainAndCloseWriter();
|
|
823
|
+
this.#clearDiskError();
|
|
2264
824
|
|
|
2265
825
|
const oldSessionFile = this.#sessionFile;
|
|
2266
|
-
const newSessionFile = path.join(
|
|
2267
|
-
const
|
|
2268
|
-
const
|
|
2269
|
-
const
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
let
|
|
826
|
+
const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
|
|
827
|
+
const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
|
|
828
|
+
const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
|
|
829
|
+
const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
|
|
830
|
+
const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
|
|
831
|
+
sessionFileExisted = this.#storage.existsSync(oldSessionFile);
|
|
832
|
+
|
|
833
|
+
let sessionMoved = false;
|
|
834
|
+
let artifactsMoved = false;
|
|
2274
835
|
|
|
2275
836
|
try {
|
|
2276
|
-
|
|
2277
|
-
if (hadSessionFile && !sameSessionFile) {
|
|
837
|
+
if (sessionFileExisted && sessionPathChanged) {
|
|
2278
838
|
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
2279
|
-
|
|
839
|
+
sessionMoved = true;
|
|
2280
840
|
}
|
|
2281
841
|
|
|
2282
|
-
if (
|
|
842
|
+
if (artifactPathChanged) {
|
|
2283
843
|
try {
|
|
2284
|
-
const
|
|
2285
|
-
if (
|
|
2286
|
-
await fs.promises.rename(
|
|
2287
|
-
|
|
844
|
+
const artifactStat = await fs.promises.stat(oldArtifactsDir);
|
|
845
|
+
if (artifactStat.isDirectory()) {
|
|
846
|
+
await fs.promises.rename(oldArtifactsDir, newArtifactsDir);
|
|
847
|
+
artifactsMoved = true;
|
|
2288
848
|
}
|
|
2289
849
|
} catch (err) {
|
|
2290
850
|
if (!isEnoent(err)) throw err;
|
|
2291
851
|
}
|
|
2292
852
|
}
|
|
2293
853
|
} catch (err) {
|
|
2294
|
-
if (
|
|
854
|
+
if (artifactsMoved) {
|
|
2295
855
|
try {
|
|
2296
|
-
await fs.promises.rename(
|
|
856
|
+
await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
|
|
2297
857
|
} catch (rollbackErr) {
|
|
2298
858
|
throw new Error(
|
|
2299
859
|
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2300
860
|
);
|
|
2301
861
|
}
|
|
2302
862
|
}
|
|
2303
|
-
|
|
863
|
+
|
|
864
|
+
if (sessionMoved) {
|
|
2304
865
|
try {
|
|
2305
866
|
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
2306
867
|
} catch (rollbackErr) {
|
|
2307
868
|
throw new Error(
|
|
2308
869
|
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2309
|
-
);
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
throw err;
|
|
2313
|
-
}
|
|
2314
|
-
this.#sessionFile = newSessionFile;
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
// Update cwd and sessionDir after the move succeeds.
|
|
2318
|
-
this.cwd = resolvedCwd;
|
|
2319
|
-
this.sessionDir = newSessionDir;
|
|
2320
|
-
|
|
2321
|
-
// Update the session header in fileEntries
|
|
2322
|
-
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2323
|
-
if (header) {
|
|
2324
|
-
header.cwd = resolvedCwd;
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
|
-
// Rewrite the session file at its new location with updated header.
|
|
2328
|
-
// hadSessionFile: file existed before move → must rewrite to update cwd
|
|
2329
|
-
// hasAssistant: assistant messages in memory but file missing → recreate from memory
|
|
2330
|
-
// Neither true → fresh session, never written → preserve lazy-persist
|
|
2331
|
-
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
2332
|
-
if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
|
|
2333
|
-
await this.#rewriteFile();
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
// Update terminal breadcrumb
|
|
2337
|
-
if (this.#sessionFile) {
|
|
2338
|
-
this.#maybeWriteBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
/** Sync version for initial creation (no existing writer to close) */
|
|
2343
|
-
#newSessionSync(options?: NewSessionOptions): string | undefined {
|
|
2344
|
-
this.#persistChain = Promise.resolve();
|
|
2345
|
-
this.#persistError = undefined;
|
|
2346
|
-
this.#persistErrorReported = false;
|
|
2347
|
-
this.#sessionId = createSessionId();
|
|
2348
|
-
this.#sessionName = undefined;
|
|
2349
|
-
this.#titleSource = undefined;
|
|
2350
|
-
const timestamp = new Date().toISOString();
|
|
2351
|
-
const header: SessionHeader = {
|
|
2352
|
-
type: "session",
|
|
2353
|
-
version: CURRENT_SESSION_VERSION,
|
|
2354
|
-
id: this.#sessionId,
|
|
2355
|
-
timestamp,
|
|
2356
|
-
cwd: this.cwd,
|
|
2357
|
-
parentSession: options?.parentSession,
|
|
2358
|
-
};
|
|
2359
|
-
this.#fileEntries = [header];
|
|
2360
|
-
this.#byId.clear();
|
|
2361
|
-
this.#labelsById.clear();
|
|
2362
|
-
this.#leafId = null;
|
|
2363
|
-
this.#flushed = false;
|
|
2364
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
2365
|
-
this.#ensuredOnDisk = false;
|
|
2366
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
2367
|
-
this.#inMemoryArtifacts = null;
|
|
2368
|
-
this.#inMemoryArtifactCounter = 0;
|
|
2369
|
-
|
|
2370
|
-
if (this.persist) {
|
|
2371
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2372
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
2373
|
-
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
|
|
2374
|
-
}
|
|
2375
|
-
return this.#sessionFile;
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
#buildIndex(): void {
|
|
2379
|
-
this.#byId.clear();
|
|
2380
|
-
this.#labelsById.clear();
|
|
2381
|
-
this.#leafId = null;
|
|
2382
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
2383
|
-
for (const entry of this.#fileEntries) {
|
|
2384
|
-
if (entry.type === "session") continue;
|
|
2385
|
-
this.#byId.set(entry.id, entry);
|
|
2386
|
-
this.#leafId = entry.id;
|
|
2387
|
-
if (entry.type === "label") {
|
|
2388
|
-
if (entry.label) {
|
|
2389
|
-
this.#labelsById.set(entry.targetId, entry.label);
|
|
2390
|
-
} else {
|
|
2391
|
-
this.#labelsById.delete(entry.targetId);
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
2395
|
-
const usage = entry.message.usage;
|
|
2396
|
-
this.#usageStatistics.input += usage.input;
|
|
2397
|
-
this.#usageStatistics.output += usage.output;
|
|
2398
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2399
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2400
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2401
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
2405
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
2406
|
-
if (usage) {
|
|
2407
|
-
this.#usageStatistics.input += usage.input;
|
|
2408
|
-
this.#usageStatistics.output += usage.output;
|
|
2409
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2410
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2411
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2412
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
#recordPersistError(err: unknown): Error {
|
|
2419
|
-
const normalized = toError(err);
|
|
2420
|
-
if (!this.#persistError) this.#persistError = normalized;
|
|
2421
|
-
if (!this.#persistErrorReported) {
|
|
2422
|
-
this.#persistErrorReported = true;
|
|
2423
|
-
logger.error("Session persistence error.", {
|
|
2424
|
-
sessionFile: this.#sessionFile,
|
|
2425
|
-
error: normalized.message,
|
|
2426
|
-
stack: normalized.stack,
|
|
2427
|
-
});
|
|
2428
|
-
}
|
|
2429
|
-
return normalized;
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
#queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
|
|
2433
|
-
const next = this.#persistChain.then(async () => {
|
|
2434
|
-
if (this.#persistError && !options?.ignoreError) throw this.#persistError;
|
|
2435
|
-
await task();
|
|
2436
|
-
});
|
|
2437
|
-
this.#persistChain = next.catch(err => {
|
|
2438
|
-
this.#recordPersistError(err);
|
|
2439
|
-
});
|
|
2440
|
-
return next;
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
2444
|
-
if (!this.persist || !this.#sessionFile) return undefined;
|
|
2445
|
-
if (this.#persistError) throw this.#persistError;
|
|
2446
|
-
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
|
|
2447
|
-
if (this.#persistWriter.isOpen()) return this.#persistWriter;
|
|
2448
|
-
// Cached writer for the current file is mid-close (queued
|
|
2449
|
-
// `#closePersistWriterInternal` has flipped `#closing` but not yet
|
|
2450
|
-
// cleared `#persistWriter`). Returning it would make `writeSync`
|
|
2451
|
-
// throw "Writer closed". Defer to the caller — `_persist` routes
|
|
2452
|
-
// the entry through the async rewrite path so it still lands on disk.
|
|
2453
|
-
return undefined;
|
|
2454
|
-
}
|
|
2455
|
-
// Note: caller must await _closePersistWriter() before calling this if switching files
|
|
2456
|
-
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
|
|
2457
|
-
onError: err => {
|
|
2458
|
-
this.#recordPersistError(err);
|
|
2459
|
-
},
|
|
2460
|
-
});
|
|
2461
|
-
this.#persistWriterPath = this.#sessionFile;
|
|
2462
|
-
return this.#persistWriter;
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
async #closePersistWriterInternal(): Promise<void> {
|
|
2466
|
-
if (this.#persistWriter) {
|
|
2467
|
-
await this.#persistWriter.close();
|
|
2468
|
-
this.#persistWriter = undefined;
|
|
2469
|
-
}
|
|
2470
|
-
this.#persistWriterPath = undefined;
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
async #closePersistWriter(): Promise<void> {
|
|
2474
|
-
await this.#queuePersistTask(
|
|
2475
|
-
async () => {
|
|
2476
|
-
await this.#closePersistWriterInternal();
|
|
2477
|
-
},
|
|
2478
|
-
{ ignoreError: true },
|
|
2479
|
-
);
|
|
2480
|
-
}
|
|
2481
|
-
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
|
|
2482
|
-
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2483
|
-
// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
|
|
2484
|
-
// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
|
|
2485
|
-
// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
|
|
2486
|
-
// the primary on the next session-dir scan.
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
2487
873
|
|
|
2488
|
-
|
|
2489
|
-
const dir = path.resolve(targetPath, "..");
|
|
2490
|
-
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2491
|
-
try {
|
|
2492
|
-
await this.storage.rename(targetPath, backupPath);
|
|
2493
|
-
} catch (err) {
|
|
2494
|
-
if (isEnoent(err)) {
|
|
2495
|
-
await this.storage.rename(tempPath, targetPath);
|
|
2496
|
-
return;
|
|
874
|
+
throw err;
|
|
2497
875
|
}
|
|
2498
|
-
throw toError(renameError);
|
|
2499
|
-
}
|
|
2500
876
|
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
const replaceError = toError(err);
|
|
2505
|
-
const originalError = toError(renameError);
|
|
2506
|
-
try {
|
|
2507
|
-
await this.storage.rename(backupPath, targetPath);
|
|
2508
|
-
} catch (rollbackErr) {
|
|
2509
|
-
const rollbackError = toError(rollbackErr);
|
|
2510
|
-
throw new Error(
|
|
2511
|
-
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2512
|
-
{ cause: originalError },
|
|
2513
|
-
);
|
|
2514
|
-
}
|
|
2515
|
-
throw replaceError;
|
|
877
|
+
this.#sessionFile = newSessionFile;
|
|
878
|
+
this.#artifactManager = null;
|
|
879
|
+
this.#artifactManagerSessionFile = null;
|
|
2516
880
|
}
|
|
2517
881
|
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
if (!isEnoent(err)) {
|
|
2522
|
-
logger.warn("Failed to remove session rewrite backup", {
|
|
2523
|
-
sessionFile: targetPath,
|
|
2524
|
-
backupPath,
|
|
2525
|
-
error: toError(err).message,
|
|
2526
|
-
});
|
|
2527
|
-
}
|
|
2528
|
-
}
|
|
2529
|
-
}
|
|
882
|
+
this.#cwd = resolvedCwd;
|
|
883
|
+
this.#sessionDir = nextSessionDir;
|
|
884
|
+
this.#header.cwd = resolvedCwd;
|
|
2530
885
|
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
await this.#
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
2540
|
-
if (!this.#sessionFile) return;
|
|
2541
|
-
const dir = path.resolve(this.#sessionFile, "..");
|
|
2542
|
-
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
2543
|
-
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
2544
|
-
try {
|
|
2545
|
-
for (const entry of entries) {
|
|
2546
|
-
await writer.write(entry);
|
|
2547
|
-
}
|
|
2548
|
-
await writer.flush();
|
|
2549
|
-
await writer.fsync();
|
|
2550
|
-
await writer.close();
|
|
2551
|
-
await this.#replaceSessionFile(tempPath, this.#sessionFile);
|
|
2552
|
-
} catch (err) {
|
|
2553
|
-
try {
|
|
2554
|
-
await writer.close();
|
|
2555
|
-
} catch {
|
|
2556
|
-
// Ignore cleanup errors
|
|
2557
|
-
}
|
|
2558
|
-
try {
|
|
2559
|
-
await this.storage.unlink(tempPath);
|
|
2560
|
-
} catch {
|
|
2561
|
-
// Ignore cleanup errors
|
|
2562
|
-
}
|
|
2563
|
-
throw toError(err);
|
|
886
|
+
// Rewrite at the new location when the file already existed (update cwd) or
|
|
887
|
+
// there is in-memory output worth materializing; otherwise stay lazy.
|
|
888
|
+
const hasAssistant = this.#historyContainsAssistantMessage();
|
|
889
|
+
if (this.#persist && this.#sessionFile && (sessionFileExisted || hasAssistant)) {
|
|
890
|
+
this.#forceFileCreation = true;
|
|
891
|
+
await this.#rewriteAtomically();
|
|
2564
892
|
}
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
async #rewriteFile(): Promise<void> {
|
|
2568
|
-
if (!this.persist || !this.#sessionFile) return;
|
|
2569
|
-
await this.#queuePersistTask(async () => {
|
|
2570
|
-
await this.#closePersistWriterInternal();
|
|
2571
|
-
const entries = await Promise.all(
|
|
2572
|
-
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
2573
|
-
);
|
|
2574
|
-
await this.#writeEntriesAtomically(entries);
|
|
2575
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
2576
|
-
this.#flushed = true;
|
|
2577
|
-
});
|
|
2578
|
-
}
|
|
2579
893
|
|
|
2580
|
-
|
|
2581
|
-
return this.persist;
|
|
894
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2582
895
|
}
|
|
2583
896
|
|
|
2584
897
|
/**
|
|
2585
|
-
* Force
|
|
2586
|
-
*
|
|
898
|
+
* Force the session onto disk even with no assistant message yet (ACP
|
|
899
|
+
* session/new must create a discoverable file immediately).
|
|
2587
900
|
*/
|
|
2588
901
|
async ensureOnDisk(): Promise<void> {
|
|
2589
|
-
if (!this
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
this.#
|
|
902
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
903
|
+
this.#forceFileCreation = true;
|
|
904
|
+
if (this.#fileIsCurrent && !this.#rewriteRequired) return;
|
|
905
|
+
await this.#rewriteAtomically();
|
|
2593
906
|
}
|
|
2594
907
|
|
|
2595
|
-
/** Flush pending writes
|
|
908
|
+
/** Flush pending writes. Call before switching sessions or on shutdown. */
|
|
2596
909
|
async flush(): Promise<void> {
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
await this.#persistWriter.fsync();
|
|
2601
|
-
}
|
|
910
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
911
|
+
await this.#scheduleDiskWork(async () => {
|
|
912
|
+
if (this.#writer?.isOpen()) await this.#writer.flush();
|
|
2602
913
|
});
|
|
2603
|
-
if (this.#
|
|
914
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2604
915
|
}
|
|
2605
916
|
|
|
2606
917
|
/**
|
|
2607
|
-
* Synchronously flush all in-memory entries to disk
|
|
2608
|
-
*
|
|
2609
|
-
*
|
|
2610
|
-
*
|
|
2611
|
-
*
|
|
2612
|
-
* Hot path: the persist writer is open and flushed, so a single fsyncSync
|
|
2613
|
-
* pushes the page-cache data to physical disk.
|
|
2614
|
-
*
|
|
2615
|
-
* Cold path: entries are only in memory (session just started, or a rewrite
|
|
2616
|
-
* is pending). Writes all entries to a temp file, fsyncs, and atomically
|
|
2617
|
-
* renames over the session file — then re-opens an append writer so the
|
|
2618
|
-
* hot path resumes on subsequent `_persist` calls.
|
|
918
|
+
* Synchronously flush all in-memory entries to disk. Use when the process may
|
|
919
|
+
* exit before an async flush settles (Ctrl+C in the TUI). Software-crash
|
|
920
|
+
* durable; not atomic and not power-loss safe — a same-process crash never
|
|
921
|
+
* lands mid-`writeFileSync`.
|
|
2619
922
|
*/
|
|
2620
923
|
flushSync(): void {
|
|
2621
|
-
if (!this
|
|
2622
|
-
if (this.#
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
// Just fsync the fd — the data is already in the kernel page cache.
|
|
2626
|
-
if (this.#persistWriter?.isOpen() && this.#flushed && !this.#needsFullRewriteOnNextPersist) {
|
|
2627
|
-
this.#persistWriter.fsyncSync();
|
|
2628
|
-
return;
|
|
2629
|
-
}
|
|
2630
|
-
|
|
2631
|
-
// Cold path: write all in-memory entries to a temp file and atomically
|
|
2632
|
-
// replace the session file. This is safe to run even when an async
|
|
2633
|
-
// rewrite is queued on #persistChain: the async task won't progress
|
|
2634
|
-
// while we're on the sync call stack, and the file we produce is a
|
|
2635
|
-
// superset of whatever the async rewrite would write.
|
|
2636
|
-
const dir = path.resolve(this.#sessionFile, "..");
|
|
2637
|
-
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
2638
|
-
const fd = fs.openSync(tempPath, "w");
|
|
2639
|
-
try {
|
|
2640
|
-
for (const entry of this.#fileEntries) {
|
|
2641
|
-
const persisted = prepareEntryForPersistenceSync(entry, this.#blobStore);
|
|
2642
|
-
const line = `${JSON.stringify(persisted)}\n`;
|
|
2643
|
-
fs.writeSync(fd, line);
|
|
2644
|
-
}
|
|
2645
|
-
fs.fsyncSync(fd);
|
|
2646
|
-
} finally {
|
|
2647
|
-
fs.closeSync(fd);
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
// Atomic replace (with EPERM retry for Windows)
|
|
2651
|
-
try {
|
|
2652
|
-
fs.renameSync(tempPath, this.#sessionFile);
|
|
2653
|
-
} catch (err) {
|
|
2654
|
-
if (!hasFsCode(err, "EPERM")) {
|
|
2655
|
-
try {
|
|
2656
|
-
fs.unlinkSync(tempPath);
|
|
2657
|
-
} catch {
|
|
2658
|
-
/* best effort */
|
|
2659
|
-
}
|
|
2660
|
-
throw toError(err);
|
|
2661
|
-
}
|
|
2662
|
-
// Windows: move the old file aside, then rename
|
|
2663
|
-
const backupPath = path.join(dir, `${path.basename(this.#sessionFile)}.${Snowflake.next()}.bak`);
|
|
2664
|
-
try {
|
|
2665
|
-
fs.renameSync(this.#sessionFile, backupPath);
|
|
2666
|
-
} catch (moveAsideErr) {
|
|
2667
|
-
if (isEnoent(moveAsideErr)) {
|
|
2668
|
-
fs.renameSync(tempPath, this.#sessionFile);
|
|
2669
|
-
return;
|
|
2670
|
-
}
|
|
2671
|
-
try {
|
|
2672
|
-
fs.unlinkSync(tempPath);
|
|
2673
|
-
} catch {
|
|
2674
|
-
/* best effort */
|
|
2675
|
-
}
|
|
2676
|
-
throw toError(err);
|
|
2677
|
-
}
|
|
2678
|
-
try {
|
|
2679
|
-
fs.renameSync(tempPath, this.#sessionFile);
|
|
2680
|
-
} catch (replaceErr) {
|
|
2681
|
-
// Roll back
|
|
2682
|
-
try {
|
|
2683
|
-
fs.renameSync(backupPath, this.#sessionFile);
|
|
2684
|
-
} catch {
|
|
2685
|
-
/* best effort */
|
|
2686
|
-
}
|
|
2687
|
-
throw toError(replaceErr);
|
|
2688
|
-
}
|
|
2689
|
-
try {
|
|
2690
|
-
fs.unlinkSync(backupPath);
|
|
2691
|
-
} catch {
|
|
2692
|
-
/* best effort */
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
// Re-open the persist writer in append mode so the hot path resumes.
|
|
2697
|
-
if (this.#persistWriter) {
|
|
2698
|
-
// The old writer is stale (pointed at the pre-rewrite file or was
|
|
2699
|
-
// mid-close). Close it asynchronously — it's a no-op if already
|
|
2700
|
-
// closed, and we don't want to block on draining its queue.
|
|
2701
|
-
void this.#persistWriter.close().catch(() => {});
|
|
2702
|
-
}
|
|
2703
|
-
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
|
|
2704
|
-
onError: err => this.#recordPersistError(err),
|
|
2705
|
-
});
|
|
2706
|
-
this.#persistWriterPath = this.#sessionFile;
|
|
2707
|
-
this.#flushed = true;
|
|
2708
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
924
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
925
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
926
|
+
this.#rewriteSynchronously();
|
|
927
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2709
928
|
}
|
|
2710
929
|
|
|
2711
|
-
/**
|
|
930
|
+
/** Flush, then close the append writer. */
|
|
2712
931
|
async close(): Promise<void> {
|
|
2713
|
-
if (!this.#
|
|
2714
|
-
await this.#
|
|
2715
|
-
await this.#
|
|
2716
|
-
this.#
|
|
932
|
+
if (!this.#persist) return;
|
|
933
|
+
await this.#scheduleDiskWork(async () => {
|
|
934
|
+
await this.#closeWriterHandle();
|
|
935
|
+
this.#fileIsCurrent = true;
|
|
2717
936
|
});
|
|
2718
|
-
if (this.#
|
|
937
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2719
938
|
}
|
|
2720
939
|
|
|
2721
940
|
getCwd(): string {
|
|
2722
|
-
return this
|
|
941
|
+
return this.#cwd;
|
|
2723
942
|
}
|
|
2724
943
|
|
|
2725
|
-
/** Get usage statistics across all assistant messages in the session. */
|
|
2726
944
|
getUsageStatistics(): UsageStatistics {
|
|
2727
|
-
return this.#
|
|
945
|
+
return this.#index.usageSnapshot();
|
|
2728
946
|
}
|
|
2729
947
|
|
|
2730
948
|
/**
|
|
2731
949
|
* Open a new per-turn budget window: snapshot the cumulative output baseline,
|
|
2732
|
-
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2733
|
-
* per real user message; `total` is null when no `+Nk` directive was present.
|
|
950
|
+
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2734
951
|
*/
|
|
2735
952
|
beginTurnBudget(total: number | null, hard: boolean): void {
|
|
2736
|
-
this.#
|
|
2737
|
-
this.#
|
|
953
|
+
this.#turnBudgetTotal = total;
|
|
954
|
+
this.#turnBudgetHard = hard;
|
|
955
|
+
this.#turnOutputBaseline = this.#index.usageSnapshot().output;
|
|
2738
956
|
this.#turnEvalOutput = 0;
|
|
2739
957
|
}
|
|
2740
958
|
|
|
2741
|
-
/** Record output tokens consumed by an eval-spawned subagent in the current turn. */
|
|
2742
959
|
recordEvalSubagentOutput(output: number): void {
|
|
2743
960
|
if (Number.isFinite(output) && output > 0) this.#turnEvalOutput += output;
|
|
2744
961
|
}
|
|
2745
962
|
|
|
2746
|
-
/**
|
|
2747
|
-
* Current turn budget for the eval `budget` helper: the ceiling (null = none),
|
|
2748
|
-
* output tokens spent this turn (main loop + eval-spawned subagents, no
|
|
2749
|
-
* double-count), and whether the ceiling is hard.
|
|
2750
|
-
*/
|
|
2751
963
|
getTurnBudget(): { total: number | null; spent: number; hard: boolean } {
|
|
2752
|
-
const
|
|
2753
|
-
return { total: this.#
|
|
964
|
+
const mainOutput = Math.max(0, this.#index.usageSnapshot().output - this.#turnOutputBaseline);
|
|
965
|
+
return { total: this.#turnBudgetTotal, spent: mainOutput + this.#turnEvalOutput, hard: this.#turnBudgetHard };
|
|
2754
966
|
}
|
|
2755
967
|
|
|
2756
968
|
getSessionDir(): string {
|
|
2757
|
-
return this
|
|
969
|
+
return this.#sessionDir;
|
|
2758
970
|
}
|
|
2759
971
|
|
|
2760
972
|
getSessionId(): string {
|
|
@@ -2765,152 +977,78 @@ export class SessionManager {
|
|
|
2765
977
|
return this.#sessionFile;
|
|
2766
978
|
}
|
|
2767
979
|
|
|
2768
|
-
/**
|
|
2769
|
-
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
2770
|
-
* Returns null when the session is not persisted to a file.
|
|
2771
|
-
* When this session has adopted an external ArtifactManager (subagent case),
|
|
2772
|
-
* returns that manager's directory so reads/writes land in the shared parent
|
|
2773
|
-
* dir instead of a private (non-existent) subdir.
|
|
2774
|
-
*/
|
|
2775
980
|
getArtifactsDir(): string | null {
|
|
2776
981
|
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
|
|
2777
|
-
|
|
2778
|
-
return sessionFile ? sessionFile.slice(0, -6) : null;
|
|
982
|
+
return artifactsDirectoryFor(this.#sessionFile);
|
|
2779
983
|
}
|
|
2780
984
|
|
|
2781
|
-
/**
|
|
2782
|
-
* Adopt an externally-owned ArtifactManager. Used by subagents to share
|
|
2783
|
-
* the parent session's artifact directory and ID counter.
|
|
2784
|
-
*/
|
|
2785
985
|
adoptArtifactManager(manager: ArtifactManager): void {
|
|
2786
986
|
this.#adoptedArtifactManager = manager;
|
|
2787
987
|
}
|
|
2788
988
|
|
|
2789
|
-
/**
|
|
2790
|
-
* Returns the ArtifactManager this session writes through. Lazily creates
|
|
2791
|
-
* one bound to the current session file unless an external manager was
|
|
2792
|
-
* adopted via `adoptArtifactManager`. Returns null only for non-persistent
|
|
2793
|
-
* sessions with no adopted manager.
|
|
2794
|
-
*/
|
|
2795
989
|
getArtifactManager(): ArtifactManager | null {
|
|
2796
|
-
return this.#
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
/**
|
|
2800
|
-
* Returns an artifact manager bound to the current session file.
|
|
2801
|
-
* Recreates the manager when the active session file changes.
|
|
2802
|
-
*/
|
|
2803
|
-
#getOrCreateArtifactManager(): ArtifactManager | null {
|
|
2804
|
-
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
2805
|
-
const sessionFile = this.#sessionFile;
|
|
2806
|
-
if (!sessionFile) {
|
|
2807
|
-
this.#artifactManager = null;
|
|
2808
|
-
this.#artifactManagerSessionFile = null;
|
|
2809
|
-
return null;
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2812
|
-
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) {
|
|
2813
|
-
return this.#artifactManager;
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
const manager = new ArtifactManager(sessionFile.slice(0, -6));
|
|
2817
|
-
this.#artifactManager = manager;
|
|
2818
|
-
this.#artifactManagerSessionFile = sessionFile;
|
|
2819
|
-
return manager;
|
|
990
|
+
return this.#artifactManagerForSession();
|
|
2820
991
|
}
|
|
2821
992
|
|
|
2822
|
-
/**
|
|
2823
|
-
* Allocate a new artifact path and ID for the current session.
|
|
2824
|
-
* Returns an empty object when the session is not persisted.
|
|
2825
|
-
*/
|
|
2826
993
|
async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> {
|
|
2827
|
-
|
|
2828
|
-
if (!manager) return {};
|
|
2829
|
-
return manager.allocatePath(toolType);
|
|
994
|
+
return (await this.#artifactManagerForSession()?.allocatePath(toolType)) ?? {};
|
|
2830
995
|
}
|
|
2831
996
|
|
|
2832
|
-
/**
|
|
2833
|
-
* Save artifact content under the current session and return artifact ID.
|
|
2834
|
-
* Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise).
|
|
2835
|
-
*/
|
|
2836
997
|
async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
|
|
2837
|
-
const manager = this.#
|
|
998
|
+
const manager = this.#artifactManagerForSession();
|
|
2838
999
|
if (manager) return manager.save(content, toolType);
|
|
2839
|
-
|
|
2840
|
-
|
|
1000
|
+
|
|
1001
|
+
// Non-persistent session: keep an in-memory copy so spill truncation works.
|
|
1002
|
+
this.#inMemoryArtifacts ??= new Map();
|
|
2841
1003
|
const id = String(this.#inMemoryArtifactCounter++);
|
|
2842
1004
|
this.#inMemoryArtifacts.set(id, content);
|
|
2843
1005
|
return id;
|
|
2844
1006
|
}
|
|
2845
1007
|
|
|
2846
|
-
/**
|
|
2847
|
-
* Resolve an artifact ID to an on-disk path for the current session.
|
|
2848
|
-
* Returns null when missing or when the session is not persisted.
|
|
2849
|
-
*/
|
|
2850
1008
|
async getArtifactPath(id: string): Promise<string | null> {
|
|
2851
|
-
|
|
2852
|
-
if (!manager) return null;
|
|
2853
|
-
return manager.getPath(id);
|
|
2854
|
-
}
|
|
2855
|
-
|
|
2856
|
-
/**
|
|
2857
|
-
* Path to the unsent-input draft sidecar for the current session. Lives inside
|
|
2858
|
-
* the artifacts directory so it is removed together with the session on
|
|
2859
|
-
* `dropSession`. Returns null when the session has no on-disk identity.
|
|
2860
|
-
*/
|
|
2861
|
-
#getDraftPath(): string | null {
|
|
2862
|
-
const dir = this.getArtifactsDir();
|
|
2863
|
-
return dir ? path.join(dir, "draft.txt") : null;
|
|
1009
|
+
return (await this.#artifactManagerForSession()?.getPath(id)) ?? null;
|
|
2864
1010
|
}
|
|
2865
1011
|
|
|
2866
|
-
/**
|
|
2867
|
-
* Persist (or clear) the current editor draft so the next resume of this
|
|
2868
|
-
* session can restore it. Empty text deletes any stale draft. No-op when the
|
|
2869
|
-
* session is not persisted.
|
|
2870
|
-
*/
|
|
2871
1012
|
async saveDraft(text: string): Promise<void> {
|
|
2872
|
-
const draftPath = this.#
|
|
2873
|
-
if (!draftPath || !this
|
|
1013
|
+
const draftPath = this.#draftPath();
|
|
1014
|
+
if (!draftPath || !this.#persist) return;
|
|
1015
|
+
|
|
2874
1016
|
if (text.length === 0) {
|
|
2875
1017
|
try {
|
|
2876
|
-
await this
|
|
1018
|
+
await this.#storage.unlink(draftPath);
|
|
2877
1019
|
} catch (err) {
|
|
2878
1020
|
if (!isEnoent(err)) throw err;
|
|
2879
1021
|
}
|
|
2880
1022
|
return;
|
|
2881
1023
|
}
|
|
2882
|
-
|
|
2883
|
-
//
|
|
2884
|
-
// never produced an assistant reply would persist a draft next to a
|
|
2885
|
-
// session file that does not exist on disk.
|
|
1024
|
+
|
|
1025
|
+
// Force the header onto disk so resume can find the file this draft attaches to.
|
|
2886
1026
|
await this.ensureOnDisk();
|
|
2887
|
-
await this
|
|
1027
|
+
await this.#storage.writeText(draftPath, text);
|
|
2888
1028
|
}
|
|
2889
1029
|
|
|
2890
|
-
/**
|
|
2891
|
-
* Read and remove the saved draft. Returns the previously-saved text, or
|
|
2892
|
-
* null when no draft is pending. Single-shot: a successful read removes the
|
|
2893
|
-
* sidecar so a subsequent resume does not re-restore the same text.
|
|
2894
|
-
*/
|
|
2895
1030
|
async consumeDraft(): Promise<string | null> {
|
|
2896
|
-
const draftPath = this.#
|
|
1031
|
+
const draftPath = this.#draftPath();
|
|
2897
1032
|
if (!draftPath) return null;
|
|
2898
|
-
|
|
1033
|
+
|
|
1034
|
+
let draft: string;
|
|
2899
1035
|
try {
|
|
2900
|
-
|
|
1036
|
+
draft = await this.#storage.readText(draftPath);
|
|
2901
1037
|
} catch (err) {
|
|
2902
1038
|
if (isEnoent(err)) return null;
|
|
2903
1039
|
throw err;
|
|
2904
1040
|
}
|
|
1041
|
+
|
|
2905
1042
|
try {
|
|
2906
|
-
await this
|
|
1043
|
+
await this.#storage.unlink(draftPath);
|
|
2907
1044
|
} catch (err) {
|
|
2908
1045
|
if (!isEnoent(err)) throw err;
|
|
2909
1046
|
}
|
|
2910
|
-
|
|
1047
|
+
|
|
1048
|
+
return draft;
|
|
2911
1049
|
}
|
|
2912
1050
|
|
|
2913
|
-
/** The source that set the session name: "user" (manual
|
|
1051
|
+
/** The source that set the session name: "user" (manual/RPC) or "auto" (generated title). */
|
|
2914
1052
|
get titleSource(): "auto" | "user" | undefined {
|
|
2915
1053
|
return this.#titleSource;
|
|
2916
1054
|
}
|
|
@@ -2926,174 +1064,51 @@ export class SessionManager {
|
|
|
2926
1064
|
};
|
|
2927
1065
|
}
|
|
2928
1066
|
|
|
2929
|
-
#fireSessionNameChanged(): void {
|
|
2930
|
-
for (const cb of [...this.#sessionNameChangedCallbacks]) {
|
|
2931
|
-
try {
|
|
2932
|
-
cb();
|
|
2933
|
-
} catch (err) {
|
|
2934
|
-
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
}
|
|
2938
|
-
|
|
2939
|
-
/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
|
|
2940
|
-
static #sanitizeName(name: string): string {
|
|
2941
|
-
return name
|
|
2942
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
2943
|
-
.replace(/ +/g, " ")
|
|
2944
|
-
.trim();
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
1067
|
/**
|
|
2948
1068
|
* Set the session display name.
|
|
2949
|
-
* @param source
|
|
2950
|
-
* Auto
|
|
1069
|
+
* @param source "user" for explicit renames; "auto" for generated titles.
|
|
1070
|
+
* Auto titles are ignored once the user has set a name.
|
|
2951
1071
|
*/
|
|
2952
1072
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
2953
|
-
// User-set names take permanent precedence over auto-generated ones.
|
|
2954
1073
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
2955
1074
|
|
|
2956
|
-
const
|
|
2957
|
-
if (!
|
|
1075
|
+
const title = SessionManager.#cleanTitle(name);
|
|
1076
|
+
if (!title) return false;
|
|
2958
1077
|
|
|
2959
|
-
this.#sessionName =
|
|
1078
|
+
this.#sessionName = title;
|
|
2960
1079
|
this.#titleSource = source;
|
|
1080
|
+
this.#header.title = title;
|
|
1081
|
+
this.#header.titleSource = source;
|
|
2961
1082
|
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
if (header) {
|
|
2965
|
-
header.title = sanitized;
|
|
2966
|
-
header.titleSource = source;
|
|
1083
|
+
if (this.#persist && this.#sessionFile && this.#storage.existsSync(this.#sessionFile)) {
|
|
1084
|
+
await this.#rewriteAtomically();
|
|
2967
1085
|
}
|
|
2968
1086
|
|
|
2969
|
-
|
|
2970
|
-
const sessionFile = this.#sessionFile;
|
|
2971
|
-
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
2972
|
-
await this.#rewriteFile();
|
|
2973
|
-
}
|
|
2974
|
-
this.#fireSessionNameChanged();
|
|
1087
|
+
this.#notifySessionNameListeners();
|
|
2975
1088
|
return true;
|
|
2976
1089
|
}
|
|
2977
1090
|
|
|
2978
|
-
_persist(entry: SessionEntry): void {
|
|
2979
|
-
if (!this.persist || !this.#sessionFile) return;
|
|
2980
|
-
if (this.#persistError) throw this.#persistError;
|
|
2981
|
-
|
|
2982
|
-
// Normally we wait for the first assistant message before persisting to avoid
|
|
2983
|
-
// creating files for sessions that never produce output. Once ensureOnDisk() has
|
|
2984
|
-
// been called, the session is already on disk and every entry must be flushed.
|
|
2985
|
-
if (!this.#ensuredOnDisk) {
|
|
2986
|
-
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
2987
|
-
if (!hasAssistant) {
|
|
2988
|
-
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
2989
|
-
this.#flushed = false;
|
|
2990
|
-
return;
|
|
2991
|
-
}
|
|
2992
|
-
}
|
|
2993
|
-
|
|
2994
|
-
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
2995
|
-
// Cold path: rewrite the whole file atomically. Async — the writer is
|
|
2996
|
-
// closed/reopened and every entry is re-prepared. Errors flow through
|
|
2997
|
-
// `#persistChain` → `#recordPersistError`; we swallow the rejection
|
|
2998
|
-
// here to avoid an unhandled rejection when the persist dir races with
|
|
2999
|
-
// test-level tempDir cleanup.
|
|
3000
|
-
this.#rewriteFile().catch(() => {});
|
|
3001
|
-
return;
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// Hot path: synchronously truncate + append. `fs.writeSync` returns once the
|
|
3005
|
-
// bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
|
|
3006
|
-
// landing immediately after this call. Image externalization (rare) runs via
|
|
3007
|
-
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
3008
|
-
// line referencing them is written.
|
|
3009
|
-
try {
|
|
3010
|
-
const writer = this.#ensurePersistWriter();
|
|
3011
|
-
if (!writer) {
|
|
3012
|
-
// `#ensurePersistWriter` returns undefined here only when the cached
|
|
3013
|
-
// writer is mid-close (the `!persist`/`!sessionFile` cases are
|
|
3014
|
-
// rejected above). Route through `#rewriteFile` so the entry — which
|
|
3015
|
-
// is already in `#fileEntries` — persists once the close drains.
|
|
3016
|
-
this.#rewriteFile().catch(() => {});
|
|
3017
|
-
return;
|
|
3018
|
-
}
|
|
3019
|
-
const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
|
|
3020
|
-
writer.writeSync(persistedEntry);
|
|
3021
|
-
} catch (err) {
|
|
3022
|
-
this.#recordPersistError(err);
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
|
|
3026
|
-
#appendEntry(entry: SessionEntry): void {
|
|
3027
|
-
this.#fileEntries.push(entry);
|
|
3028
|
-
this.#byId.set(entry.id, entry);
|
|
3029
|
-
this.#leafId = entry.id;
|
|
3030
|
-
this._persist(entry);
|
|
3031
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
3032
|
-
const usage = entry.message.usage;
|
|
3033
|
-
this.#usageStatistics.input += usage.input;
|
|
3034
|
-
this.#usageStatistics.output += usage.output;
|
|
3035
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
3036
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
3037
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
3038
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
3042
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
3043
|
-
if (usage) {
|
|
3044
|
-
this.#usageStatistics.input += usage.input;
|
|
3045
|
-
this.#usageStatistics.output += usage.output;
|
|
3046
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
3047
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
3048
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
3049
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
3050
|
-
}
|
|
3051
|
-
}
|
|
3052
|
-
if (this.onEntryAppended) {
|
|
3053
|
-
try {
|
|
3054
|
-
this.onEntryAppended(entry);
|
|
3055
|
-
} catch (err) {
|
|
3056
|
-
logger.warn("collab entry hook failed", { error: String(err) });
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
3060
|
-
|
|
3061
1091
|
/**
|
|
3062
1092
|
* Append a foreign (host-authored) entry verbatim, preserving its
|
|
3063
|
-
* `id`/`parentId
|
|
3064
|
-
* host session into the local replica file.
|
|
1093
|
+
* `id`/`parentId`. Used by collab guests to mirror the host session.
|
|
3065
1094
|
*/
|
|
3066
1095
|
ingestReplicatedEntry(entry: SessionEntry): void {
|
|
3067
|
-
this.#
|
|
1096
|
+
this.#recordEntry(entry);
|
|
3068
1097
|
}
|
|
3069
1098
|
|
|
3070
1099
|
/**
|
|
3071
1100
|
* Snapshot the session for collab replication: the live header plus a deep
|
|
3072
|
-
* copy of every entry (the host mutates entries in place on
|
|
3073
|
-
*
|
|
1101
|
+
* copy of every entry (the host mutates entries in place on rewrite paths, so
|
|
1102
|
+
* guests must not share references).
|
|
3074
1103
|
*/
|
|
3075
1104
|
snapshotForReplication(): { header: SessionHeader; entries: SessionEntry[] } {
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
title: this.#sessionName,
|
|
3084
|
-
titleSource: this.#titleSource,
|
|
3085
|
-
timestamp: new Date().toISOString(),
|
|
3086
|
-
cwd: this.cwd,
|
|
3087
|
-
};
|
|
3088
|
-
const entries = structuredClone(this.#fileEntries.filter(e => e.type !== "session")) as SessionEntry[];
|
|
3089
|
-
return { header, entries };
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
|
-
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
3093
|
-
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
3094
|
-
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
3095
|
-
* so it is easier to find them.
|
|
3096
|
-
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
1105
|
+
return { header: structuredClone(this.#header), entries: structuredClone(this.#entries) as SessionEntry[] };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Append a message as a child of the current leaf, then advance the leaf.
|
|
1110
|
+
* CompactionSummaryMessage / BranchSummaryMessage are rejected here — they are
|
|
1111
|
+
* top-level entries via appendCompaction()/branchWithSummary().
|
|
3097
1112
|
*/
|
|
3098
1113
|
appendMessage(
|
|
3099
1114
|
message:
|
|
@@ -3104,88 +1119,50 @@ export class SessionManager {
|
|
|
3104
1119
|
| PythonExecutionMessage
|
|
3105
1120
|
| FileMentionMessage,
|
|
3106
1121
|
): string {
|
|
3107
|
-
const entry: SessionMessageEntry = {
|
|
3108
|
-
|
|
3109
|
-
id: generateId(this.#byId),
|
|
3110
|
-
parentId: this.#leafId,
|
|
3111
|
-
timestamp: new Date().toISOString(),
|
|
3112
|
-
message,
|
|
3113
|
-
};
|
|
3114
|
-
this.#appendEntry(entry);
|
|
1122
|
+
const entry: SessionMessageEntry = { type: "message", ...this.#freshEntryFields(), message };
|
|
1123
|
+
this.#recordEntry(entry);
|
|
3115
1124
|
return entry.id;
|
|
3116
1125
|
}
|
|
3117
1126
|
|
|
3118
|
-
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
3119
1127
|
appendThinkingLevelChange(thinkingLevel?: string): string {
|
|
3120
1128
|
const entry: ThinkingLevelChangeEntry = {
|
|
3121
1129
|
type: "thinking_level_change",
|
|
3122
|
-
|
|
3123
|
-
parentId: this.#leafId,
|
|
3124
|
-
timestamp: new Date().toISOString(),
|
|
1130
|
+
...this.#freshEntryFields(),
|
|
3125
1131
|
thinkingLevel: thinkingLevel ?? null,
|
|
3126
1132
|
};
|
|
3127
|
-
this.#
|
|
1133
|
+
this.#recordEntry(entry);
|
|
3128
1134
|
return entry.id;
|
|
3129
1135
|
}
|
|
3130
1136
|
|
|
3131
1137
|
appendServiceTierChange(serviceTier: ServiceTier | null): string {
|
|
3132
|
-
const entry: ServiceTierChangeEntry = {
|
|
3133
|
-
|
|
3134
|
-
id: generateId(this.#byId),
|
|
3135
|
-
parentId: this.#leafId,
|
|
3136
|
-
timestamp: new Date().toISOString(),
|
|
3137
|
-
serviceTier,
|
|
3138
|
-
};
|
|
3139
|
-
this.#appendEntry(entry);
|
|
1138
|
+
const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
|
|
1139
|
+
this.#recordEntry(entry);
|
|
3140
1140
|
return entry.id;
|
|
3141
1141
|
}
|
|
3142
1142
|
|
|
3143
|
-
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
|
|
3144
1143
|
appendModeChange(mode: string, data?: Record<string, unknown>): string {
|
|
3145
|
-
const entry: ModeChangeEntry = {
|
|
3146
|
-
|
|
3147
|
-
id: generateId(this.#byId),
|
|
3148
|
-
parentId: this.#leafId,
|
|
3149
|
-
timestamp: new Date().toISOString(),
|
|
3150
|
-
mode,
|
|
3151
|
-
data,
|
|
3152
|
-
};
|
|
3153
|
-
this.#appendEntry(entry);
|
|
1144
|
+
const entry: ModeChangeEntry = { type: "mode_change", ...this.#freshEntryFields(), mode, data };
|
|
1145
|
+
this.#recordEntry(entry);
|
|
3154
1146
|
return entry.id;
|
|
3155
1147
|
}
|
|
3156
1148
|
|
|
3157
1149
|
/**
|
|
3158
|
-
* Append a model change as child of current leaf, then advance leaf.
|
|
1150
|
+
* Append a model change as a child of the current leaf, then advance the leaf.
|
|
3159
1151
|
* @param model Model in "provider/modelId" format
|
|
3160
1152
|
* @param role Optional role (default: "default")
|
|
3161
1153
|
*/
|
|
3162
1154
|
appendModelChange(model: string, role?: string): string {
|
|
3163
|
-
const entry: ModelChangeEntry = {
|
|
3164
|
-
|
|
3165
|
-
id: generateId(this.#byId),
|
|
3166
|
-
parentId: this.#leafId,
|
|
3167
|
-
timestamp: new Date().toISOString(),
|
|
3168
|
-
model,
|
|
3169
|
-
role,
|
|
3170
|
-
};
|
|
3171
|
-
this.#appendEntry(entry);
|
|
1155
|
+
const entry: ModelChangeEntry = { type: "model_change", ...this.#freshEntryFields(), model, role };
|
|
1156
|
+
this.#recordEntry(entry);
|
|
3172
1157
|
return entry.id;
|
|
3173
1158
|
}
|
|
3174
1159
|
|
|
3175
|
-
/** Append session init metadata (for subagent debugging/replay). Returns entry id. */
|
|
3176
1160
|
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
|
|
3177
|
-
const entry: SessionInitEntry = {
|
|
3178
|
-
|
|
3179
|
-
id: generateId(this.#byId),
|
|
3180
|
-
parentId: this.#leafId,
|
|
3181
|
-
timestamp: new Date().toISOString(),
|
|
3182
|
-
...init,
|
|
3183
|
-
};
|
|
3184
|
-
this.#appendEntry(entry);
|
|
1161
|
+
const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
|
|
1162
|
+
this.#recordEntry(entry);
|
|
3185
1163
|
return entry.id;
|
|
3186
1164
|
}
|
|
3187
1165
|
|
|
3188
|
-
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
3189
1166
|
appendCompaction<T = unknown>(
|
|
3190
1167
|
summary: string,
|
|
3191
1168
|
shortSummary: string | undefined,
|
|
@@ -3197,9 +1174,7 @@ export class SessionManager {
|
|
|
3197
1174
|
): string {
|
|
3198
1175
|
const entry: CompactionEntry<T> = {
|
|
3199
1176
|
type: "compaction",
|
|
3200
|
-
|
|
3201
|
-
parentId: this.#leafId,
|
|
3202
|
-
timestamp: new Date().toISOString(),
|
|
1177
|
+
...this.#freshEntryFields(),
|
|
3203
1178
|
summary,
|
|
3204
1179
|
shortSummary,
|
|
3205
1180
|
firstKeptEntryId,
|
|
@@ -3208,31 +1183,23 @@ export class SessionManager {
|
|
|
3208
1183
|
fromExtension,
|
|
3209
1184
|
preserveData,
|
|
3210
1185
|
};
|
|
3211
|
-
this.#
|
|
1186
|
+
this.#recordEntry(entry);
|
|
3212
1187
|
return entry.id;
|
|
3213
1188
|
}
|
|
3214
1189
|
|
|
3215
|
-
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
|
|
3216
1190
|
appendCustomEntry(customType: string, data?: unknown): string {
|
|
3217
|
-
const entry: CustomEntry = {
|
|
3218
|
-
|
|
3219
|
-
customType,
|
|
3220
|
-
data,
|
|
3221
|
-
id: generateId(this.#byId),
|
|
3222
|
-
parentId: this.#leafId,
|
|
3223
|
-
timestamp: new Date().toISOString(),
|
|
3224
|
-
};
|
|
3225
|
-
this.#appendEntry(entry);
|
|
1191
|
+
const entry: CustomEntry = { type: "custom", customType, data, ...this.#freshEntryFields() };
|
|
1192
|
+
this.#recordEntry(entry);
|
|
3226
1193
|
return entry.id;
|
|
3227
1194
|
}
|
|
3228
1195
|
|
|
3229
1196
|
/**
|
|
3230
|
-
* Rewrite the session file after in-place entry updates.
|
|
3231
|
-
* Use sparingly
|
|
1197
|
+
* Rewrite the session file after in-place entry updates (e.g. pruning old tool
|
|
1198
|
+
* outputs). Use sparingly.
|
|
3232
1199
|
*/
|
|
3233
1200
|
async rewriteEntries(): Promise<void> {
|
|
3234
|
-
if (!this
|
|
3235
|
-
await this.#
|
|
1201
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
1202
|
+
await this.#rewriteAtomically();
|
|
3236
1203
|
}
|
|
3237
1204
|
|
|
3238
1205
|
/**
|
|
@@ -3242,7 +1209,6 @@ export class SessionManager {
|
|
|
3242
1209
|
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
3243
1210
|
* @param details Optional extension-specific metadata (not sent to LLM)
|
|
3244
1211
|
* @param attribution Who initiated this message for billing/attribution semantics
|
|
3245
|
-
* @returns Entry id
|
|
3246
1212
|
*/
|
|
3247
1213
|
appendCustomMessageEntry<T = unknown>(
|
|
3248
1214
|
customType: string,
|
|
@@ -3256,402 +1222,244 @@ export class SessionManager {
|
|
|
3256
1222
|
customType,
|
|
3257
1223
|
content,
|
|
3258
1224
|
display,
|
|
3259
|
-
// Drop AgentSession-internal transient fields
|
|
3260
|
-
// `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
|
|
3261
|
-
// chokepoint covers every CustomMessage write path.
|
|
1225
|
+
// Drop AgentSession-internal transient fields before disk persistence.
|
|
3262
1226
|
details: stripInternalDetailsFields(details),
|
|
3263
1227
|
attribution,
|
|
3264
|
-
|
|
3265
|
-
parentId: this.#leafId,
|
|
3266
|
-
timestamp: new Date().toISOString(),
|
|
1228
|
+
...this.#freshEntryFields(),
|
|
3267
1229
|
};
|
|
3268
|
-
this.#
|
|
1230
|
+
this.#recordEntry(entry);
|
|
3269
1231
|
return entry.id;
|
|
3270
1232
|
}
|
|
3271
1233
|
|
|
3272
|
-
// =========================================================================
|
|
3273
|
-
// TTSR (Time Traveling Stream Rules)
|
|
3274
|
-
// =========================================================================
|
|
3275
|
-
|
|
3276
1234
|
/**
|
|
3277
1235
|
* Append an MCP tool selection entry recording the discovery-selected MCP tools.
|
|
3278
|
-
* @param selectedToolNames MCP tool names selected for this branch
|
|
3279
|
-
* @returns Entry id
|
|
3280
1236
|
*/
|
|
3281
1237
|
appendMCPToolSelection(selectedToolNames: string[]): string {
|
|
3282
1238
|
const entry: MCPToolSelectionEntry = {
|
|
3283
1239
|
type: "mcp_tool_selection",
|
|
3284
|
-
|
|
3285
|
-
parentId: this.#leafId,
|
|
3286
|
-
timestamp: new Date().toISOString(),
|
|
1240
|
+
...this.#freshEntryFields(),
|
|
3287
1241
|
selectedToolNames: [...selectedToolNames],
|
|
3288
1242
|
};
|
|
3289
|
-
this.#
|
|
1243
|
+
this.#recordEntry(entry);
|
|
3290
1244
|
return entry.id;
|
|
3291
1245
|
}
|
|
3292
1246
|
|
|
3293
|
-
/**
|
|
3294
|
-
* Append a TTSR injection entry recording which rules were injected.
|
|
3295
|
-
* @param ruleNames Names of rules that were injected
|
|
3296
|
-
* @returns Entry id
|
|
3297
|
-
*/
|
|
1247
|
+
/** Append a TTSR injection entry recording which rules were injected. */
|
|
3298
1248
|
appendTtsrInjection(ruleNames: string[]): string {
|
|
3299
1249
|
const entry: TtsrInjectionEntry = {
|
|
3300
1250
|
type: "ttsr_injection",
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
timestamp: new Date().toISOString(),
|
|
3304
|
-
injectedRules: ruleNames,
|
|
1251
|
+
...this.#freshEntryFields(),
|
|
1252
|
+
injectedRules: [...ruleNames],
|
|
3305
1253
|
};
|
|
3306
|
-
this.#
|
|
1254
|
+
this.#recordEntry(entry);
|
|
3307
1255
|
return entry.id;
|
|
3308
1256
|
}
|
|
3309
1257
|
|
|
3310
|
-
/**
|
|
3311
|
-
* Get all unique TTSR rule names that have been injected in the current branch.
|
|
3312
|
-
* Scans from root to current leaf for ttsr_injection entries.
|
|
3313
|
-
*/
|
|
1258
|
+
/** All unique TTSR rule names injected on the current branch (root → leaf). */
|
|
3314
1259
|
getInjectedTtsrRules(): string[] {
|
|
3315
|
-
const
|
|
3316
|
-
const
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
for (const name of entry.injectedRules) {
|
|
3320
|
-
ruleNames.add(name);
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
1260
|
+
const names = new Set<string>();
|
|
1261
|
+
for (const entry of this.getBranch()) {
|
|
1262
|
+
if (entry.type !== "ttsr_injection") continue;
|
|
1263
|
+
for (const name of entry.injectedRules) names.add(name);
|
|
3323
1264
|
}
|
|
3324
|
-
return
|
|
1265
|
+
return [...names];
|
|
3325
1266
|
}
|
|
3326
1267
|
|
|
3327
|
-
// =========================================================================
|
|
3328
|
-
// Tree Traversal
|
|
3329
|
-
// =========================================================================
|
|
3330
|
-
|
|
3331
1268
|
getLeafId(): string | null {
|
|
3332
|
-
return this.#leafId;
|
|
1269
|
+
return this.#index.leafId();
|
|
3333
1270
|
}
|
|
3334
1271
|
|
|
3335
1272
|
getLeafEntry(): SessionEntry | undefined {
|
|
3336
|
-
return this.#
|
|
1273
|
+
return this.#index.leafEntry();
|
|
3337
1274
|
}
|
|
3338
1275
|
|
|
3339
1276
|
/**
|
|
3340
|
-
*
|
|
3341
|
-
*
|
|
1277
|
+
* The most recent model role on the current branch, or undefined when no
|
|
1278
|
+
* model change has been recorded.
|
|
3342
1279
|
*/
|
|
3343
1280
|
getLastModelChangeRole(): string | undefined {
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
}
|
|
3349
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
1281
|
+
const branch = this.getBranch();
|
|
1282
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1283
|
+
const entry = branch[index];
|
|
1284
|
+
if (entry.type === "model_change") return entry.role ?? "default";
|
|
3350
1285
|
}
|
|
3351
1286
|
return undefined;
|
|
3352
1287
|
}
|
|
3353
1288
|
|
|
3354
1289
|
getEntry(id: string): SessionEntry | undefined {
|
|
3355
|
-
return this.#
|
|
1290
|
+
return this.#index.get(id);
|
|
3356
1291
|
}
|
|
3357
1292
|
|
|
3358
|
-
/**
|
|
3359
|
-
* Get all direct children of an entry.
|
|
3360
|
-
*/
|
|
1293
|
+
/** All direct children of an entry. */
|
|
3361
1294
|
getChildren(parentId: string): SessionEntry[] {
|
|
3362
|
-
|
|
3363
|
-
for (const entry of this.#byId.values()) {
|
|
3364
|
-
if (entry.parentId === parentId) {
|
|
3365
|
-
children.push(entry);
|
|
3366
|
-
}
|
|
3367
|
-
}
|
|
3368
|
-
return children;
|
|
1295
|
+
return this.#index.childrenOf(parentId);
|
|
3369
1296
|
}
|
|
3370
1297
|
|
|
3371
|
-
/**
|
|
3372
|
-
* Get the label for an entry, if any.
|
|
3373
|
-
*/
|
|
3374
1298
|
getLabel(id: string): string | undefined {
|
|
3375
|
-
return this.#
|
|
1299
|
+
return this.#index.labelFor(id);
|
|
3376
1300
|
}
|
|
3377
1301
|
|
|
3378
1302
|
/**
|
|
3379
|
-
* Set or clear a label on an entry.
|
|
3380
|
-
* Labels are user-defined markers for bookmarking/navigation.
|
|
3381
|
-
* Pass undefined or empty string to clear the label.
|
|
1303
|
+
* Set or clear a label on an entry. Pass undefined/empty to clear.
|
|
3382
1304
|
*/
|
|
3383
1305
|
appendLabelChange(targetId: string, label: string | undefined): string {
|
|
3384
|
-
if (!this.#
|
|
3385
|
-
|
|
3386
|
-
}
|
|
3387
|
-
|
|
3388
|
-
type: "label",
|
|
3389
|
-
id: generateId(this.#byId),
|
|
3390
|
-
parentId: this.#leafId,
|
|
3391
|
-
timestamp: new Date().toISOString(),
|
|
3392
|
-
targetId,
|
|
3393
|
-
label,
|
|
3394
|
-
};
|
|
3395
|
-
this.#appendEntry(entry);
|
|
3396
|
-
if (label) {
|
|
3397
|
-
this.#labelsById.set(targetId, label);
|
|
3398
|
-
} else {
|
|
3399
|
-
this.#labelsById.delete(targetId);
|
|
3400
|
-
}
|
|
1306
|
+
if (!this.#index.has(targetId)) throw new Error(`Entry ${targetId} not found`);
|
|
1307
|
+
|
|
1308
|
+
const entry: LabelEntry = { type: "label", ...this.#freshEntryFields(), targetId, label };
|
|
1309
|
+
this.#recordEntry(entry);
|
|
3401
1310
|
return entry.id;
|
|
3402
1311
|
}
|
|
3403
1312
|
|
|
3404
1313
|
/**
|
|
3405
|
-
* Walk from entry to root, returning
|
|
3406
|
-
*
|
|
3407
|
-
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
1314
|
+
* Walk from an entry to root, returning entries in path order. Includes all
|
|
1315
|
+
* entry types; use buildSessionContext() for the resolved LLM messages.
|
|
3408
1316
|
*/
|
|
3409
1317
|
getBranch(fromId?: string): SessionEntry[] {
|
|
3410
|
-
|
|
3411
|
-
const startId = fromId ?? this.#leafId;
|
|
3412
|
-
let current = startId ? this.#byId.get(startId) : undefined;
|
|
3413
|
-
while (current) {
|
|
3414
|
-
path.unshift(current);
|
|
3415
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
3416
|
-
}
|
|
3417
|
-
return path;
|
|
1318
|
+
return this.#index.pathTo(fromId ?? this.#index.leafId());
|
|
3418
1319
|
}
|
|
3419
1320
|
|
|
3420
1321
|
/**
|
|
3421
|
-
* Build the session context (
|
|
3422
|
-
*
|
|
3423
|
-
* Uses tree traversal from current leaf.
|
|
1322
|
+
* Build the session context (LLM messages), or — with `{ transcript: true }` —
|
|
1323
|
+
* the full-history display transcript, from the current leaf path.
|
|
3424
1324
|
*/
|
|
3425
1325
|
buildSessionContext(options?: BuildSessionContextOptions): SessionContext {
|
|
3426
|
-
return buildSessionContext(this
|
|
1326
|
+
return buildSessionContext(this.#entries, this.#index.leafId(), this.#index.entriesById(), options);
|
|
3427
1327
|
}
|
|
3428
1328
|
|
|
3429
|
-
/** Strip stale OpenAI Responses assistant replay metadata from loaded
|
|
1329
|
+
/** Strip stale OpenAI Responses assistant replay metadata from loaded entries. */
|
|
3430
1330
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
3431
|
-
let
|
|
3432
|
-
for (const entry of this.#
|
|
3433
|
-
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
3434
|
-
continue;
|
|
3435
|
-
}
|
|
1331
|
+
let changed = false;
|
|
1332
|
+
for (const entry of this.#entries) {
|
|
1333
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
3436
1334
|
|
|
3437
|
-
const
|
|
3438
|
-
if (
|
|
3439
|
-
continue;
|
|
3440
|
-
}
|
|
1335
|
+
const sanitized = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
|
|
1336
|
+
if (sanitized === entry.message) continue;
|
|
3441
1337
|
|
|
3442
|
-
entry.message =
|
|
3443
|
-
|
|
1338
|
+
entry.message = sanitized;
|
|
1339
|
+
changed = true;
|
|
3444
1340
|
}
|
|
3445
1341
|
|
|
3446
|
-
return
|
|
1342
|
+
return changed;
|
|
3447
1343
|
}
|
|
3448
1344
|
|
|
3449
|
-
/**
|
|
3450
|
-
* Get session header.
|
|
3451
|
-
*/
|
|
3452
1345
|
getHeader(): SessionHeader | null {
|
|
3453
|
-
|
|
3454
|
-
return h ? (h as SessionHeader) : null;
|
|
1346
|
+
return this.#header;
|
|
3455
1347
|
}
|
|
3456
1348
|
|
|
3457
|
-
/**
|
|
3458
|
-
* Get all session entries (excludes header). Returns a shallow copy.
|
|
3459
|
-
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
3460
|
-
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
3461
|
-
*/
|
|
1349
|
+
/** All session entries (excludes header). Returns a shallow copy. */
|
|
3462
1350
|
getEntries(): SessionEntry[] {
|
|
3463
|
-
return this.#
|
|
1351
|
+
return [...this.#entries];
|
|
3464
1352
|
}
|
|
3465
1353
|
|
|
3466
1354
|
/**
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
3469
|
-
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
1355
|
+
* The session as a tree. A well-formed session has exactly one root; orphaned
|
|
1356
|
+
* entries (broken parent chain) are returned as roots too.
|
|
3470
1357
|
*/
|
|
3471
1358
|
getTree(): SessionTreeNode[] {
|
|
3472
|
-
|
|
3473
|
-
const nodeMap = new Map<string, SessionTreeNode>();
|
|
3474
|
-
const roots: SessionTreeNode[] = [];
|
|
3475
|
-
|
|
3476
|
-
// Create nodes with resolved labels
|
|
3477
|
-
for (const entry of entries) {
|
|
3478
|
-
const label = this.#labelsById.get(entry.id);
|
|
3479
|
-
nodeMap.set(entry.id, { entry, children: [], label });
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
// Build tree
|
|
3483
|
-
for (const entry of entries) {
|
|
3484
|
-
const node = nodeMap.get(entry.id)!;
|
|
3485
|
-
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
3486
|
-
roots.push(node);
|
|
3487
|
-
} else {
|
|
3488
|
-
const parent = nodeMap.get(entry.parentId);
|
|
3489
|
-
if (parent) {
|
|
3490
|
-
parent.children.push(node);
|
|
3491
|
-
} else {
|
|
3492
|
-
// Orphan - treat as root
|
|
3493
|
-
roots.push(node);
|
|
3494
|
-
}
|
|
3495
|
-
}
|
|
3496
|
-
}
|
|
3497
|
-
|
|
3498
|
-
// Sort children by timestamp (oldest first, newest at bottom)
|
|
3499
|
-
// Use iterative approach to avoid stack overflow on deep trees
|
|
3500
|
-
const stack: SessionTreeNode[] = [...roots];
|
|
3501
|
-
while (stack.length > 0) {
|
|
3502
|
-
const node = stack.pop()!;
|
|
3503
|
-
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
3504
|
-
stack.push(...node.children);
|
|
3505
|
-
}
|
|
3506
|
-
|
|
3507
|
-
return roots;
|
|
1359
|
+
return this.#index.tree(this.#entries);
|
|
3508
1360
|
}
|
|
3509
1361
|
|
|
3510
|
-
// =========================================================================
|
|
3511
|
-
// Branching
|
|
3512
|
-
// =========================================================================
|
|
3513
|
-
|
|
3514
1362
|
/**
|
|
3515
|
-
*
|
|
3516
|
-
*
|
|
3517
|
-
* will create a child of that entry, forming a new branch. Existing entries
|
|
3518
|
-
* are not modified or deleted.
|
|
1363
|
+
* Move the leaf to an earlier entry so the next append forms a new branch.
|
|
1364
|
+
* Existing entries are never modified or deleted.
|
|
3519
1365
|
*/
|
|
3520
1366
|
branch(branchFromId: string): void {
|
|
3521
|
-
if (!this.#
|
|
3522
|
-
|
|
3523
|
-
}
|
|
3524
|
-
this.#leafId = branchFromId;
|
|
1367
|
+
if (!this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1368
|
+
this.#index.setLeaf(branchFromId);
|
|
3525
1369
|
}
|
|
3526
1370
|
|
|
3527
|
-
/**
|
|
3528
|
-
* Reset the leaf pointer to null (before any entries).
|
|
3529
|
-
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
3530
|
-
* Use this when navigating to re-edit the first user message.
|
|
3531
|
-
*/
|
|
1371
|
+
/** Reset the leaf to null so the next append creates a new root entry. */
|
|
3532
1372
|
resetLeaf(): void {
|
|
3533
|
-
this.#
|
|
1373
|
+
this.#index.setLeaf(null);
|
|
3534
1374
|
}
|
|
3535
1375
|
|
|
3536
|
-
/**
|
|
3537
|
-
* Start a new branch with a summary of the abandoned path.
|
|
3538
|
-
* Same as branch(), but also appends a branch_summary entry that captures
|
|
3539
|
-
* context from the abandoned conversation path.
|
|
3540
|
-
*/
|
|
1376
|
+
/** Like branch(), but also records a branch_summary of the abandoned path. */
|
|
3541
1377
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
3542
|
-
if (branchFromId !== null && !this.#
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
this.#leafId = branchFromId;
|
|
1378
|
+
if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1379
|
+
|
|
1380
|
+
this.#index.setLeaf(branchFromId);
|
|
3546
1381
|
const entry: BranchSummaryEntry = {
|
|
3547
1382
|
type: "branch_summary",
|
|
3548
|
-
id: generateId(this.#
|
|
1383
|
+
id: generateId(this.#index),
|
|
3549
1384
|
parentId: branchFromId,
|
|
3550
|
-
timestamp:
|
|
1385
|
+
timestamp: nowIso(),
|
|
3551
1386
|
fromId: branchFromId ?? "root",
|
|
3552
1387
|
summary,
|
|
3553
1388
|
details,
|
|
3554
1389
|
fromExtension,
|
|
3555
1390
|
};
|
|
3556
|
-
this.#
|
|
1391
|
+
this.#recordEntry(entry);
|
|
3557
1392
|
return entry.id;
|
|
3558
1393
|
}
|
|
3559
1394
|
|
|
3560
1395
|
/**
|
|
3561
|
-
* Create a new session file containing only the path from root to
|
|
3562
|
-
*
|
|
3563
|
-
* Returns the new session file path, or undefined if not persisting.
|
|
1396
|
+
* Create a new session file containing only the path from root to `leafId`.
|
|
1397
|
+
* Returns the new file path, or undefined when not persisting.
|
|
3564
1398
|
*/
|
|
3565
1399
|
createBranchedSession(leafId: string): string | undefined {
|
|
3566
|
-
const
|
|
1400
|
+
const sourceSessionFile = this.#sessionFile;
|
|
3567
1401
|
const branchPath = this.getBranch(leafId);
|
|
3568
|
-
if (branchPath.length === 0) {
|
|
3569
|
-
throw new Error(`Entry ${leafId} not found`);
|
|
3570
|
-
}
|
|
1402
|
+
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
3571
1403
|
|
|
3572
|
-
//
|
|
3573
|
-
const
|
|
3574
|
-
|
|
3575
|
-
const
|
|
3576
|
-
const
|
|
3577
|
-
|
|
3578
|
-
|
|
1404
|
+
// Drop label entries from the path; recreate them fresh from the resolved map.
|
|
1405
|
+
const entriesToKeep = branchPath.filter(entry => entry.type !== "label");
|
|
1406
|
+
const keptIds = new Set(entriesToKeep.map(entry => entry.id));
|
|
1407
|
+
const labelsToCarry: Array<{ targetId: string; label: string }> = [];
|
|
1408
|
+
for (const [targetId, label] of this.#index.labelsInEffect()) {
|
|
1409
|
+
if (keptIds.has(targetId)) labelsToCarry.push({ targetId, label });
|
|
1410
|
+
}
|
|
3579
1411
|
|
|
1412
|
+
const timestamp = nowIso();
|
|
1413
|
+
const newSessionId = mintSessionId();
|
|
1414
|
+
const newSessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${newSessionId}.jsonl`);
|
|
3580
1415
|
const header: SessionHeader = {
|
|
3581
1416
|
type: "session",
|
|
3582
1417
|
version: CURRENT_SESSION_VERSION,
|
|
3583
1418
|
id: newSessionId,
|
|
3584
1419
|
timestamp,
|
|
3585
|
-
cwd: this
|
|
3586
|
-
parentSession: this
|
|
1420
|
+
cwd: this.#cwd,
|
|
1421
|
+
parentSession: this.#persist ? sourceSessionFile : undefined,
|
|
3587
1422
|
};
|
|
3588
1423
|
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
const
|
|
3592
|
-
for (const [targetId, label] of this.#labelsById) {
|
|
3593
|
-
if (pathEntryIds.has(targetId)) {
|
|
3594
|
-
labelsToWrite.push({ targetId, label });
|
|
3595
|
-
}
|
|
3596
|
-
}
|
|
3597
|
-
|
|
3598
|
-
if (this.persist) {
|
|
3599
|
-
const lines: string[] = [];
|
|
3600
|
-
lines.push(JSON.stringify(header));
|
|
3601
|
-
for (const entry of pathWithoutLabels) {
|
|
3602
|
-
lines.push(JSON.stringify(entry));
|
|
3603
|
-
}
|
|
3604
|
-
// Write fresh label entries at the end
|
|
3605
|
-
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3606
|
-
let parentId = lastEntryId;
|
|
3607
|
-
const labelEntries: LabelEntry[] = [];
|
|
3608
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
3609
|
-
const labelEntry: LabelEntry = {
|
|
3610
|
-
type: "label",
|
|
3611
|
-
id: generateId(new Set(pathEntryIds)),
|
|
3612
|
-
parentId,
|
|
3613
|
-
timestamp: new Date().toISOString(),
|
|
3614
|
-
targetId,
|
|
3615
|
-
label,
|
|
3616
|
-
};
|
|
3617
|
-
lines.push(JSON.stringify(labelEntry));
|
|
3618
|
-
pathEntryIds.add(labelEntry.id);
|
|
3619
|
-
labelEntries.push(labelEntry);
|
|
3620
|
-
parentId = labelEntry.id;
|
|
3621
|
-
}
|
|
3622
|
-
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
3623
|
-
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
3624
|
-
this.#sessionId = newSessionId;
|
|
3625
|
-
this.#sessionFile = newSessionFile;
|
|
3626
|
-
this.#flushed = true;
|
|
3627
|
-
this.#buildIndex();
|
|
3628
|
-
return newSessionFile;
|
|
3629
|
-
}
|
|
3630
|
-
|
|
3631
|
-
// In-memory mode: replace current session with the path + labels
|
|
3632
|
-
const labelEntries: LabelEntry[] = [];
|
|
3633
|
-
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3634
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
1424
|
+
const labels: LabelEntry[] = [];
|
|
1425
|
+
let parentId = entriesToKeep[entriesToKeep.length - 1]?.id ?? null;
|
|
1426
|
+
for (const carried of labelsToCarry) {
|
|
3635
1427
|
const labelEntry: LabelEntry = {
|
|
3636
1428
|
type: "label",
|
|
3637
|
-
id: generateId(new Set([...
|
|
1429
|
+
id: generateId(new Set([...keptIds, ...labels.map(entry => entry.id)])),
|
|
3638
1430
|
parentId,
|
|
3639
|
-
timestamp:
|
|
3640
|
-
targetId,
|
|
3641
|
-
label,
|
|
1431
|
+
timestamp: nowIso(),
|
|
1432
|
+
targetId: carried.targetId,
|
|
1433
|
+
label: carried.label,
|
|
3642
1434
|
};
|
|
3643
|
-
|
|
1435
|
+
labels.push(labelEntry);
|
|
3644
1436
|
parentId = labelEntry.id;
|
|
3645
1437
|
}
|
|
3646
|
-
|
|
1438
|
+
|
|
1439
|
+
this.#header = header;
|
|
1440
|
+
this.#entries = [...entriesToKeep, ...labels];
|
|
3647
1441
|
this.#sessionId = newSessionId;
|
|
3648
|
-
this.#
|
|
3649
|
-
|
|
1442
|
+
this.#sessionName = header.title;
|
|
1443
|
+
this.#titleSource = header.titleSource;
|
|
1444
|
+
this.#index.rebuild(this.#entries);
|
|
1445
|
+
this.#artifactManager = null;
|
|
1446
|
+
this.#artifactManagerSessionFile = null;
|
|
1447
|
+
this.#forceFileCreation = this.#persist;
|
|
1448
|
+
|
|
1449
|
+
if (!this.#persist) {
|
|
1450
|
+
this.#sessionFile = undefined;
|
|
1451
|
+
this.#fileIsCurrent = false;
|
|
1452
|
+
this.#rewriteRequired = false;
|
|
1453
|
+
return undefined;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
this.#sessionFile = newSessionFile;
|
|
1457
|
+
this.#rewriteSynchronously();
|
|
1458
|
+
this.#rememberBreadcrumb(this.#cwd, newSessionFile);
|
|
1459
|
+
return newSessionFile;
|
|
3650
1460
|
}
|
|
3651
1461
|
|
|
3652
|
-
/**
|
|
3653
|
-
* Resolve the canonical default session directory for a cwd.
|
|
3654
|
-
*/
|
|
1462
|
+
/** Resolve the canonical default session directory for a cwd. */
|
|
3655
1463
|
static getDefaultSessionDir(
|
|
3656
1464
|
cwd: string,
|
|
3657
1465
|
agentDir?: string,
|
|
@@ -3662,19 +1470,19 @@ export class SessionManager {
|
|
|
3662
1470
|
|
|
3663
1471
|
/**
|
|
3664
1472
|
* Create a new session.
|
|
3665
|
-
* @param cwd Working directory (stored in session header)
|
|
3666
|
-
* @param sessionDir Optional session directory
|
|
1473
|
+
* @param cwd Working directory (stored in the session header)
|
|
1474
|
+
* @param sessionDir Optional session directory; defaults to the cwd-derived dir.
|
|
3667
1475
|
*/
|
|
3668
1476
|
static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
|
|
3669
1477
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3670
1478
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3671
|
-
manager.#
|
|
1479
|
+
manager.#resetToNewSession();
|
|
3672
1480
|
return manager;
|
|
3673
1481
|
}
|
|
3674
1482
|
|
|
3675
1483
|
/**
|
|
3676
|
-
* Fork a session into the current project directory
|
|
3677
|
-
*
|
|
1484
|
+
* Fork a session into the current project directory: copy history from another
|
|
1485
|
+
* session file while creating a fresh session file in this sessionDir.
|
|
3678
1486
|
*/
|
|
3679
1487
|
static async forkFrom(
|
|
3680
1488
|
sourcePath: string,
|
|
@@ -3686,123 +1494,119 @@ export class SessionManager {
|
|
|
3686
1494
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3687
1495
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3688
1496
|
manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
const
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
manager.#
|
|
3699
|
-
manager.#sessionName =
|
|
3700
|
-
manager.#titleSource =
|
|
1497
|
+
|
|
1498
|
+
const sourceEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
1499
|
+
migrateToCurrentVersion(sourceEntries);
|
|
1500
|
+
await resolveBlobRefsInEntries(sourceEntries, manager.#blobs);
|
|
1501
|
+
|
|
1502
|
+
const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
1503
|
+
const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
1504
|
+
manager.#resetToNewSession({ parentSession: sourceHeader?.id });
|
|
1505
|
+
manager.#header.title = sourceHeader?.title;
|
|
1506
|
+
manager.#header.titleSource = sourceHeader?.titleSource;
|
|
1507
|
+
manager.#sessionName = manager.#header.title;
|
|
1508
|
+
manager.#titleSource = manager.#header.titleSource;
|
|
1509
|
+
manager.#entries = history;
|
|
1510
|
+
manager.#index.rebuild(history);
|
|
3701
1511
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
3702
|
-
manager.#
|
|
3703
|
-
await manager.#
|
|
1512
|
+
manager.#forceFileCreation = true;
|
|
1513
|
+
await manager.#rewriteAtomically();
|
|
3704
1514
|
return manager;
|
|
3705
1515
|
}
|
|
3706
1516
|
|
|
3707
1517
|
/**
|
|
3708
1518
|
* Open a specific session file.
|
|
3709
|
-
* @param
|
|
3710
|
-
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
|
1519
|
+
* @param sessionDir Optional dir for /new or /branch; defaults to the file's parent.
|
|
3711
1520
|
*/
|
|
3712
1521
|
static async open(
|
|
3713
1522
|
filePath: string,
|
|
3714
1523
|
sessionDir?: string,
|
|
3715
1524
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3716
1525
|
): Promise<SessionManager> {
|
|
3717
|
-
|
|
3718
|
-
const
|
|
3719
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1526
|
+
const loaded = await loadEntriesFromFile(filePath, storage);
|
|
1527
|
+
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
3720
1528
|
const cwd = header?.cwd ?? getProjectDir();
|
|
3721
|
-
|
|
3722
|
-
const dir = sessionDir ?? path.resolve(filePath, "..");
|
|
1529
|
+
const dir = sessionDir ?? path.dirname(path.resolve(filePath));
|
|
3723
1530
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3724
|
-
await manager
|
|
1531
|
+
await manager.setSessionFile(filePath);
|
|
3725
1532
|
return manager;
|
|
3726
1533
|
}
|
|
3727
1534
|
|
|
3728
|
-
/**
|
|
3729
|
-
* Continue the most recent session, or create new if none.
|
|
3730
|
-
* @param cwd Working directory
|
|
3731
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
|
|
3732
|
-
*/
|
|
1535
|
+
/** Continue the most recent session, or create a new one if none exists. */
|
|
3733
1536
|
static async continueRecent(
|
|
3734
1537
|
cwd: string,
|
|
3735
1538
|
sessionDir?: string,
|
|
3736
1539
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3737
1540
|
): Promise<SessionManager> {
|
|
3738
1541
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3739
|
-
// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
|
|
3740
|
-
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
3741
|
-
const breadcrumbCwd = breadcrumb ? path.resolve(breadcrumb.cwd) : undefined;
|
|
3742
1542
|
const resolvedCwd = path.resolve(cwd);
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
1543
|
+
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
1544
|
+
let chosenSession: string | null | undefined;
|
|
1545
|
+
|
|
1546
|
+
if (breadcrumb) {
|
|
1547
|
+
const breadcrumbCwd = path.resolve(breadcrumb.cwd);
|
|
1548
|
+
if (breadcrumbCwd === resolvedCwd) {
|
|
1549
|
+
chosenSession = breadcrumb.sessionFile;
|
|
1550
|
+
} else {
|
|
1551
|
+
// The terminal's last session started in a different cwd. If that cwd is
|
|
1552
|
+
// gone (worktree move/rename) and this location has no sessions of its
|
|
1553
|
+
// own, re-root the moved session here instead of starting fresh. When an
|
|
1554
|
+
// explicit sessionDir is reused across the move, the stale breadcrumb file
|
|
1555
|
+
// may be the newest entry there; prefer a genuine current-cwd session.
|
|
1556
|
+
let newestInTargetDir = await findMostRecentSession(dir, storage);
|
|
1557
|
+
const breadcrumbFile = path.resolve(breadcrumb.sessionFile);
|
|
1558
|
+
const breadcrumbCwdMissing = !fs.existsSync(breadcrumbCwd);
|
|
1559
|
+
const newestIsBreadcrumb = newestInTargetDir ? path.resolve(newestInTargetDir) === breadcrumbFile : false;
|
|
1560
|
+
let currentProjectAlreadyHasSession = false;
|
|
1561
|
+
|
|
1562
|
+
if (breadcrumbCwdMissing && newestIsBreadcrumb) {
|
|
1563
|
+
const localSession = (await SessionManager.list(cwd, dir, storage)).find(
|
|
1564
|
+
session =>
|
|
1565
|
+
path.resolve(session.path) !== breadcrumbFile &&
|
|
1566
|
+
session.cwd &&
|
|
1567
|
+
path.resolve(session.cwd) === resolvedCwd,
|
|
1568
|
+
);
|
|
1569
|
+
if (localSession) {
|
|
1570
|
+
newestInTargetDir = localSession.path;
|
|
1571
|
+
currentProjectAlreadyHasSession = true;
|
|
1572
|
+
}
|
|
3771
1573
|
}
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
1574
|
+
|
|
1575
|
+
const looksLikeMovedProject =
|
|
1576
|
+
breadcrumbCwdMissing &&
|
|
1577
|
+
(newestInTargetDir === null || (newestIsBreadcrumb && !currentProjectAlreadyHasSession));
|
|
1578
|
+
if (looksLikeMovedProject) {
|
|
1579
|
+
logger.info("Re-rooting moved session", { from: breadcrumbCwd, to: resolvedCwd });
|
|
1580
|
+
const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
|
|
1581
|
+
await manager.moveTo(cwd, sessionDir);
|
|
1582
|
+
return manager;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
chosenSession = newestInTargetDir;
|
|
3779
1586
|
}
|
|
3780
1587
|
}
|
|
3781
|
-
|
|
3782
|
-
if (
|
|
1588
|
+
|
|
1589
|
+
if (chosenSession === undefined) chosenSession = await findMostRecentSession(dir, storage);
|
|
1590
|
+
|
|
3783
1591
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3784
|
-
if (
|
|
3785
|
-
|
|
3786
|
-
} else {
|
|
3787
|
-
manager.#initNewSession();
|
|
3788
|
-
}
|
|
1592
|
+
if (chosenSession) await manager.setSessionFile(chosenSession);
|
|
1593
|
+
else manager.#resetToNewSession();
|
|
3789
1594
|
return manager;
|
|
3790
1595
|
}
|
|
3791
1596
|
|
|
3792
|
-
/** Create an in-memory session (no file persistence) */
|
|
1597
|
+
/** Create an in-memory session (no file persistence). */
|
|
3793
1598
|
static inMemory(
|
|
3794
1599
|
cwd: string = getProjectDir(),
|
|
3795
1600
|
storage: SessionStorage = new MemorySessionStorage(),
|
|
3796
1601
|
): SessionManager {
|
|
3797
1602
|
const manager = new SessionManager(cwd, "", false, storage);
|
|
3798
|
-
manager.#
|
|
1603
|
+
manager.#resetToNewSession();
|
|
3799
1604
|
return manager;
|
|
3800
1605
|
}
|
|
3801
1606
|
|
|
3802
1607
|
/**
|
|
3803
|
-
* List
|
|
3804
|
-
* @param
|
|
3805
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
|
|
1608
|
+
* List sessions for a project directory.
|
|
1609
|
+
* @param sessionDir Optional dir; defaults to the cwd-derived dir.
|
|
3806
1610
|
*/
|
|
3807
1611
|
static async list(
|
|
3808
1612
|
cwd: string,
|
|
@@ -3810,27 +1614,11 @@ export class SessionManager {
|
|
|
3810
1614
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3811
1615
|
): Promise<SessionInfo[]> {
|
|
3812
1616
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3813
|
-
|
|
3814
|
-
await recoverOrphanedBackups(dir, storage);
|
|
3815
|
-
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
3816
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3817
|
-
} catch {
|
|
3818
|
-
return [];
|
|
3819
|
-
}
|
|
1617
|
+
return listSessions(dir, storage);
|
|
3820
1618
|
}
|
|
3821
1619
|
|
|
3822
|
-
/**
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
3826
|
-
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
3827
|
-
try {
|
|
3828
|
-
const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
|
|
3829
|
-
path.join(sessionsRoot, name),
|
|
3830
|
-
);
|
|
3831
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3832
|
-
} catch {
|
|
3833
|
-
return [];
|
|
3834
|
-
}
|
|
1620
|
+
/** List all sessions across all project directories. */
|
|
1621
|
+
static listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
1622
|
+
return listAllSessions(storage);
|
|
3835
1623
|
}
|
|
3836
1624
|
}
|