@kolisachint/hoocode-agent 0.4.108 → 0.4.109

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 (57) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/core/agent-session-compaction.d.ts +79 -0
  3. package/dist/core/agent-session-compaction.d.ts.map +1 -0
  4. package/dist/core/agent-session-compaction.js +346 -0
  5. package/dist/core/agent-session-compaction.js.map +1 -0
  6. package/dist/core/agent-session-retry.d.ts +76 -0
  7. package/dist/core/agent-session-retry.d.ts.map +1 -0
  8. package/dist/core/agent-session-retry.js +192 -0
  9. package/dist/core/agent-session-retry.js.map +1 -0
  10. package/dist/core/agent-session-skills.d.ts +34 -0
  11. package/dist/core/agent-session-skills.d.ts.map +1 -0
  12. package/dist/core/agent-session-skills.js +52 -0
  13. package/dist/core/agent-session-skills.js.map +1 -0
  14. package/dist/core/agent-session-stats.d.ts +74 -0
  15. package/dist/core/agent-session-stats.d.ts.map +1 -0
  16. package/dist/core/agent-session-stats.js +187 -0
  17. package/dist/core/agent-session-stats.js.map +1 -0
  18. package/dist/core/agent-session-tree-navigation.d.ts +69 -0
  19. package/dist/core/agent-session-tree-navigation.d.ts.map +1 -0
  20. package/dist/core/agent-session-tree-navigation.js +198 -0
  21. package/dist/core/agent-session-tree-navigation.js.map +1 -0
  22. package/dist/core/agent-session.d.ts +10 -66
  23. package/dist/core/agent-session.d.ts.map +1 -1
  24. package/dist/core/agent-session.js +96 -806
  25. package/dist/core/agent-session.js.map +1 -1
  26. package/dist/core/context-files.d.ts +25 -0
  27. package/dist/core/context-files.d.ts.map +1 -0
  28. package/dist/core/context-files.js +97 -0
  29. package/dist/core/context-files.js.map +1 -0
  30. package/dist/core/package-manager.d.ts.map +1 -1
  31. package/dist/core/package-manager.js +3 -519
  32. package/dist/core/package-manager.js.map +1 -1
  33. package/dist/core/package-resource-discovery.d.ts +62 -0
  34. package/dist/core/package-resource-discovery.d.ts.map +1 -0
  35. package/dist/core/package-resource-discovery.js +530 -0
  36. package/dist/core/package-resource-discovery.js.map +1 -0
  37. package/dist/core/resource-loader.d.ts +1 -10
  38. package/dist/core/resource-loader.d.ts.map +1 -1
  39. package/dist/core/resource-loader.js +4 -83
  40. package/dist/core/resource-loader.js.map +1 -1
  41. package/dist/core/settings-manager.d.ts +5 -140
  42. package/dist/core/settings-manager.d.ts.map +1 -1
  43. package/dist/core/settings-manager.js +4 -81
  44. package/dist/core/settings-manager.js.map +1 -1
  45. package/dist/core/settings-storage.d.ts +29 -0
  46. package/dist/core/settings-storage.d.ts.map +1 -0
  47. package/dist/core/settings-storage.js +90 -0
  48. package/dist/core/settings-storage.js.map +1 -0
  49. package/dist/core/settings-types.d.ts +128 -0
  50. package/dist/core/settings-types.d.ts.map +1 -0
  51. package/dist/core/settings-types.js +9 -0
  52. package/dist/core/settings-types.js.map +1 -0
  53. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  54. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  55. package/examples/extensions/sandbox/package.json +1 -1
  56. package/examples/extensions/with-deps/package.json +1 -1
  57. package/package.json +4 -4
@@ -12,14 +12,15 @@
12
12
  *
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
- import { basename, dirname, resolve } from "node:path";
17
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "@kolisachint/hoocode-agent-core";
18
- import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
15
+ import { basename, dirname } from "node:path";
16
+ import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
19
17
  import { theme } from "../modes/interactive/theme/theme.js";
20
- import { stripFrontmatter } from "../utils/frontmatter.js";
21
- import { sleep } from "../utils/sleep.js";
22
18
  import { loadAgentRegistry } from "./agent-registry.js";
19
+ import { CompactionController } from "./agent-session-compaction.js";
20
+ import { AutoRetryController } from "./agent-session-retry.js";
21
+ import { expandSkillCommand } from "./agent-session-skills.js";
22
+ import { collectUserMessagesForForking, computeContextUsage, computeSessionStats, exportSessionBranchToJsonl, getLastAssistantText, } from "./agent-session-stats.js";
23
+ import { TreeNavigationController, } from "./agent-session-tree-navigation.js";
23
24
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
24
25
  import { executeBashWithOperations } from "./bash-executor.js";
25
26
  import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
@@ -29,7 +30,6 @@ import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
29
30
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
30
31
  import { expandPromptTemplate, tryExpandPromptTemplate } from "./prompt-templates.js";
31
32
  import { clearProviderExhaustion, isProviderQuotaError, markProviderExhausted } from "./provider-health.js";
32
- import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
33
33
  import { createSyntheticSourceInfo } from "./source-info.js";
34
34
  import { updateSubagentSkillPaths } from "./subagent-pool-instance.js";
35
35
  import { buildSystemPrompt } from "./system-prompt.js";
@@ -37,27 +37,7 @@ import { createLocalBashOperations } from "./tools/bash.js";
37
37
  import { createAllToolDefinitions } from "./tools/index.js";
38
38
  import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
39
39
  import { updateWarmSubagentSkillPaths } from "./warm-subagent-pool-instance.js";
40
- /**
41
- * Retryable error signatures (overloaded, rate limit, server/network errors,
42
- * transport closes). Compiled once at module load instead of on every assistant
43
- * response. Context-overflow errors are handled separately by compaction.
44
- */
45
- const RETRYABLE_ERROR_PATTERN = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
46
- /**
47
- * Parse a skill block from message text.
48
- * Returns null if the text doesn't contain a skill block.
49
- */
50
- export function parseSkillBlock(text) {
51
- const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
52
- if (!match)
53
- return null;
54
- return {
55
- name: match[1],
56
- location: match[2],
57
- content: match[3],
58
- userMessage: match[4]?.trim() || undefined,
59
- };
60
- }
40
+ export { parseSkillBlock } from "./agent-session-skills.js";
61
41
  // ============================================================================
62
42
  // Constants
63
43
  // ============================================================================
@@ -82,16 +62,11 @@ export class AgentSession {
82
62
  /** Messages queued to be included with the next user prompt as context ("asides"). */
83
63
  _pendingNextTurnMessages = [];
84
64
  // Compaction state
85
- _compactionAbortController = undefined;
86
- _autoCompactionAbortController = undefined;
87
- _overflowRecoveryAttempted = false;
88
- // Branch summarization state
89
- _branchSummaryAbortController = undefined;
65
+ _compaction;
66
+ // Branch summarization / tree navigation
67
+ _tree;
90
68
  // Retry state
91
- _retryAbortController = undefined;
92
- _retryAttempt = 0;
93
- _retryPromise = undefined;
94
- _retryResolve = undefined;
69
+ _retry;
95
70
  // Bash execution state
96
71
  _bashAbortController = undefined;
97
72
  _pendingBashMessages = [];
@@ -143,6 +118,52 @@ export class AgentSession {
143
118
  : undefined;
144
119
  this._baseToolsOverride = config.baseToolsOverride;
145
120
  this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
121
+ this._retry = new AutoRetryController({
122
+ getRetrySettings: () => this.settingsManager.getRetrySettings(),
123
+ getModel: () => this.model,
124
+ getAgentMessages: () => this.agent.state.messages,
125
+ setAgentMessages: (messages) => {
126
+ this.agent.state.messages = messages;
127
+ },
128
+ continueAgent: () => {
129
+ this.agent.continue().catch(() => {
130
+ // Retry failed - will be caught by next agent_end
131
+ });
132
+ },
133
+ waitForAgentIdle: () => this.agent.waitForIdle(),
134
+ emit: (event) => this._emit(event),
135
+ });
136
+ this._compaction = new CompactionController({
137
+ sessionManager: this.sessionManager,
138
+ settingsManager: this.settingsManager,
139
+ modelRegistry: this._modelRegistry,
140
+ getModel: () => this.model,
141
+ getThinkingLevel: () => this.thinkingLevel,
142
+ getExtensionRunner: () => this._extensionRunner,
143
+ getAgentMessages: () => this.agent.state.messages,
144
+ setAgentMessages: (messages) => {
145
+ this.agent.state.messages = messages;
146
+ },
147
+ getRequiredRequestAuth: (model) => this._getRequiredRequestAuth(model),
148
+ emit: (event) => this._emit(event),
149
+ disconnectFromAgent: () => this._disconnectFromAgent(),
150
+ reconnectToAgent: () => this._reconnectToAgent(),
151
+ abortSession: () => this.abort(),
152
+ continueAgent: () => {
153
+ this.agent.continue().catch(() => { });
154
+ },
155
+ hasQueuedMessages: () => this.agent.hasQueuedMessages(),
156
+ });
157
+ this._tree = new TreeNavigationController({
158
+ sessionManager: this.sessionManager,
159
+ settingsManager: this.settingsManager,
160
+ getModel: () => this.model,
161
+ getExtensionRunner: () => this._extensionRunner,
162
+ getRequiredRequestAuth: (model) => this._getRequiredRequestAuth(model),
163
+ setAgentMessages: (messages) => {
164
+ this.agent.state.messages = messages;
165
+ },
166
+ });
146
167
  // Always subscribe to agent events for internal handling
147
168
  // (session persistence, extensions, auto-compaction, retry logic)
148
169
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
@@ -256,44 +277,19 @@ export class AgentSession {
256
277
  _handleAgentEvent = (event) => {
257
278
  // Create retry promise synchronously before queueing async processing.
258
279
  // Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
259
- // as soon as agent.prompt() resolves. If _retryPromise is created only inside
280
+ // as soon as agent.prompt() resolves. If the retry promise is created only inside
260
281
  // _processAgentEvent, slow earlier queued events can delay agent_end processing
261
282
  // and waitForRetry() can miss the in-flight retry.
262
- this._createRetryPromiseForAgentEnd(event);
283
+ this._retry.createPromiseForAgentEnd(event);
263
284
  this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => this._processAgentEvent(event));
264
285
  // Keep queue alive if an event handler fails
265
286
  this._agentEventQueue.catch(() => { });
266
287
  };
267
- _createRetryPromiseForAgentEnd(event) {
268
- if (event.type !== "agent_end" || this._retryPromise) {
269
- return;
270
- }
271
- const settings = this.settingsManager.getRetrySettings();
272
- if (!settings.enabled) {
273
- return;
274
- }
275
- const lastAssistant = this._findLastAssistantInMessages(event.messages);
276
- if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
277
- return;
278
- }
279
- this._retryPromise = new Promise((resolve) => {
280
- this._retryResolve = resolve;
281
- });
282
- }
283
- _findLastAssistantInMessages(messages) {
284
- for (let i = messages.length - 1; i >= 0; i--) {
285
- const message = messages[i];
286
- if (message.role === "assistant") {
287
- return message;
288
- }
289
- }
290
- return undefined;
291
- }
292
288
  async _processAgentEvent(event) {
293
289
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
294
290
  // This ensures the UI sees the updated queue state
295
291
  if (event.type === "message_start" && event.message.role === "user") {
296
- this._overflowRecoveryAttempted = false;
292
+ this._compaction.resetOverflowRecovery();
297
293
  const messageText = this._getUserMessageText(event.message);
298
294
  if (messageText) {
299
295
  // Check steering queue first
@@ -335,7 +331,7 @@ export class AgentSession {
335
331
  this._lastAssistantMessage = event.message;
336
332
  const assistantMsg = event.message;
337
333
  if (assistantMsg.stopReason !== "error") {
338
- this._overflowRecoveryAttempted = false;
334
+ this._compaction.resetOverflowRecovery();
339
335
  // A successful response clears any prior provider-exhaustion flag so
340
336
  // subagent dispatch is unblocked as soon as the provider recovers.
341
337
  const provider = this.model?.provider;
@@ -344,13 +340,8 @@ export class AgentSession {
344
340
  }
345
341
  // Reset retry counter immediately on successful assistant response
346
342
  // This prevents accumulation across multiple LLM calls within a turn
347
- if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
348
- this._emit({
349
- type: "auto_retry_end",
350
- success: true,
351
- attempt: this._retryAttempt,
352
- });
353
- this._retryAttempt = 0;
343
+ if (assistantMsg.stopReason !== "error") {
344
+ this._retry.onSuccessfulAssistantResponse();
354
345
  }
355
346
  }
356
347
  }
@@ -359,8 +350,8 @@ export class AgentSession {
359
350
  const msg = this._lastAssistantMessage;
360
351
  this._lastAssistantMessage = undefined;
361
352
  // Check for retryable errors first (overloaded, rate limit, server errors)
362
- if (this._isRetryableError(msg)) {
363
- const didRetry = await this._handleRetryableError(msg);
353
+ if (this._retry.isRetryableError(msg)) {
354
+ const didRetry = await this._retry.handleRetryableError(msg);
364
355
  if (didRetry)
365
356
  return; // Retry was initiated, don't proceed to compaction
366
357
  // Retries are exhausted/disabled and a quota or rate-limit error
@@ -372,16 +363,8 @@ export class AgentSession {
372
363
  markProviderExhausted(provider, msg.errorMessage ?? "provider error");
373
364
  }
374
365
  }
375
- this._resolveRetry();
376
- await this._checkCompaction(msg);
377
- }
378
- }
379
- /** Resolve the pending retry promise */
380
- _resolveRetry() {
381
- if (this._retryResolve) {
382
- this._retryResolve();
383
- this._retryResolve = undefined;
384
- this._retryPromise = undefined;
366
+ this._retry.resolve();
367
+ await this._compaction.checkCompaction(msg);
385
368
  }
386
369
  }
387
370
  /** Extract text content from a message */
@@ -571,7 +554,7 @@ export class AgentSession {
571
554
  }
572
555
  /** Current retry attempt (0 if not retrying) */
573
556
  get retryAttempt() {
574
- return this._retryAttempt;
557
+ return this._retry.attempt;
575
558
  }
576
559
  /**
577
560
  * Get the names of currently active tools.
@@ -617,9 +600,7 @@ export class AgentSession {
617
600
  }
618
601
  /** Whether compaction or branch summarization is currently running */
619
602
  get isCompacting() {
620
- return (this._autoCompactionAbortController !== undefined ||
621
- this._compactionAbortController !== undefined ||
622
- this._branchSummaryAbortController !== undefined);
603
+ return this._compaction.isCompacting || this._tree.isSummarizing;
623
604
  }
624
605
  /** All messages including custom types like BashExecutionMessage */
625
606
  get messages() {
@@ -798,7 +779,7 @@ export class AgentSession {
798
779
  // Check if we need to compact before sending (catches aborted responses)
799
780
  const lastAssistant = this._findLastAssistantMessage();
800
781
  if (lastAssistant) {
801
- await this._checkCompaction(lastAssistant, false);
782
+ await this._compaction.checkCompaction(lastAssistant, false);
802
783
  }
803
784
  // Build messages array (custom message if any, then user message)
804
785
  messages = [];
@@ -869,7 +850,7 @@ export class AgentSession {
869
850
  }
870
851
  preflightResult?.(true);
871
852
  await this.agent.prompt(messages);
872
- await this.waitForRetry();
853
+ await this._retry.waitForRetry();
873
854
  }
874
855
  /**
875
856
  * Try to execute an extension command. Returns true if command was found and executed.
@@ -904,29 +885,13 @@ export class AgentSession {
904
885
  * Emits errors via extension runner if file read fails.
905
886
  */
906
887
  _expandSkillCommand(text) {
907
- if (!text.startsWith("/skill:"))
908
- return text;
909
- const spaceIndex = text.indexOf(" ");
910
- const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
911
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
912
- const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
913
- if (!skill)
914
- return text; // Unknown skill, pass through
915
- try {
916
- const content = readFileSync(skill.filePath, "utf-8");
917
- const body = stripFrontmatter(content).trim();
918
- const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
919
- return args ? `${skillBlock}\n\n${args}` : skillBlock;
920
- }
921
- catch (err) {
922
- // Emit error like extension commands do
888
+ return expandSkillCommand(text, this.resourceLoader.getSkills().skills, ({ filePath, error }) => {
923
889
  this._extensionRunner.emitError({
924
- extensionPath: skill.filePath,
890
+ extensionPath: filePath,
925
891
  event: "skill_expansion",
926
- error: err instanceof Error ? err.message : String(err),
892
+ error,
927
893
  });
928
- return text; // Return original on error
929
- }
894
+ });
930
895
  }
931
896
  /**
932
897
  * Queue a steering message while the agent is running.
@@ -1118,7 +1083,7 @@ export class AgentSession {
1118
1083
  * Abort current operation and wait for agent to become idle.
1119
1084
  */
1120
1085
  async abort() {
1121
- this.abortRetry();
1086
+ this._retry.abort();
1122
1087
  this.agent.abort();
1123
1088
  await this.agent.waitForIdle();
1124
1089
  }
@@ -1300,325 +1265,35 @@ export class AgentSession {
1300
1265
  // =========================================================================
1301
1266
  // Compaction
1302
1267
  // =========================================================================
1303
- /**
1304
- * Shared core for manual and auto compaction.
1305
- *
1306
- * Runs the `session_before_compact` extension hook, produces the compaction
1307
- * (from an extension or by summarizing), persists it, updates agent context,
1308
- * and emits `session_compact`. Returns `{ status: "cancelled" }` if an
1309
- * extension cancels or the signal aborts; callers map that to their own
1310
- * cancel handling (manual throws, auto emits).
1311
- */
1312
- async _applyCompaction(params) {
1313
- const { preparation, branchEntries, model, apiKey, headers, customInstructions, signal } = params;
1314
- let extensionCompaction;
1315
- let fromExtension = false;
1316
- if (this._extensionRunner.hasHandlers("session_before_compact")) {
1317
- const result = (await this._extensionRunner.emit({
1318
- type: "session_before_compact",
1319
- preparation,
1320
- branchEntries,
1321
- customInstructions,
1322
- signal,
1323
- }));
1324
- if (result?.cancel) {
1325
- return { status: "cancelled" };
1326
- }
1327
- if (result?.compaction) {
1328
- extensionCompaction = result.compaction;
1329
- fromExtension = true;
1330
- }
1331
- }
1332
- const generated = extensionCompaction ??
1333
- (await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
1334
- if (signal.aborted) {
1335
- return { status: "cancelled" };
1336
- }
1337
- const { summary, firstKeptEntryId, tokensBefore, tokensAfter, details } = generated;
1338
- this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, tokensAfter);
1339
- const newEntries = this.sessionManager.getEntries();
1340
- this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
1341
- const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
1342
- if (this._extensionRunner && savedCompactionEntry) {
1343
- await this._extensionRunner.emit({
1344
- type: "session_compact",
1345
- compactionEntry: savedCompactionEntry,
1346
- fromExtension,
1347
- });
1348
- }
1349
- return {
1350
- status: "ok",
1351
- result: { summary, firstKeptEntryId, tokensBefore, tokensAfter: tokensAfter ?? tokensBefore, details },
1352
- };
1353
- }
1354
1268
  /**
1355
1269
  * Manually compact the session context.
1356
1270
  * Aborts current agent operation first.
1357
1271
  * @param customInstructions Optional instructions for the compaction summary
1358
1272
  */
1359
1273
  async compact(customInstructions) {
1360
- this._disconnectFromAgent();
1361
- await this.abort();
1362
- this._compactionAbortController = new AbortController();
1363
- this._emit({ type: "compaction_start", reason: "manual" });
1364
- try {
1365
- if (!this.model) {
1366
- throw new Error(formatNoModelSelectedMessage());
1367
- }
1368
- const { apiKey, headers } = await this._getRequiredRequestAuth(this.model);
1369
- const pathEntries = this.sessionManager.getBranch();
1370
- const settings = this.settingsManager.getCompactionSettings();
1371
- const preparation = prepareCompaction(pathEntries, settings);
1372
- if (!preparation) {
1373
- // Check why we can't compact
1374
- const lastEntry = pathEntries[pathEntries.length - 1];
1375
- if (lastEntry?.type === "compaction") {
1376
- throw new Error("Already compacted");
1377
- }
1378
- throw new Error("Nothing to compact (session too small)");
1379
- }
1380
- const applied = await this._applyCompaction({
1381
- preparation,
1382
- branchEntries: pathEntries,
1383
- model: this.model,
1384
- apiKey,
1385
- headers,
1386
- customInstructions,
1387
- signal: this._compactionAbortController.signal,
1388
- });
1389
- if (applied.status === "cancelled") {
1390
- throw new Error("Compaction cancelled");
1391
- }
1392
- const compactionResult = applied.result;
1393
- this._emit({
1394
- type: "compaction_end",
1395
- reason: "manual",
1396
- result: compactionResult,
1397
- aborted: false,
1398
- willRetry: false,
1399
- });
1400
- return compactionResult;
1401
- }
1402
- catch (error) {
1403
- const message = error instanceof Error ? error.message : String(error);
1404
- const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
1405
- this._emit({
1406
- type: "compaction_end",
1407
- reason: "manual",
1408
- result: undefined,
1409
- aborted,
1410
- willRetry: false,
1411
- errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
1412
- });
1413
- throw error;
1414
- }
1415
- finally {
1416
- this._compactionAbortController = undefined;
1417
- this._reconnectToAgent();
1418
- }
1274
+ return this._compaction.compact(customInstructions);
1419
1275
  }
1420
1276
  /**
1421
1277
  * Cancel in-progress compaction (manual or auto).
1422
1278
  */
1423
1279
  abortCompaction() {
1424
- this._compactionAbortController?.abort();
1425
- this._autoCompactionAbortController?.abort();
1280
+ this._compaction.abortCompaction();
1426
1281
  }
1427
1282
  /**
1428
1283
  * Cancel in-progress branch summarization.
1429
1284
  */
1430
1285
  abortBranchSummary() {
1431
- this._branchSummaryAbortController?.abort();
1432
- }
1433
- /**
1434
- * Check if compaction is needed and run it.
1435
- * Called after agent_end and before prompt submission.
1436
- *
1437
- * Two cases:
1438
- * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
1439
- * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
1440
- *
1441
- * @param assistantMessage The assistant message to check
1442
- * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1443
- */
1444
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
1445
- const settings = this.settingsManager.getCompactionSettings();
1446
- if (!settings.enabled)
1447
- return;
1448
- // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1449
- if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
1450
- return;
1451
- const contextWindow = this.model?.contextWindow ?? 0;
1452
- // Skip overflow check if the message came from a different model.
1453
- // This handles the case where user switched from a smaller-context model (e.g. opus)
1454
- // to a larger-context model (e.g. codex) - the overflow error from the old model
1455
- // shouldn't trigger compaction for the new model.
1456
- const sameModel = this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
1457
- // Skip compaction checks if this assistant message is older than the latest
1458
- // compaction boundary. This prevents a stale pre-compaction usage/error
1459
- // from retriggering compaction on the first prompt after compaction.
1460
- const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
1461
- const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
1462
- if (assistantIsFromBeforeCompaction) {
1463
- return;
1464
- }
1465
- // Case 1: Overflow - LLM returned context overflow error
1466
- if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
1467
- if (this._overflowRecoveryAttempted) {
1468
- this._emit({
1469
- type: "compaction_end",
1470
- reason: "overflow",
1471
- result: undefined,
1472
- aborted: false,
1473
- willRetry: false,
1474
- errorMessage: "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
1475
- });
1476
- return;
1477
- }
1478
- this._overflowRecoveryAttempted = true;
1479
- // Remove the error message from agent state (it IS saved to session for history,
1480
- // but we don't want it in context for the retry)
1481
- const messages = this.agent.state.messages;
1482
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
1483
- this.agent.state.messages = messages.slice(0, -1);
1484
- }
1485
- await this._runAutoCompaction("overflow", true);
1486
- return;
1487
- }
1488
- // Case 2: Threshold - context is getting large
1489
- // For error messages (no usage data), estimate from last successful response.
1490
- // This ensures sessions that hit persistent API errors (e.g. 529) can still compact.
1491
- let contextTokens;
1492
- if (assistantMessage.stopReason === "error") {
1493
- const messages = this.agent.state.messages;
1494
- const estimate = estimateContextTokens(messages);
1495
- if (estimate.lastUsageIndex === null)
1496
- return; // No usage data at all
1497
- // Verify the usage source is post-compaction. Kept pre-compaction messages
1498
- // have stale usage reflecting the old (larger) context and would falsely
1499
- // trigger compaction right after one just finished.
1500
- const usageMsg = messages[estimate.lastUsageIndex];
1501
- if (compactionEntry &&
1502
- usageMsg.role === "assistant" &&
1503
- usageMsg.timestamp <= new Date(compactionEntry.timestamp).getTime()) {
1504
- return;
1505
- }
1506
- contextTokens = estimate.tokens;
1507
- }
1508
- else {
1509
- contextTokens = calculateContextTokens(assistantMessage.usage);
1510
- }
1511
- if (shouldCompact(contextTokens, contextWindow, settings)) {
1512
- await this._runAutoCompaction("threshold", false);
1513
- }
1514
- }
1515
- /**
1516
- * Internal: Run auto-compaction with events.
1517
- */
1518
- async _runAutoCompaction(reason, willRetry) {
1519
- const settings = this.settingsManager.getCompactionSettings();
1520
- this._emit({ type: "compaction_start", reason });
1521
- this._autoCompactionAbortController = new AbortController();
1522
- try {
1523
- if (!this.model) {
1524
- this._emit({
1525
- type: "compaction_end",
1526
- reason,
1527
- result: undefined,
1528
- aborted: false,
1529
- willRetry: false,
1530
- });
1531
- return;
1532
- }
1533
- const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
1534
- if (!authResult.ok || !authResult.apiKey) {
1535
- this._emit({
1536
- type: "compaction_end",
1537
- reason,
1538
- result: undefined,
1539
- aborted: false,
1540
- willRetry: false,
1541
- });
1542
- return;
1543
- }
1544
- const { apiKey, headers } = authResult;
1545
- const pathEntries = this.sessionManager.getBranch();
1546
- const preparation = prepareCompaction(pathEntries, settings);
1547
- if (!preparation) {
1548
- this._emit({
1549
- type: "compaction_end",
1550
- reason,
1551
- result: undefined,
1552
- aborted: false,
1553
- willRetry: false,
1554
- });
1555
- return;
1556
- }
1557
- const applied = await this._applyCompaction({
1558
- preparation,
1559
- branchEntries: pathEntries,
1560
- model: this.model,
1561
- apiKey,
1562
- headers,
1563
- customInstructions: undefined,
1564
- signal: this._autoCompactionAbortController.signal,
1565
- });
1566
- if (applied.status === "cancelled") {
1567
- this._emit({
1568
- type: "compaction_end",
1569
- reason,
1570
- result: undefined,
1571
- aborted: true,
1572
- willRetry: false,
1573
- });
1574
- return;
1575
- }
1576
- const result = applied.result;
1577
- this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
1578
- if (willRetry) {
1579
- const messages = this.agent.state.messages;
1580
- const lastMsg = messages[messages.length - 1];
1581
- if (lastMsg?.role === "assistant" && lastMsg.stopReason === "error") {
1582
- this.agent.state.messages = messages.slice(0, -1);
1583
- }
1584
- setTimeout(() => {
1585
- this.agent.continue().catch(() => { });
1586
- }, 100);
1587
- }
1588
- else if (this.agent.hasQueuedMessages()) {
1589
- // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
1590
- // Kick the loop so queued messages are actually delivered.
1591
- setTimeout(() => {
1592
- this.agent.continue().catch(() => { });
1593
- }, 100);
1594
- }
1595
- }
1596
- catch (error) {
1597
- const errorMessage = error instanceof Error ? error.message : "compaction failed";
1598
- this._emit({
1599
- type: "compaction_end",
1600
- reason,
1601
- result: undefined,
1602
- aborted: false,
1603
- willRetry: false,
1604
- errorMessage: reason === "overflow"
1605
- ? `Context overflow recovery failed: ${errorMessage}`
1606
- : `Auto-compaction failed: ${errorMessage}`,
1607
- });
1608
- }
1609
- finally {
1610
- this._autoCompactionAbortController = undefined;
1611
- }
1286
+ this._tree.abortBranchSummary();
1612
1287
  }
1613
1288
  /**
1614
1289
  * Toggle auto-compaction setting.
1615
1290
  */
1616
1291
  setAutoCompactionEnabled(enabled) {
1617
- this.settingsManager.setCompactionEnabled(enabled);
1292
+ this._compaction.setAutoCompactionEnabled(enabled);
1618
1293
  }
1619
1294
  /** Whether auto-compaction is enabled */
1620
1295
  get autoCompactionEnabled() {
1621
- return this.settingsManager.getCompactionEnabled();
1296
+ return this._compaction.autoCompactionEnabled;
1622
1297
  }
1623
1298
  async bindExtensions(bindings) {
1624
1299
  if (bindings.uiContext !== undefined) {
@@ -1943,114 +1618,15 @@ export class AgentSession {
1943
1618
  // =========================================================================
1944
1619
  // Auto-Retry
1945
1620
  // =========================================================================
1946
- /**
1947
- * Check if an error is retryable (overloaded, rate limit, server errors).
1948
- * Context overflow errors are NOT retryable (handled by compaction instead).
1949
- */
1950
- _isRetryableError(message) {
1951
- if (message.stopReason !== "error" || !message.errorMessage)
1952
- return false;
1953
- // Context overflow is handled by compaction, not retry
1954
- const contextWindow = this.model?.contextWindow ?? 0;
1955
- if (isContextOverflow(message, contextWindow))
1956
- return false;
1957
- const err = message.errorMessage;
1958
- // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, request ended without sending chunks, HTTP/2 closed before response, terminated, retry delay exceeded
1959
- return RETRYABLE_ERROR_PATTERN.test(err);
1960
- }
1961
- /**
1962
- * Handle retryable errors with exponential backoff.
1963
- * @returns true if retry was initiated, false if max retries exceeded or disabled
1964
- */
1965
- async _handleRetryableError(message) {
1966
- const settings = this.settingsManager.getRetrySettings();
1967
- if (!settings.enabled) {
1968
- this._resolveRetry();
1969
- return false;
1970
- }
1971
- // Retry promise is created synchronously in _handleAgentEvent for agent_end.
1972
- // Keep a defensive fallback here in case a future refactor bypasses that path.
1973
- if (!this._retryPromise) {
1974
- this._retryPromise = new Promise((resolve) => {
1975
- this._retryResolve = resolve;
1976
- });
1977
- }
1978
- this._retryAttempt++;
1979
- if (this._retryAttempt > settings.maxRetries) {
1980
- // Max retries exceeded, emit final failure and reset
1981
- this._emit({
1982
- type: "auto_retry_end",
1983
- success: false,
1984
- attempt: this._retryAttempt - 1,
1985
- finalError: message.errorMessage,
1986
- });
1987
- this._retryAttempt = 0;
1988
- this._resolveRetry(); // Resolve so waitForRetry() completes
1989
- return false;
1990
- }
1991
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
1992
- this._emit({
1993
- type: "auto_retry_start",
1994
- attempt: this._retryAttempt,
1995
- maxAttempts: settings.maxRetries,
1996
- delayMs,
1997
- errorMessage: message.errorMessage || "Unknown error",
1998
- });
1999
- // Remove error message from agent state (keep in session for history)
2000
- const messages = this.agent.state.messages;
2001
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2002
- this.agent.state.messages = messages.slice(0, -1);
2003
- }
2004
- // Wait with exponential backoff (abortable)
2005
- this._retryAbortController = new AbortController();
2006
- try {
2007
- await sleep(delayMs, this._retryAbortController.signal);
2008
- }
2009
- catch {
2010
- // Aborted during sleep - emit end event so UI can clean up
2011
- const attempt = this._retryAttempt;
2012
- this._retryAttempt = 0;
2013
- this._retryAbortController = undefined;
2014
- this._emit({
2015
- type: "auto_retry_end",
2016
- success: false,
2017
- attempt,
2018
- finalError: "Retry cancelled",
2019
- });
2020
- this._resolveRetry();
2021
- return false;
2022
- }
2023
- this._retryAbortController = undefined;
2024
- // Retry via continue() - use setTimeout to break out of event handler chain
2025
- setTimeout(() => {
2026
- this.agent.continue().catch(() => {
2027
- // Retry failed - will be caught by next agent_end
2028
- });
2029
- }, 0);
2030
- return true;
2031
- }
2032
1621
  /**
2033
1622
  * Cancel in-progress retry.
2034
1623
  */
2035
1624
  abortRetry() {
2036
- this._retryAbortController?.abort();
2037
- // Note: _retryAttempt is reset in the catch block of _autoRetry
2038
- this._resolveRetry();
2039
- }
2040
- /**
2041
- * Wait for any in-progress retry to complete.
2042
- * Returns immediately if no retry is in progress.
2043
- */
2044
- async waitForRetry() {
2045
- if (!this._retryPromise) {
2046
- return;
2047
- }
2048
- await this._retryPromise;
2049
- await this.agent.waitForIdle();
1625
+ this._retry.abort();
2050
1626
  }
2051
1627
  /** Whether auto-retry is currently in progress */
2052
1628
  get isRetrying() {
2053
- return this._retryPromise !== undefined;
1629
+ return this._retry.isRetrying;
2054
1630
  }
2055
1631
  /** Whether auto-retry is enabled */
2056
1632
  get autoRetryEnabled() {
@@ -2173,275 +1749,31 @@ export class AgentSession {
2173
1749
  * @returns Result with editorText (if user message) and cancelled status
2174
1750
  */
2175
1751
  async navigateTree(targetId, options = {}) {
2176
- const oldLeafId = this.sessionManager.getLeafId();
2177
- // No-op if already at target
2178
- if (targetId === oldLeafId) {
2179
- return { cancelled: false };
2180
- }
2181
- // Model required for summarization
2182
- if (options.summarize && !this.model) {
2183
- throw new Error("No model available for summarization");
2184
- }
2185
- const targetEntry = this.sessionManager.getEntry(targetId);
2186
- if (!targetEntry) {
2187
- throw new Error(`Entry ${targetId} not found`);
2188
- }
2189
- // Collect entries to summarize (from old leaf to common ancestor)
2190
- const { entries: entriesToSummarize, commonAncestorId } = await collectEntriesForBranchSummary(this.sessionManager, oldLeafId, targetId);
2191
- // Prepare event data - mutable so extensions can override
2192
- let customInstructions = options.customInstructions;
2193
- let replaceInstructions = options.replaceInstructions;
2194
- let label = options.label;
2195
- const preparation = {
2196
- targetId,
2197
- oldLeafId,
2198
- commonAncestorId,
2199
- entriesToSummarize,
2200
- userWantsSummary: options.summarize ?? false,
2201
- customInstructions,
2202
- replaceInstructions,
2203
- label,
2204
- };
2205
- // Set up abort controller for summarization
2206
- this._branchSummaryAbortController = new AbortController();
2207
- try {
2208
- let extensionSummary;
2209
- let fromExtension = false;
2210
- // Emit session_before_tree event
2211
- if (this._extensionRunner.hasHandlers("session_before_tree")) {
2212
- const result = (await this._extensionRunner.emit({
2213
- type: "session_before_tree",
2214
- preparation,
2215
- signal: this._branchSummaryAbortController.signal,
2216
- }));
2217
- if (result?.cancel) {
2218
- return { cancelled: true };
2219
- }
2220
- if (result?.summary && options.summarize) {
2221
- extensionSummary = result.summary;
2222
- fromExtension = true;
2223
- }
2224
- // Allow extensions to override instructions and label
2225
- if (result?.customInstructions !== undefined) {
2226
- customInstructions = result.customInstructions;
2227
- }
2228
- if (result?.replaceInstructions !== undefined) {
2229
- replaceInstructions = result.replaceInstructions;
2230
- }
2231
- if (result?.label !== undefined) {
2232
- label = result.label;
2233
- }
2234
- }
2235
- // Run default summarizer if needed
2236
- let summaryText;
2237
- let summaryDetails;
2238
- if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
2239
- const model = this.model;
2240
- const { apiKey, headers } = await this._getRequiredRequestAuth(model);
2241
- const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
2242
- const result = await generateBranchSummary(entriesToSummarize, {
2243
- model,
2244
- apiKey,
2245
- headers,
2246
- signal: this._branchSummaryAbortController.signal,
2247
- customInstructions,
2248
- replaceInstructions,
2249
- reserveTokens: branchSummarySettings.reserveTokens,
2250
- });
2251
- if (result.aborted) {
2252
- return { cancelled: true, aborted: true };
2253
- }
2254
- if (result.error) {
2255
- throw new Error(result.error);
2256
- }
2257
- summaryText = result.summary;
2258
- summaryDetails = {
2259
- readFiles: result.readFiles || [],
2260
- modifiedFiles: result.modifiedFiles || [],
2261
- };
2262
- }
2263
- else if (extensionSummary) {
2264
- summaryText = extensionSummary.summary;
2265
- summaryDetails = extensionSummary.details;
2266
- }
2267
- // Determine the new leaf position based on target type
2268
- let newLeafId;
2269
- let editorText;
2270
- if (targetEntry.type === "message" && targetEntry.message.role === "user") {
2271
- // User message: leaf = parent (null if root), text goes to editor
2272
- newLeafId = targetEntry.parentId;
2273
- editorText = this._extractUserMessageText(targetEntry.message.content);
2274
- }
2275
- else if (targetEntry.type === "custom_message") {
2276
- // Custom message: leaf = parent (null if root), text goes to editor
2277
- newLeafId = targetEntry.parentId;
2278
- editorText =
2279
- typeof targetEntry.content === "string"
2280
- ? targetEntry.content
2281
- : targetEntry.content
2282
- .filter((c) => c.type === "text")
2283
- .map((c) => c.text)
2284
- .join("");
2285
- }
2286
- else {
2287
- // Non-user message: leaf = selected node
2288
- newLeafId = targetId;
2289
- }
2290
- // Switch leaf (with or without summary)
2291
- // Summary is attached at the navigation target position (newLeafId), not the old branch
2292
- let summaryEntry;
2293
- if (summaryText) {
2294
- // Create summary at target position (can be null for root)
2295
- const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
2296
- summaryEntry = this.sessionManager.getEntry(summaryId);
2297
- // Attach label to the summary entry
2298
- if (label) {
2299
- this.sessionManager.appendLabelChange(summaryId, label);
2300
- }
2301
- }
2302
- else if (newLeafId === null) {
2303
- // No summary, navigating to root - reset leaf
2304
- this.sessionManager.resetLeaf();
2305
- }
2306
- else {
2307
- // No summary, navigating to non-root
2308
- this.sessionManager.branch(newLeafId);
2309
- }
2310
- // Attach label to target entry when not summarizing (no summary entry to label)
2311
- if (label && !summaryText) {
2312
- this.sessionManager.appendLabelChange(targetId, label);
2313
- }
2314
- // Update agent state
2315
- const sessionContext = this.sessionManager.buildSessionContext();
2316
- this.agent.state.messages = sessionContext.messages;
2317
- // Emit session_tree event
2318
- await this._extensionRunner.emit({
2319
- type: "session_tree",
2320
- newLeafId: this.sessionManager.getLeafId(),
2321
- oldLeafId,
2322
- summaryEntry,
2323
- fromExtension: summaryText ? fromExtension : undefined,
2324
- });
2325
- // Emit to custom tools
2326
- return { editorText, cancelled: false, summaryEntry };
2327
- }
2328
- finally {
2329
- this._branchSummaryAbortController = undefined;
2330
- }
1752
+ return this._tree.navigateTree(targetId, options);
2331
1753
  }
2332
1754
  /**
2333
1755
  * Get all user messages from session for fork selector.
2334
1756
  */
2335
1757
  getUserMessagesForForking() {
2336
- const entries = this.sessionManager.getEntries();
2337
- const result = [];
2338
- for (const entry of entries) {
2339
- if (entry.type !== "message")
2340
- continue;
2341
- if (entry.message.role !== "user")
2342
- continue;
2343
- const text = this._extractUserMessageText(entry.message.content);
2344
- if (text) {
2345
- result.push({ entryId: entry.id, text });
2346
- }
2347
- }
2348
- return result;
2349
- }
2350
- _extractUserMessageText(content) {
2351
- if (typeof content === "string")
2352
- return content;
2353
- if (Array.isArray(content)) {
2354
- return content
2355
- .filter((c) => c.type === "text")
2356
- .map((c) => c.text)
2357
- .join("");
2358
- }
2359
- return "";
1758
+ return collectUserMessagesForForking(this.sessionManager);
2360
1759
  }
2361
1760
  /**
2362
1761
  * Get session statistics.
2363
1762
  */
2364
1763
  getSessionStats() {
2365
- const state = this.state;
2366
- const userMessages = state.messages.filter((m) => m.role === "user").length;
2367
- const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
2368
- const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
2369
- let toolCalls = 0;
2370
- let totalInput = 0;
2371
- let totalOutput = 0;
2372
- let totalCacheRead = 0;
2373
- let totalCacheWrite = 0;
2374
- let totalCost = 0;
2375
- for (const message of state.messages) {
2376
- if (message.role === "assistant") {
2377
- const assistantMsg = message;
2378
- toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
2379
- totalInput += assistantMsg.usage.input;
2380
- totalOutput += assistantMsg.usage.output;
2381
- totalCacheRead += assistantMsg.usage.cacheRead;
2382
- totalCacheWrite += assistantMsg.usage.cacheWrite;
2383
- totalCost += assistantMsg.usage.cost.total;
2384
- }
2385
- }
2386
- return {
1764
+ return computeSessionStats({
1765
+ messages: this.state.messages,
2387
1766
  sessionFile: this.sessionFile,
2388
1767
  sessionId: this.sessionId,
2389
- userMessages,
2390
- assistantMessages,
2391
- toolCalls,
2392
- toolResults,
2393
- totalMessages: state.messages.length,
2394
- tokens: {
2395
- input: totalInput,
2396
- output: totalOutput,
2397
- cacheRead: totalCacheRead,
2398
- cacheWrite: totalCacheWrite,
2399
- total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
2400
- },
2401
- cost: totalCost,
2402
1768
  contextUsage: this.getContextUsage(),
2403
- };
1769
+ });
2404
1770
  }
2405
1771
  getContextUsage() {
2406
- const model = this.model;
2407
- if (!model)
2408
- return undefined;
2409
- const contextWindow = model.contextWindow ?? 0;
2410
- if (contextWindow <= 0)
2411
- return undefined;
2412
- // After compaction, the last assistant usage reflects pre-compaction context size.
2413
- // We can only trust usage from an assistant that responded after the latest compaction.
2414
- // If no such assistant exists, context token count is unknown until the next LLM response.
2415
- const branchEntries = this.sessionManager.getBranch();
2416
- const latestCompaction = getLatestCompactionEntry(branchEntries);
2417
- if (latestCompaction) {
2418
- // Check if there's a valid assistant usage after the compaction boundary
2419
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
2420
- let hasPostCompactionUsage = false;
2421
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
2422
- const entry = branchEntries[i];
2423
- if (entry.type === "message" && entry.message.role === "assistant") {
2424
- const assistant = entry.message;
2425
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
2426
- const contextTokens = calculateContextTokens(assistant.usage);
2427
- if (contextTokens > 0) {
2428
- hasPostCompactionUsage = true;
2429
- }
2430
- break;
2431
- }
2432
- }
2433
- }
2434
- if (!hasPostCompactionUsage) {
2435
- return { tokens: null, contextWindow, percent: null };
2436
- }
2437
- }
2438
- const estimate = estimateContextTokens(this.messages);
2439
- const percent = (estimate.tokens / contextWindow) * 100;
2440
- return {
2441
- tokens: estimate.tokens,
2442
- contextWindow,
2443
- percent,
2444
- };
1772
+ return computeContextUsage({
1773
+ model: this.model,
1774
+ sessionManager: this.sessionManager,
1775
+ messages: this.messages,
1776
+ });
2445
1777
  }
2446
1778
  /**
2447
1779
  * Export session to HTML.
@@ -2469,29 +1801,7 @@ export class AgentSession {
2469
1801
  * @returns The resolved output file path.
2470
1802
  */
2471
1803
  exportToJsonl(outputPath) {
2472
- const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
2473
- const dir = dirname(filePath);
2474
- if (!existsSync(dir)) {
2475
- mkdirSync(dir, { recursive: true });
2476
- }
2477
- const header = {
2478
- type: "session",
2479
- version: CURRENT_SESSION_VERSION,
2480
- id: this.sessionManager.getSessionId(),
2481
- timestamp: new Date().toISOString(),
2482
- cwd: this.sessionManager.getCwd(),
2483
- };
2484
- const branchEntries = this.sessionManager.getBranch();
2485
- const lines = [JSON.stringify(header)];
2486
- // Re-chain parentIds to form a linear sequence
2487
- let prevId = null;
2488
- for (const entry of branchEntries) {
2489
- const linear = { ...entry, parentId: prevId };
2490
- lines.push(JSON.stringify(linear));
2491
- prevId = entry.id;
2492
- }
2493
- writeFileSync(filePath, `${lines.join("\n")}\n`);
2494
- return filePath;
1804
+ return exportSessionBranchToJsonl(this.sessionManager, outputPath);
2495
1805
  }
2496
1806
  // =========================================================================
2497
1807
  // Utilities
@@ -2502,27 +1812,7 @@ export class AgentSession {
2502
1812
  * @returns Text content, or undefined if no assistant message exists
2503
1813
  */
2504
1814
  getLastAssistantText() {
2505
- const lastAssistant = this.messages
2506
- .slice()
2507
- .reverse()
2508
- .find((m) => {
2509
- if (m.role !== "assistant")
2510
- return false;
2511
- const msg = m;
2512
- // Skip aborted messages with no content
2513
- if (msg.stopReason === "aborted" && msg.content.length === 0)
2514
- return false;
2515
- return true;
2516
- });
2517
- if (!lastAssistant)
2518
- return undefined;
2519
- let text = "";
2520
- for (const content of lastAssistant.content) {
2521
- if (content.type === "text") {
2522
- text += content.text;
2523
- }
2524
- }
2525
- return text.trim() || undefined;
1815
+ return getLastAssistantText(this.messages);
2526
1816
  }
2527
1817
  // =========================================================================
2528
1818
  // Extension System