@sayknow-cli/coding-agent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/bin/skc.js +4 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/config/settings-schema.d.ts +39 -2
  12. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  13. package/dist/types/defaults/skc-defaults.d.ts +2 -13
  14. package/dist/types/extensibility/shared-events.d.ts +1 -0
  15. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  16. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  17. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  18. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  20. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  21. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  22. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  23. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  24. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  25. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  26. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  27. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  28. package/dist/types/lsp/types.d.ts +2 -0
  29. package/dist/types/main.d.ts +2 -0
  30. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  31. package/dist/types/modes/components/model-selector.d.ts +8 -0
  32. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  33. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  34. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  35. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  36. package/dist/types/notifications/config.d.ts +9 -1
  37. package/dist/types/notifications/engine.d.ts +59 -0
  38. package/dist/types/notifications/html-format.d.ts +11 -0
  39. package/dist/types/notifications/index.d.ts +149 -1
  40. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  41. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  42. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  43. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  44. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  45. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  46. package/dist/types/notifications/recent-activity.d.ts +35 -0
  47. package/dist/types/notifications/telegram-daemon.d.ts +133 -16
  48. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  49. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  50. package/dist/types/notifications/threaded-render.d.ts +6 -1
  51. package/dist/types/notifications/topic-registry.d.ts +12 -9
  52. package/dist/types/runtime-mcp/types.d.ts +7 -0
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +16 -4
  55. package/dist/types/session/blob-store.d.ts +25 -0
  56. package/dist/types/session/session-manager.d.ts +57 -0
  57. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  58. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  59. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  60. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  61. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  62. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  63. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  64. package/dist/types/system-prompt.d.ts +2 -0
  65. package/dist/types/task/executor.d.ts +9 -1
  66. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  67. package/dist/types/tools/fetch.d.ts +23 -0
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/telegram-send.d.ts +32 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/bridge.d.ts +103 -0
  72. package/dist/types/web/insane/url-guard.d.ts +25 -0
  73. package/dist/types/web/scrapers/types.d.ts +5 -0
  74. package/dist/types/web/scrapers/utils.d.ts +7 -1
  75. package/dist/types/web/search/provider.d.ts +18 -1
  76. package/dist/types/web/search/providers/insane.d.ts +53 -0
  77. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  78. package/dist/types/web/search/types.d.ts +12 -4
  79. package/package.json +14 -10
  80. package/scripts/g004-tmux-smoke.ts +100 -0
  81. package/scripts/g005-daemon-smoke.ts +180 -0
  82. package/scripts/g011-daemon-path-smoke.ts +153 -0
  83. package/scripts/verify-insane-vendor.ts +132 -0
  84. package/src/cli/args.ts +1 -1
  85. package/src/cli/fast-help.ts +30 -2
  86. package/src/cli/mcp-cli.ts +272 -0
  87. package/src/cli/notify-cli.ts +152 -5
  88. package/src/cli/plugin-cli.ts +66 -3
  89. package/src/cli.ts +30 -12
  90. package/src/commands/mcp.ts +117 -0
  91. package/src/commands/plugin.ts +4 -0
  92. package/src/commands/session.ts +18 -0
  93. package/src/commands/team.ts +1 -1
  94. package/src/config/keybindings.ts +2 -2
  95. package/src/config/model-profile-activation.ts +55 -7
  96. package/src/config/settings-schema.ts +30 -1
  97. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  98. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  99. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  100. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  101. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  103. package/src/defaults/skc-defaults.ts +2 -27
  104. package/src/export/html/index.ts +2 -2
  105. package/src/extensibility/extensions/runner.ts +1 -0
  106. package/src/extensibility/shared-events.ts +1 -0
  107. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  108. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  109. package/src/extensibility/skc-plugins/index.ts +9 -0
  110. package/src/extensibility/skc-plugins/injection.ts +109 -0
  111. package/src/extensibility/skc-plugins/installer.ts +434 -0
  112. package/src/extensibility/skc-plugins/loader.ts +3 -1
  113. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  114. package/src/extensibility/skc-plugins/observability.ts +84 -0
  115. package/src/extensibility/skc-plugins/paths.ts +1 -1
  116. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  117. package/src/extensibility/skc-plugins/registry.ts +180 -0
  118. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  119. package/src/extensibility/skc-plugins/schema.ts +250 -20
  120. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  121. package/src/extensibility/skc-plugins/types.ts +199 -3
  122. package/src/extensibility/skc-plugins/validation.ts +80 -0
  123. package/src/extensibility/skills.ts +15 -0
  124. package/src/hooks/skill-state.ts +57 -0
  125. package/src/internal-urls/docs-index.generated.ts +17 -13
  126. package/src/lsp/config.ts +16 -3
  127. package/src/lsp/defaults.json +7 -0
  128. package/src/lsp/types.ts +2 -0
  129. package/src/main.ts +14 -3
  130. package/src/modes/bridge/bridge-mode.ts +11 -0
  131. package/src/modes/components/assistant-message.ts +49 -1
  132. package/src/modes/components/custom-editor.ts +2 -0
  133. package/src/modes/components/footer.ts +2 -3
  134. package/src/modes/components/hook-editor.ts +1 -1
  135. package/src/modes/components/hook-selector.ts +67 -43
  136. package/src/modes/components/model-selector.ts +56 -11
  137. package/src/modes/components/status-line/git-utils.ts +25 -0
  138. package/src/modes/components/status-line.ts +10 -11
  139. package/src/modes/components/welcome.ts +2 -3
  140. package/src/modes/controllers/event-controller.ts +15 -0
  141. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  142. package/src/modes/controllers/selector-controller.ts +53 -11
  143. package/src/modes/interactive-mode.ts +50 -3
  144. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  145. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  146. package/src/modes/theme/defaults/index.ts +2 -0
  147. package/src/modes/utils/context-usage.ts +2 -2
  148. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  149. package/src/notifications/attachment-registry.ts +23 -0
  150. package/src/notifications/chat-adapters.ts +147 -0
  151. package/src/notifications/config.ts +23 -2
  152. package/src/notifications/engine.ts +100 -0
  153. package/src/notifications/html-format.ts +38 -0
  154. package/src/notifications/index.ts +417 -45
  155. package/src/notifications/lifecycle-commands.ts +238 -0
  156. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  157. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  158. package/src/notifications/managed-daemon.ts +163 -0
  159. package/src/notifications/operator-runtime.ts +171 -0
  160. package/src/notifications/rate-limit-pool.ts +19 -0
  161. package/src/notifications/recent-activity.ts +132 -0
  162. package/src/notifications/telegram-daemon.ts +984 -242
  163. package/src/notifications/telegram-reference.ts +25 -7
  164. package/src/notifications/threaded-inbound.ts +60 -4
  165. package/src/notifications/threaded-render.ts +20 -2
  166. package/src/notifications/topic-registry.ts +23 -9
  167. package/src/prompts/agents/executor.md +2 -2
  168. package/src/runtime-mcp/transports/stdio.ts +38 -4
  169. package/src/runtime-mcp/types.ts +7 -0
  170. package/src/sdk.ts +157 -10
  171. package/src/session/agent-session.ts +248 -125
  172. package/src/session/blob-store.ts +196 -8
  173. package/src/session/session-manager.ts +762 -12
  174. package/src/skc-runtime/launch-tmux.ts +85 -22
  175. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  176. package/src/skc-runtime/psmux-detect.ts +239 -0
  177. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  178. package/src/skc-runtime/team-runtime.ts +56 -23
  179. package/src/skc-runtime/tmux-common.ts +88 -4
  180. package/src/skc-runtime/tmux-sessions.ts +111 -9
  181. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  182. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  183. package/src/skc-runtime/workflow-manifest.generated.json +29 -0
  184. package/src/skc-runtime/workflow-manifest.ts +7 -2
  185. package/src/slash-commands/builtin-registry.ts +23 -3
  186. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  187. package/src/slash-commands/helpers/parse.ts +2 -1
  188. package/src/system-prompt.ts +9 -0
  189. package/src/task/executor.ts +31 -7
  190. package/src/task/index.ts +2 -0
  191. package/src/tools/ask.ts +5 -1
  192. package/src/tools/bash.ts +9 -0
  193. package/src/tools/composer-bash-policy.ts +96 -0
  194. package/src/tools/fetch.ts +94 -1
  195. package/src/tools/index.ts +6 -1
  196. package/src/tools/telegram-send.ts +137 -0
  197. package/src/utils/changelog.ts +8 -0
  198. package/src/web/insane/bridge.ts +350 -0
  199. package/src/web/insane/url-guard.ts +159 -0
  200. package/src/web/scrapers/types.ts +143 -45
  201. package/src/web/scrapers/utils.ts +70 -19
  202. package/src/web/search/provider.ts +77 -18
  203. package/src/web/search/providers/anthropic.ts +70 -3
  204. package/src/web/search/providers/codex.ts +1 -119
  205. package/src/web/search/providers/gemini.ts +99 -0
  206. package/src/web/search/providers/insane.ts +551 -0
  207. package/src/web/search/providers/openai-compatible.ts +66 -32
  208. package/src/web/search/providers/text-citations.ts +111 -0
  209. package/src/web/search/types.ts +13 -2
  210. package/vendor/insane-search/LICENSE +21 -0
  211. package/vendor/insane-search/MANIFEST.json +24 -0
  212. package/vendor/insane-search/engine/__init__.py +23 -0
  213. package/vendor/insane-search/engine/__main__.py +128 -0
  214. package/vendor/insane-search/engine/bias_check.py +183 -0
  215. package/vendor/insane-search/engine/executor.py +254 -0
  216. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  217. package/vendor/insane-search/engine/learning.py +175 -0
  218. package/vendor/insane-search/engine/phase0.py +214 -0
  219. package/vendor/insane-search/engine/safety.py +91 -0
  220. package/vendor/insane-search/engine/templates/package.json +11 -0
  221. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  222. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  223. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  224. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  225. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  226. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  227. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  228. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  229. package/vendor/insane-search/engine/transport.py +211 -0
  230. package/vendor/insane-search/engine/url_transforms.py +98 -0
  231. package/vendor/insane-search/engine/validators.py +331 -0
  232. package/vendor/insane-search/engine/waf_detector.py +214 -0
  233. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -41,6 +41,7 @@ import {
41
41
  isBlobRef,
42
42
  isImageDataUrl,
43
43
  MemoryBlobStore,
44
+ parseBlobRef,
44
45
  ResidentBlobMissingError,
45
46
  resolveImageData,
46
47
  resolveImageDataUrl,
@@ -93,9 +94,55 @@ export interface SessionEntryBase {
93
94
  timestamp: string;
94
95
  }
95
96
 
97
+ export interface ColdSpillRef {
98
+ kind: "cold_spill";
99
+ ref: string;
100
+ encoding: "utf8" | "json";
101
+ originalChars: number;
102
+ sha256: string;
103
+ bytes: number;
104
+ }
105
+
106
+ export interface EvictedContentMarker {
107
+ evictedAt: number;
108
+ reason: "compacted_history";
109
+ compactionEntryId: string;
110
+ firstKeptEntryId: string;
111
+ payloads: Record<string, ColdSpillRef>;
112
+ }
113
+
114
+ export interface EvictCompactedContentResult {
115
+ evictedEntries: number;
116
+ hotCharsRemoved: number;
117
+ coldBlobBytes: number;
118
+ payloadRefs: number;
119
+ alreadyEvictedEntries: number;
120
+ coldSpillWriteCount: number;
121
+ coldSpillReadCount: number;
122
+ residentTextReadCount: number;
123
+ residentImageReadCount: number;
124
+ }
125
+
126
+ export interface SessionManagerObservabilityStats {
127
+ coldSpillWriteCount: number;
128
+ coldSpillReadCount: number;
129
+ residentTextReadCount: number;
130
+ residentImageReadCount: number;
131
+ publicMaterializerCallCount: number;
132
+ getEntryMaterializerCallCount: number;
133
+ getBranchMaterializerCallCount: number;
134
+ getEntriesMaterializerCallCount: number;
135
+ materializedEntriesCachePopulateCount: number;
136
+ pathOnlyContextBuildCount: number;
137
+ }
138
+
96
139
  export interface SessionMessageEntry extends SessionEntryBase {
97
140
  type: "message";
98
141
  message: AgentMessage;
142
+ /** Cold-spill marker: when present, heavy message content was moved to durable
143
+ * content-addressed blobs after compaction. The marker is entry-level session
144
+ * metadata (not a message field) so strict message types stay intact. */
145
+ evictedContent?: EvictedContentMarker;
99
146
  }
100
147
 
101
148
  export interface ThinkingLevelChangeEntry extends SessionEntryBase {
@@ -227,6 +274,8 @@ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
227
274
  display: boolean;
228
275
  /** Who initiated this message for billing/attribution semantics. */
229
276
  attribution?: MessageAttribution;
277
+ /** Cold-spill marker for custom-message content evicted after compaction. */
278
+ evictedContent?: EvictedContentMarker;
230
279
  }
231
280
 
232
281
  /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
@@ -319,6 +368,21 @@ function createSessionId(): string {
319
368
  return Bun.randomUUIDv7();
320
369
  }
321
370
 
371
+ /**
372
+ * A session id pre-allocated by the notifications lifecycle subsystem, when this
373
+ * process was spawned by `/session_create`. Gated by `SKC_LIFECYCLE_REQUEST_ID`
374
+ * so it ONLY applies to lifecycle-launched sessions (never normal launches): the
375
+ * daemon tags the tmux session, endpoint discovery, and its `/session_recent`
376
+ * id with this value, so the agent MUST adopt it as its header id or those ids
377
+ * diverge (breaking close/resume-by-id after the session is gone).
378
+ */
379
+ function lifecyclePreallocatedSessionId(): string | undefined {
380
+ if (!process.env.SKC_LIFECYCLE_REQUEST_ID) return undefined;
381
+ const id = process.env.SKC_SESSION_ID?.trim();
382
+ if (!id || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) return undefined;
383
+ return id;
384
+ }
385
+
322
386
  /** Generate a unique short ID (8 hex chars, collision-checked) */
323
387
  function generateId(byId: { has(id: string): boolean }): string {
324
388
  for (let i = 0; i < 100; i++) {
@@ -576,8 +640,11 @@ export function buildSessionContext(
576
640
 
577
641
  // Walk from leaf to root, then reverse once to avoid repeated front insertions on long branches.
578
642
  const path: SessionEntry[] = [];
643
+ const visited = new Set<string>();
579
644
  let current: SessionEntry | undefined = leaf;
580
645
  while (current) {
646
+ if (visited.has(current.id)) break;
647
+ visited.add(current.id);
581
648
  path.push(current);
582
649
  current = current.parentId ? byId.get(current.parentId) : undefined;
583
650
  }
@@ -1199,6 +1266,17 @@ function isResidentBlobSentinel(value: unknown): value is ResidentBlobSentinel {
1199
1266
  isBlobRef((value as { ref: string }).ref)
1200
1267
  );
1201
1268
  }
1269
+ function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
1270
+ if (value === null || value === undefined || typeof value !== "object") return false;
1271
+ if ((value as { [RESIDENT_BLOB_SENTINEL_KEY]?: unknown })[RESIDENT_BLOB_SENTINEL_KEY] === true) return true;
1272
+ if (seen.has(value)) return false;
1273
+ seen.add(value);
1274
+ if (Array.isArray(value)) return value.some(item => containsResidentSentinel(item, seen));
1275
+ for (const child of Object.values(value)) {
1276
+ if (containsResidentSentinel(child, seen)) return true;
1277
+ }
1278
+ return false;
1279
+ }
1202
1280
 
1203
1281
  /**
1204
1282
  * Recursively truncate large strings in an object for session persistence.
@@ -1246,6 +1324,8 @@ const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1246
1324
  "toolName",
1247
1325
  "targetId",
1248
1326
  "firstKeptEntryId",
1327
+ "encrypted_content",
1328
+ "reasoning_encrypted_content",
1249
1329
  ]);
1250
1330
 
1251
1331
  function shouldExternalizeResidentString(key: string | undefined): boolean {
@@ -1257,6 +1337,7 @@ interface ResidentBlobStores {
1257
1337
  imageStore: BlobStore;
1258
1338
  sessionId?: string;
1259
1339
  sessionFile?: string;
1340
+ onResidentBlobRead?: (kind: ResidentBlobKind) => void;
1260
1341
  }
1261
1342
 
1262
1343
  type ResidentBlobMissingPolicy = "throw" | "placeholder";
@@ -1344,6 +1425,7 @@ function materializeResidentValueSync(
1344
1425
  }
1345
1426
  }
1346
1427
  cache.set(cacheKey, resolved);
1428
+ stores.onResidentBlobRead?.(obj.kind);
1347
1429
  return resolved;
1348
1430
  }
1349
1431
  if (Array.isArray(obj)) {
@@ -1435,6 +1517,329 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
1435
1517
  return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
1436
1518
  }
1437
1519
 
1520
+ function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
1521
+ if (entry.type === "compaction") {
1522
+ const cache = new Map<string, string>();
1523
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", cache);
1524
+ const shortSummary = materializeResidentValueSync(entry.shortSummary, stores, "shortSummary", cache);
1525
+ const remote = entry.preserveData?.openaiRemoteCompaction;
1526
+ const remoteRecord = isRecord(remote) ? remote : undefined;
1527
+ const replacementHistory = remoteRecord
1528
+ ? materializeResidentValueSync(remoteRecord.replacementHistory, stores, "replacementHistory", cache)
1529
+ : undefined;
1530
+ const preserveData =
1531
+ remoteRecord && replacementHistory !== undefined && replacementHistory !== remoteRecord.replacementHistory
1532
+ ? {
1533
+ ...entry.preserveData,
1534
+ openaiRemoteCompaction: {
1535
+ ...remoteRecord,
1536
+ replacementHistory,
1537
+ },
1538
+ }
1539
+ : entry.preserveData;
1540
+ return {
1541
+ ...entry,
1542
+ summary: typeof summary === "string" ? summary : entry.summary,
1543
+ shortSummary: typeof shortSummary === "string" ? shortSummary : entry.shortSummary,
1544
+ preserveData,
1545
+ };
1546
+ }
1547
+ if (entry.type === "branch_summary") {
1548
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", new Map<string, string>());
1549
+ return typeof summary === "string" ? { ...entry, summary } : { ...entry };
1550
+ }
1551
+ return cloneSessionEntry(entry);
1552
+ }
1553
+
1554
+ const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
1555
+ const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__skcColdSpillArguments";
1556
+ const COLD_SPILL_MIN_CHARS = 1024;
1557
+
1558
+ type ColdSpillWrite = {
1559
+ path: string;
1560
+ encoding: "utf8" | "json";
1561
+ data: Buffer;
1562
+ originalChars: number;
1563
+ };
1564
+
1565
+ type ColdSpillResidentPromotion = {
1566
+ stores: ResidentBlobStores;
1567
+ };
1568
+
1569
+ function isRecord(value: unknown): value is Record<string, unknown> {
1570
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1571
+ }
1572
+ function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
1573
+ return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
1574
+ }
1575
+
1576
+ function residentBlobBytesForColdSpill(value: ResidentBlobSentinel, promotion: ColdSpillResidentPromotion): Buffer {
1577
+ const hash = parseBlobRef(value.ref);
1578
+ if (!hash)
1579
+ throw new ResidentBlobMissingError(
1580
+ value.ref,
1581
+ value.kind,
1582
+ promotion.stores.sessionId,
1583
+ promotion.stores.sessionFile,
1584
+ );
1585
+ const store = value.kind === "text" ? promotion.stores.textStore : promotion.stores.imageStore;
1586
+ const data = store.getSync(hash);
1587
+ if (!data)
1588
+ throw new ResidentBlobMissingError(hash, value.kind, promotion.stores.sessionId, promotion.stores.sessionFile);
1589
+ promotion.stores.onResidentBlobRead?.(value.kind);
1590
+ if (value.kind === "imageData") return Buffer.from(data.toString("base64"), "utf8");
1591
+ return Buffer.from(data);
1592
+ }
1593
+
1594
+ function coldSpillResidentValue(
1595
+ value: ResidentBlobSentinel,
1596
+ basePath: string,
1597
+ writes: ColdSpillWrite[],
1598
+ promotion: ColdSpillResidentPromotion,
1599
+ ): string {
1600
+ const data = residentBlobBytesForColdSpill(value, promotion);
1601
+ writes.push({ path: basePath, encoding: "utf8", data, originalChars: data.byteLength });
1602
+ return COLD_SPILL_NOTICE;
1603
+ }
1604
+
1605
+ function coldSpillTextValue(value: string, basePath: string, writes: ColdSpillWrite[]): string {
1606
+ writes.push({ path: basePath, encoding: "utf8", data: Buffer.from(value, "utf8"), originalChars: value.length });
1607
+ return COLD_SPILL_NOTICE;
1608
+ }
1609
+
1610
+ function coldSpillJsonValue(value: unknown, basePath: string, writes: ColdSpillWrite[]): Record<string, unknown> {
1611
+ const json = JSON.stringify(value);
1612
+ writes.push({ path: basePath, encoding: "json", data: Buffer.from(json, "utf8"), originalChars: json.length });
1613
+ return {
1614
+ [COLD_SPILL_ARGUMENTS_SENTINEL_KEY]: true,
1615
+ refPath: basePath,
1616
+ notice: COLD_SPILL_NOTICE,
1617
+ };
1618
+ }
1619
+
1620
+ function coldSpillSubtreeValue(
1621
+ value: unknown,
1622
+ basePath: string,
1623
+ writes: ColdSpillWrite[],
1624
+ promotion: ColdSpillResidentPromotion,
1625
+ ): unknown {
1626
+ if (isResidentBlobSentinel(value)) return coldSpillResidentValue(value, basePath, writes, promotion);
1627
+ if (isColdSpillArgumentsSentinel(value)) return value;
1628
+ if (typeof value === "string") {
1629
+ return value.length >= COLD_SPILL_MIN_CHARS ? coldSpillTextValue(value, basePath, writes) : value;
1630
+ }
1631
+ if (Array.isArray(value)) {
1632
+ if (!containsResidentSentinel(value)) {
1633
+ const json = JSON.stringify(value);
1634
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1635
+ }
1636
+ let changed = false;
1637
+ const next = value.map((child, index) => {
1638
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${index}`, writes, promotion);
1639
+ if (replaced !== child) changed = true;
1640
+ return replaced;
1641
+ });
1642
+ return changed ? next : value;
1643
+ }
1644
+ if (!isRecord(value)) return value;
1645
+ if (!containsResidentSentinel(value)) {
1646
+ const json = JSON.stringify(value);
1647
+ return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
1648
+ }
1649
+ let changed = false;
1650
+ const entries = Object.entries(value).map(([key, child]) => {
1651
+ const replaced = coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1652
+ if (replaced !== child) changed = true;
1653
+ return [key, replaced] as const;
1654
+ });
1655
+ return changed ? Object.fromEntries(entries) : value;
1656
+ }
1657
+
1658
+ function coldSpillArgumentsValue(
1659
+ value: unknown,
1660
+ basePath: string,
1661
+ writes: ColdSpillWrite[],
1662
+ promotion: ColdSpillResidentPromotion,
1663
+ ): unknown {
1664
+ return coldSpillSubtreeValue(value, basePath, writes, promotion);
1665
+ }
1666
+
1667
+ function coldSpillContentBlock(
1668
+ block: unknown,
1669
+ basePath: string,
1670
+ writes: ColdSpillWrite[],
1671
+ promotion: ColdSpillResidentPromotion,
1672
+ ): unknown {
1673
+ if (!isRecord(block) || typeof block.type !== "string") return block;
1674
+ if (isResidentBlobSentinel(block)) return coldSpillResidentValue(block, basePath, writes, promotion);
1675
+ if (block.type === "image") return block;
1676
+ if (block.type === "text") {
1677
+ const text = block.text;
1678
+ if (isResidentBlobSentinel(text))
1679
+ return { ...block, text: coldSpillResidentValue(text, `${basePath}.text`, writes, promotion) };
1680
+ if (typeof text !== "string" || text.length < COLD_SPILL_MIN_CHARS) return block;
1681
+ return { ...block, text: coldSpillTextValue(text, `${basePath}.text`, writes) };
1682
+ }
1683
+ if (block.type === "thinking") {
1684
+ const thinking = block.thinking;
1685
+ if (typeof thinking !== "string" || thinking.length < COLD_SPILL_MIN_CHARS) return block;
1686
+ return { ...block, thinking: coldSpillTextValue(thinking, `${basePath}.thinking`, writes) };
1687
+ }
1688
+ if (block.type === "redactedThinking") {
1689
+ const data = block.data;
1690
+ if (typeof data !== "string" || data.length < COLD_SPILL_MIN_CHARS) return block;
1691
+ return { ...block, data: coldSpillTextValue(data, `${basePath}.data`, writes) };
1692
+ }
1693
+ if (block.type === "toolCall") {
1694
+ const args = block.arguments;
1695
+ if (isColdSpillArgumentsSentinel(args)) return block;
1696
+ const json = JSON.stringify(args);
1697
+ if (json.length < COLD_SPILL_MIN_CHARS && !containsResidentSentinel(args)) return block;
1698
+ const nextArgs = coldSpillArgumentsValue(args, `${basePath}.arguments`, writes, promotion);
1699
+ return nextArgs === args ? block : { ...block, arguments: nextArgs };
1700
+ }
1701
+ let changed = false;
1702
+ const entries = Object.entries(block).map(([key, child]) => {
1703
+ const replaced = key === "type" ? child : coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
1704
+ if (replaced !== child) changed = true;
1705
+ return [key, replaced] as const;
1706
+ });
1707
+ return changed ? Object.fromEntries(entries) : block;
1708
+ }
1709
+
1710
+ function coldSpillContentBlocks(
1711
+ value: unknown[],
1712
+ basePath: string,
1713
+ writes: ColdSpillWrite[],
1714
+ promotion: ColdSpillResidentPromotion,
1715
+ ): unknown {
1716
+ if (!containsResidentSentinel(value)) {
1717
+ let changedRuns = false;
1718
+ const merged: unknown[] = [];
1719
+ for (let index = 0; index < value.length; index++) {
1720
+ const block = value[index];
1721
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
1722
+ const start = index;
1723
+ const texts: string[] = [];
1724
+ while (index < value.length) {
1725
+ const runBlock = value[index];
1726
+ if (!isRecord(runBlock) || runBlock.type !== "text" || typeof runBlock.text !== "string") break;
1727
+ texts.push(runBlock.text);
1728
+ index++;
1729
+ }
1730
+ index--;
1731
+ const text = texts.join("");
1732
+ if (text.length >= COLD_SPILL_MIN_CHARS) {
1733
+ changedRuns = true;
1734
+ merged.push({ ...block, text: coldSpillTextValue(text, `${basePath}.${start}.text`, writes) });
1735
+ } else {
1736
+ merged.push(...value.slice(start, index + 1));
1737
+ }
1738
+ continue;
1739
+ }
1740
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1741
+ if (replaced !== block) changedRuns = true;
1742
+ merged.push(replaced);
1743
+ }
1744
+ if (changedRuns) return merged;
1745
+ }
1746
+ let changed = false;
1747
+ const next = value.map((block, index) => {
1748
+ const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
1749
+ if (replaced !== block) changed = true;
1750
+ return replaced;
1751
+ });
1752
+ return changed ? next : value;
1753
+ }
1754
+
1755
+ function coldSpillCustomMessageContent(
1756
+ content: CustomMessageEntry["content"],
1757
+ writes: ColdSpillWrite[],
1758
+ promotion: ColdSpillResidentPromotion,
1759
+ ): CustomMessageEntry["content"] {
1760
+ if (typeof content === "string") {
1761
+ return content.length >= COLD_SPILL_MIN_CHARS
1762
+ ? coldSpillTextValue(content, "custom_message.content", writes)
1763
+ : content;
1764
+ }
1765
+ if (Array.isArray(content))
1766
+ return coldSpillContentBlocks(
1767
+ content,
1768
+ "custom_message.content",
1769
+ writes,
1770
+ promotion,
1771
+ ) as CustomMessageEntry["content"];
1772
+ return content;
1773
+ }
1774
+
1775
+ function coldSpillUnavailable(ref: ColdSpillRef): string {
1776
+ return `[Cold-spill blob unavailable: ${ref.ref}; original ${ref.originalChars} chars unavailable]`;
1777
+ }
1778
+
1779
+ function rehydrateColdSpillRef(ref: ColdSpillRef, blobStore: BlobStore, residentStores?: ResidentBlobStores): unknown {
1780
+ const hash = ref.ref.startsWith("blob:sha256:") ? ref.ref.slice("blob:sha256:".length) : ref.sha256;
1781
+ const data = blobStore.getCheckedSync(hash);
1782
+ if (!data || hash !== ref.sha256) return coldSpillUnavailable(ref);
1783
+ const text = data.toString("utf8");
1784
+ if (ref.encoding === "json") {
1785
+ try {
1786
+ const parsed = JSON.parse(text) as unknown;
1787
+ return residentStores ? materializeResidentValueSync(parsed, residentStores) : parsed;
1788
+ } catch {
1789
+ return coldSpillUnavailable(ref);
1790
+ }
1791
+ }
1792
+ return text;
1793
+ }
1794
+
1795
+ function rehydrateColdSpillValue(
1796
+ value: unknown,
1797
+ marker: EvictedContentMarker | undefined,
1798
+ blobStore: BlobStore,
1799
+ basePath: string,
1800
+ residentStores?: ResidentBlobStores,
1801
+ ): unknown {
1802
+ const directRef = marker?.payloads[basePath];
1803
+ if (directRef) return rehydrateColdSpillRef(directRef, blobStore, residentStores);
1804
+ if (isColdSpillArgumentsSentinel(value) && typeof value.refPath === "string") {
1805
+ const ref = marker?.payloads[value.refPath];
1806
+ return ref ? rehydrateColdSpillRef(ref, blobStore, residentStores) : value;
1807
+ }
1808
+ if (Array.isArray(value))
1809
+ return value.map((item, index) =>
1810
+ rehydrateColdSpillValue(item, marker, blobStore, `${basePath}.${index}`, residentStores),
1811
+ );
1812
+ if (!isRecord(value)) return value;
1813
+ const entries = Object.entries(value).map(([key, child]) => {
1814
+ if (key === "evictedContent") return [key, child] as const;
1815
+ return [key, rehydrateColdSpillValue(child, marker, blobStore, `${basePath}.${key}`, residentStores)] as const;
1816
+ });
1817
+ return Object.fromEntries(entries);
1818
+ }
1819
+
1820
+ function rehydrateColdSpillEntry(
1821
+ entry: SessionEntry,
1822
+ blobStore: BlobStore,
1823
+ residentStores?: ResidentBlobStores,
1824
+ ): SessionEntry {
1825
+ if (entry.type === "message") {
1826
+ const marker = entry.evictedContent;
1827
+ const message = rehydrateColdSpillValue(
1828
+ entry.message,
1829
+ marker,
1830
+ blobStore,
1831
+ "message",
1832
+ residentStores,
1833
+ ) as AgentMessage;
1834
+ return { ...entry, message };
1835
+ }
1836
+ if (entry.type === "custom_message") {
1837
+ const marker = entry.evictedContent;
1838
+ return rehydrateColdSpillValue(entry, marker, blobStore, "custom_message", residentStores) as CustomMessageEntry;
1839
+ }
1840
+ return cloneSessionEntry(entry);
1841
+ }
1842
+
1438
1843
  async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
1439
1844
  async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
1440
1845
  async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
@@ -2171,6 +2576,8 @@ interface SessionManagerStateSnapshot {
2171
2576
 
2172
2577
  export class SessionManager {
2173
2578
  #sessionId: string = "";
2579
+ /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
2580
+ #lifecycleIdAdopted: boolean = false;
2174
2581
  #sessionName: string | undefined;
2175
2582
  #titleSource: "auto" | "user" | undefined;
2176
2583
  #sessionFile: string | undefined;
@@ -2220,6 +2627,16 @@ export class SessionManager {
2220
2627
  #sessionContextEntryRevision = -1;
2221
2628
  #sessionContextLeafRevision = -1;
2222
2629
  #sessionContextReplayMetadataRevision = -1;
2630
+ #coldSpillWriteCount = 0;
2631
+ #coldSpillReadCount = 0;
2632
+ #residentTextReadCount = 0;
2633
+ #residentImageReadCount = 0;
2634
+ #publicMaterializerCallCount = 0;
2635
+ #getEntryMaterializerCallCount = 0;
2636
+ #getBranchMaterializerCallCount = 0;
2637
+ #getEntriesMaterializerCallCount = 0;
2638
+ #materializedEntriesCachePopulateCount = 0;
2639
+ #pathOnlyContextBuildCount = 0;
2223
2640
 
2224
2641
  private constructor(
2225
2642
  private cwd: string,
@@ -2244,6 +2661,19 @@ export class SessionManager {
2244
2661
  };
2245
2662
  }
2246
2663
 
2664
+ #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
2665
+ return {
2666
+ ...this.#residentBlobStores(),
2667
+ onResidentBlobRead: kind => {
2668
+ if (kind === "text") {
2669
+ this.#residentTextReadCount++;
2670
+ } else {
2671
+ this.#residentImageReadCount++;
2672
+ }
2673
+ },
2674
+ };
2675
+ }
2676
+
2247
2677
  #residentCacheDir(sessionFile: string): string {
2248
2678
  const instance = ++residentCacheInstanceCounter;
2249
2679
  return path.join(
@@ -2615,7 +3045,12 @@ export class SessionManager {
2615
3045
  this.#persistChain = Promise.resolve();
2616
3046
  this.#persistError = undefined;
2617
3047
  this.#persistErrorReported = false;
2618
- this.#sessionId = createSessionId();
3048
+ // Adopt a lifecycle pre-allocated id exactly once (the initial session of a
3049
+ // /session_create child); later new-session paths (/new, fork, branch) get
3050
+ // fresh ids so they cannot reuse the original SKC_SESSION_ID.
3051
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
3052
+ if (preallocated) this.#lifecycleIdAdopted = true;
3053
+ this.#sessionId = preallocated ?? createSessionId();
2619
3054
  this.#sessionName = undefined;
2620
3055
  this.#titleSource = undefined;
2621
3056
  const timestamp = new Date().toISOString();
@@ -3579,8 +4014,11 @@ export class SessionManager {
3579
4014
  * Returns undefined if no model change has been recorded.
3580
4015
  */
3581
4016
  getLastModelChangeRole(): string | undefined {
4017
+ const visited = new Set<string>();
3582
4018
  let current = this.getLeafEntry();
3583
4019
  while (current) {
4020
+ if (visited.has(current.id)) break;
4021
+ visited.add(current.id);
3584
4022
  if (current.type === "model_change") {
3585
4023
  return current.role ?? "default";
3586
4024
  }
@@ -3589,7 +4027,222 @@ export class SessionManager {
3589
4027
  return undefined;
3590
4028
  }
3591
4029
 
4030
+ evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
4031
+ const firstKept = this.#byId.get(firstKeptEntryId);
4032
+ const compaction = this.#byId.get(compactionEntryId);
4033
+ if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
4034
+ if (compaction?.type !== "compaction") throw new Error(`Compaction entry ${compactionEntryId} not found`);
4035
+ const ids: string[] = [];
4036
+ const visited = new Set<string>();
4037
+ let current: SessionEntry | undefined = compaction;
4038
+ while (current) {
4039
+ if (visited.has(current.id)) break;
4040
+ visited.add(current.id);
4041
+ ids.push(current.id);
4042
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4043
+ }
4044
+ ids.reverse();
4045
+ let evictedEntries = 0;
4046
+ let hotCharsRemoved = 0;
4047
+ let coldBlobBytes = 0;
4048
+ let payloadRefs = 0;
4049
+ let alreadyEvictedEntries = 0;
4050
+ let mutated = false;
4051
+ try {
4052
+ for (const id of ids) {
4053
+ if (id === firstKeptEntryId) break;
4054
+ const entry = this.#byId.get(id);
4055
+ if (!entry || entry.type === "compaction") continue;
4056
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4057
+ if (entry.evictedContent?.reason === "compacted_history") {
4058
+ alreadyEvictedEntries++;
4059
+ continue;
4060
+ }
4061
+ const beforeChars = JSON.stringify(entry).length;
4062
+ const writes: ColdSpillWrite[] = [];
4063
+ const nextEntry = this.#coldSpillClone(entry, writes);
4064
+ if (writes.length === 0 || nextEntry === entry) continue;
4065
+ const payloads: Record<string, ColdSpillRef> = {};
4066
+ for (const write of writes) {
4067
+ const put = this.#blobStore.putImmutableSync(write.data);
4068
+ this.#coldSpillWriteCount++;
4069
+ payloads[write.path] = {
4070
+ kind: "cold_spill",
4071
+ ref: put.ref,
4072
+ encoding: write.encoding,
4073
+ originalChars: write.originalChars,
4074
+ sha256: put.hash,
4075
+ bytes: put.bytes,
4076
+ };
4077
+ coldBlobBytes += put.bytes;
4078
+ }
4079
+ const marker: EvictedContentMarker = {
4080
+ evictedAt: Date.now(),
4081
+ reason: "compacted_history",
4082
+ compactionEntryId,
4083
+ firstKeptEntryId,
4084
+ payloads,
4085
+ };
4086
+ // Store the marker at the ENTRY level (session metadata), not on the
4087
+ // strict message type, so message shapes stay type-clean.
4088
+ if (nextEntry.type === "message" || nextEntry.type === "custom_message") {
4089
+ nextEntry.evictedContent = marker;
4090
+ }
4091
+ this.#replaceCanonicalEntry(nextEntry);
4092
+ mutated = true;
4093
+ evictedEntries++;
4094
+ payloadRefs += writes.length;
4095
+ hotCharsRemoved += Math.max(0, beforeChars - JSON.stringify(nextEntry).length);
4096
+ }
4097
+ } finally {
4098
+ if (mutated) {
4099
+ this.#needsFullRewriteOnNextPersist = true;
4100
+ this.#bumpEntryRevision();
4101
+ this.#replayMetadataRevision++;
4102
+ this.#materializedEntriesCache = undefined;
4103
+ this.#materializedEntriesRevision = -1;
4104
+ this.#sessionContextCache = undefined;
4105
+ }
4106
+ }
4107
+ return {
4108
+ evictedEntries,
4109
+ hotCharsRemoved,
4110
+ coldBlobBytes,
4111
+ payloadRefs,
4112
+ alreadyEvictedEntries,
4113
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4114
+ coldSpillReadCount: this.#coldSpillReadCount,
4115
+ residentTextReadCount: this.#residentTextReadCount,
4116
+ residentImageReadCount: this.#residentImageReadCount,
4117
+ };
4118
+ }
4119
+
4120
+ #coldSpillClone(entry: SessionEntry, writes: ColdSpillWrite[]): SessionEntry {
4121
+ if (entry.type === "message") {
4122
+ const content = "content" in entry.message ? entry.message.content : undefined;
4123
+ if (!Array.isArray(content)) return entry;
4124
+ const nextContent = coldSpillContentBlocks(content, "message.content", writes, {
4125
+ stores: this.#residentBlobStoresForColdRehydrate(),
4126
+ });
4127
+ return nextContent === content
4128
+ ? entry
4129
+ : { ...entry, message: { ...entry.message, content: nextContent } as AgentMessage };
4130
+ }
4131
+ if (entry.type === "custom_message") {
4132
+ const content = coldSpillCustomMessageContent(entry.content, writes, {
4133
+ stores: this.#residentBlobStoresForColdRehydrate(),
4134
+ });
4135
+ return content === entry.content ? entry : { ...entry, content };
4136
+ }
4137
+ return entry;
4138
+ }
4139
+
4140
+ #replaceCanonicalEntry(entry: SessionEntry): void {
4141
+ this.#byId.set(entry.id, entry);
4142
+ const index = this.#fileEntries.findIndex(candidate => candidate.type !== "session" && candidate.id === entry.id);
4143
+ if (index >= 0) this.#fileEntries[index] = entry;
4144
+ }
4145
+
4146
+ getObservabilityStatsForTests(): SessionManagerObservabilityStats {
4147
+ return {
4148
+ coldSpillWriteCount: this.#coldSpillWriteCount,
4149
+ coldSpillReadCount: this.#coldSpillReadCount,
4150
+ residentTextReadCount: this.#residentTextReadCount,
4151
+ residentImageReadCount: this.#residentImageReadCount,
4152
+ publicMaterializerCallCount: this.#publicMaterializerCallCount,
4153
+ getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
4154
+ getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
4155
+ getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
4156
+ materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
4157
+ pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
4158
+ };
4159
+ }
4160
+
4161
+ hotRetainedMessageCharsForTests(): number {
4162
+ let total = 0;
4163
+ for (const entry of this.#fileEntries) {
4164
+ if (entry.type !== "message" && entry.type !== "custom_message") continue;
4165
+ total += JSON.stringify(entry).length;
4166
+ }
4167
+ return total;
4168
+ }
4169
+
4170
+ getCanonicalEntryForTests(id: string): SessionEntry | undefined {
4171
+ const entry = this.#byId.get(id);
4172
+ return entry ? cloneSessionEntry(entry) : undefined;
4173
+ }
4174
+
4175
+ getEntryForFidelity(id: string): SessionEntry | undefined {
4176
+ const entry = this.#byId.get(id);
4177
+ return entry
4178
+ ? rehydrateColdSpillEntry(
4179
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()),
4180
+ this.#blobStore,
4181
+ this.#residentBlobStoresForColdRehydrate(),
4182
+ )
4183
+ : undefined;
4184
+ }
4185
+
4186
+ getBranchForFidelity(fromId?: string): SessionEntry[] {
4187
+ const cache = new Map<string, string>();
4188
+ const path: SessionEntry[] = [];
4189
+ const visited = new Set<string>();
4190
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4191
+ while (current) {
4192
+ if (visited.has(current.id)) break;
4193
+ visited.add(current.id);
4194
+ path.push(
4195
+ rehydrateColdSpillEntry(
4196
+ materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
4197
+ this.#blobStore,
4198
+ this.#residentBlobStoresForColdRehydrate(),
4199
+ ),
4200
+ );
4201
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4202
+ }
4203
+ path.reverse();
4204
+ return path;
4205
+ }
4206
+
4207
+ #getCanonicalBranchClones(fromId?: string): SessionEntry[] {
4208
+ const path: SessionEntry[] = [];
4209
+ const visited = new Set<string>();
4210
+ let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4211
+ while (current) {
4212
+ if (visited.has(current.id)) break;
4213
+ visited.add(current.id);
4214
+ path.push(cloneSessionEntry(current));
4215
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4216
+ }
4217
+ path.reverse();
4218
+ return path;
4219
+ }
4220
+
4221
+ /**
4222
+ * Walk the active branch without materializing resident blobs or rehydrating
4223
+ * cold-spill payloads. Intended for metadata-only scans such as todo-phase
4224
+ * sync; callers must not mutate returned entries.
4225
+ */
4226
+ getActivePathEntriesCanonical(fromId?: string): SessionEntry[] {
4227
+ return this.#getCanonicalBranchClones(fromId);
4228
+ }
4229
+
4230
+ getEntriesForExport(): SessionEntry[] {
4231
+ const cache = new Map<string, string>();
4232
+ return this.#fileEntries
4233
+ .filter((entry): entry is SessionEntry => entry.type !== "session")
4234
+ .map(entry =>
4235
+ rehydrateColdSpillEntry(
4236
+ materializeResidentEntrySync(entry, this.#residentBlobStores(), cache),
4237
+ this.#blobStore,
4238
+ this.#residentBlobStoresForColdRehydrate(),
4239
+ ),
4240
+ );
4241
+ }
4242
+
3592
4243
  getEntry(id: string): SessionEntry | undefined {
4244
+ this.#publicMaterializerCallCount++;
4245
+ this.#getEntryMaterializerCallCount++;
3593
4246
  const entry = this.#byId.get(id);
3594
4247
  return entry ? materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()) : undefined;
3595
4248
  }
@@ -3647,11 +4300,16 @@ export class SessionManager {
3647
4300
  * Use buildSessionContext() to get the resolved messages for the LLM.
3648
4301
  */
3649
4302
  getBranch(fromId?: string): SessionEntry[] {
4303
+ this.#publicMaterializerCallCount++;
4304
+ this.#getBranchMaterializerCallCount++;
3650
4305
  const cache = new Map<string, string>();
3651
4306
  const path: SessionEntry[] = [];
4307
+ const visited = new Set<string>();
3652
4308
  const startId = fromId ?? this.#leafId;
3653
4309
  let current = startId ? this.#byId.get(startId) : undefined;
3654
4310
  while (current) {
4311
+ if (visited.has(current.id)) break;
4312
+ visited.add(current.id);
3655
4313
  path.push(materializeResidentEntrySync(current, this.#residentBlobStores(), cache));
3656
4314
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
3657
4315
  }
@@ -3673,13 +4331,64 @@ export class SessionManager {
3673
4331
  ) {
3674
4332
  return cloneSessionContext(cached);
3675
4333
  }
3676
- const context = buildSessionContext(this.#getMaterializedEntriesInternal(), this.#leafId);
4334
+ this.#pathOnlyContextBuildCount++;
4335
+ const context = buildSessionContext(this.#getActivePathEntriesForProviderContext(), this.#leafId);
3677
4336
  this.#sessionContextCache = new WeakRef(context);
3678
4337
  this.#sessionContextEntryRevision = this.#entryRevision;
3679
4338
  this.#sessionContextLeafRevision = this.#leafRevision;
3680
4339
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
3681
4340
  return cloneSessionContext(context);
3682
4341
  }
4342
+
4343
+ #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
4344
+ if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
4345
+ const ids: string[] = [];
4346
+ const visited = new Set<string>();
4347
+ let current = this.#byId.get(fromId ?? this.#leafId ?? "");
4348
+ while (current) {
4349
+ if (visited.has(current.id)) break;
4350
+ visited.add(current.id);
4351
+ ids.push(current.id);
4352
+ current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4353
+ }
4354
+ ids.reverse();
4355
+ const pathEntries = ids
4356
+ .map(id => this.#byId.get(id))
4357
+ .filter((entry): entry is SessionEntry => entry !== undefined);
4358
+ let compaction: CompactionEntry | undefined;
4359
+ for (const entry of pathEntries) if (entry.type === "compaction") compaction = entry;
4360
+ if (!compaction) return pathEntries.map(entry => this.#entryForProviderContext(entry, undefined));
4361
+ const compactionIndex = pathEntries.findIndex(entry => entry.id === compaction.id);
4362
+ const firstKeptIndex = pathEntries.findIndex(entry => entry.id === compaction.firstKeptEntryId);
4363
+ const remote = compaction.preserveData?.openaiRemoteCompaction;
4364
+ const hasRemoteReplacement = isRecord(remote) && Array.isArray(remote.replacementHistory);
4365
+ return pathEntries.map((entry, index) => {
4366
+ const covered =
4367
+ index < compactionIndex && (hasRemoteReplacement || (firstKeptIndex >= 0 && index < firstKeptIndex));
4368
+ return this.#entryForProviderContext(entry, covered ? "covered" : undefined);
4369
+ });
4370
+ }
4371
+
4372
+ #entryForProviderContext(entry: SessionEntry, coldSpillPolicy: "covered" | undefined): SessionEntry {
4373
+ if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
4374
+ return cloneSessionEntry(entry);
4375
+ }
4376
+ if (entry.type !== "message" && entry.type !== "custom_message")
4377
+ return materializeProviderVisibleEntrySync(entry, this.#residentBlobStores());
4378
+ const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
4379
+ const rehydrated = rehydrateColdSpillEntry(
4380
+ materialized,
4381
+ this.#blobStore,
4382
+ this.#residentBlobStoresForColdRehydrate(),
4383
+ );
4384
+ if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
4385
+ return rehydrated;
4386
+ }
4387
+
4388
+ #countColdSpillPayloads(entry: SessionEntry): number {
4389
+ const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
4390
+ return marker ? Object.keys(marker.payloads ?? {}).length : 0;
4391
+ }
3683
4392
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
3684
4393
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
3685
4394
  let didSanitize = false;
@@ -3721,6 +4430,7 @@ export class SessionManager {
3721
4430
  if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
3722
4431
  return this.#materializedEntriesCache;
3723
4432
  }
4433
+ this.#materializedEntriesCachePopulateCount++;
3724
4434
  const resolvedTextBlobCache = new Map<string, string>();
3725
4435
  const materializedEntries = this.#fileEntries
3726
4436
  .filter((e): e is SessionEntry => e.type !== "session")
@@ -3731,6 +4441,8 @@ export class SessionManager {
3731
4441
  }
3732
4442
 
3733
4443
  getEntries(): SessionEntry[] {
4444
+ this.#publicMaterializerCallCount++;
4445
+ this.#getEntriesMaterializerCallCount++;
3734
4446
  return this.#getMaterializedEntriesInternal().map(entry => cloneSessionEntry(entry));
3735
4447
  }
3736
4448
 
@@ -3750,18 +4462,48 @@ export class SessionManager {
3750
4462
  nodeMap.set(entry.id, { entry, children: [], label });
3751
4463
  }
3752
4464
 
3753
- // Build tree
4465
+ const addRoot = (node: SessionTreeNode): void => {
4466
+ if (!roots.includes(node)) {
4467
+ roots.push(node);
4468
+ }
4469
+ };
4470
+ const removeRoot = (node: SessionTreeNode): void => {
4471
+ const index = roots.indexOf(node);
4472
+ if (index !== -1) {
4473
+ roots.splice(index, 1);
4474
+ }
4475
+ };
4476
+ const wouldCreateChildCycle = (parent: SessionTreeNode, child: SessionTreeNode): boolean => {
4477
+ const stack: SessionTreeNode[] = [child];
4478
+ const visited = new Set<SessionTreeNode>();
4479
+ while (stack.length > 0) {
4480
+ const current = stack.pop()!;
4481
+ if (current === parent) {
4482
+ return true;
4483
+ }
4484
+ if (visited.has(current)) {
4485
+ continue;
4486
+ }
4487
+ visited.add(current);
4488
+ stack.push(...current.children);
4489
+ }
4490
+ return false;
4491
+ };
4492
+
4493
+ // Build tree. Corrupt session files can contain duplicate IDs or parentId
4494
+ // cycles; reject only the edge that would make the returned tree cyclic.
3754
4495
  for (const entry of entries) {
3755
4496
  const node = nodeMap.get(entry.id)!;
3756
4497
  if (entry.parentId === null || entry.parentId === entry.id) {
3757
- roots.push(node);
4498
+ addRoot(node);
3758
4499
  } else {
3759
4500
  const parent = nodeMap.get(entry.parentId);
3760
- if (parent) {
4501
+ if (parent && !wouldCreateChildCycle(parent, node)) {
3761
4502
  parent.children.push(node);
4503
+ removeRoot(node);
3762
4504
  } else {
3763
- // Orphan - treat as root
3764
- roots.push(node);
4505
+ // Orphan or cycle-closing edge - treat as root
4506
+ addRoot(node);
3765
4507
  }
3766
4508
  }
3767
4509
  }
@@ -3769,8 +4511,13 @@ export class SessionManager {
3769
4511
  // Sort children by timestamp (oldest first, newest at bottom)
3770
4512
  // Use iterative approach to avoid stack overflow on deep trees
3771
4513
  const stack: SessionTreeNode[] = [...roots];
4514
+ const sorted = new Set<SessionTreeNode>();
3772
4515
  while (stack.length > 0) {
3773
4516
  const node = stack.pop()!;
4517
+ if (sorted.has(node)) {
4518
+ continue;
4519
+ }
4520
+ sorted.add(node);
3774
4521
  node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
3775
4522
  stack.push(...node.children);
3776
4523
  }
@@ -3837,14 +4584,17 @@ export class SessionManager {
3837
4584
  */
3838
4585
  createBranchedSession(leafId: string): string | undefined {
3839
4586
  const previousSessionFile = this.#sessionFile;
3840
- const branchPath = this.getBranch(leafId);
4587
+ const branchPath = this.#getCanonicalBranchClones(leafId);
3841
4588
  if (branchPath.length === 0) {
3842
4589
  throw new Error(`Entry ${leafId} not found`);
3843
4590
  }
3844
4591
 
3845
4592
  // Filter out LabelEntry from path - we'll recreate them from the resolved map
3846
4593
  const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
3847
-
4594
+ const materializedPathWithoutLabels = materializeResidentEntriesSync(
4595
+ pathWithoutLabels,
4596
+ this.#residentBlobStores(),
4597
+ );
3848
4598
  const newSessionId = createSessionId();
3849
4599
  const timestamp = new Date().toISOString();
3850
4600
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -3871,7 +4621,7 @@ export class SessionManager {
3871
4621
  if (this.persist) {
3872
4622
  const lines: string[] = [];
3873
4623
  lines.push(JSON.stringify(header));
3874
- for (const entry of pathWithoutLabels) {
4624
+ for (const entry of materializedPathWithoutLabels) {
3875
4625
  lines.push(JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore)));
3876
4626
  }
3877
4627
  // Write fresh label entries at the end
@@ -3898,7 +4648,7 @@ export class SessionManager {
3898
4648
  this.#resetResidentTextBlobStore();
3899
4649
  this.#fileEntries = [
3900
4650
  header,
3901
- ...pathWithoutLabels.map(
4651
+ ...materializedPathWithoutLabels.map(
3902
4652
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3903
4653
  ),
3904
4654
  ...labelEntries,
@@ -3928,7 +4678,7 @@ export class SessionManager {
3928
4678
  this.#resetResidentTextBlobStore();
3929
4679
  this.#fileEntries = [
3930
4680
  header,
3931
- ...pathWithoutLabels.map(
4681
+ ...materializedPathWithoutLabels.map(
3932
4682
  entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
3933
4683
  ),
3934
4684
  ...labelEntries,