@mariozechner/pi-coding-agent 0.13.2 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/README.md +62 -0
- package/dist/compaction.d.ts.map +1 -1
- package/dist/compaction.js +5 -2
- package/dist/compaction.js.map +1 -1
- package/dist/export-html.d.ts.map +1 -1
- package/dist/export-html.js +52 -25
- package/dist/export-html.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +120 -9
- package/dist/main.js.map +1 -1
- package/dist/messages.d.ts +43 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +71 -0
- package/dist/messages.js.map +1 -0
- package/dist/model-config.d.ts.map +1 -1
- package/dist/model-config.js +9 -0
- package/dist/model-config.js.map +1 -1
- package/dist/settings-manager.d.ts +6 -3
- package/dist/settings-manager.d.ts.map +1 -1
- package/dist/settings-manager.js +7 -0
- package/dist/settings-manager.js.map +1 -1
- package/dist/shell.d.ts +16 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +108 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/dark.json +4 -1
- package/dist/theme/light.json +4 -1
- package/dist/theme/theme-schema.json +28 -0
- package/dist/theme/theme.d.ts +3 -2
- package/dist/theme/theme.d.ts.map +1 -1
- package/dist/theme/theme.js +32 -3
- package/dist/theme/theme.js.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +3 -107
- package/dist/tools/bash.js.map +1 -1
- package/dist/tui/bash-execution.d.ts +33 -0
- package/dist/tui/bash-execution.d.ts.map +1 -0
- package/dist/tui/bash-execution.js +134 -0
- package/dist/tui/bash-execution.js.map +1 -0
- package/dist/tui/tui-renderer.d.ts +7 -1
- package/dist/tui/tui-renderer.d.ts.map +1 -1
- package/dist/tui/tui-renderer.js +225 -10
- package/dist/tui/tui-renderer.js.map +1 -1
- package/package.json +4 -4
- package/dist/fuzzy.test.d.ts +0 -2
- package/dist/fuzzy.test.d.ts.map +0 -1
- package/dist/fuzzy.test.js +0 -76
- package/dist/fuzzy.test.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [0.14.1] - 2025-12-08
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Fix build errors with tsgo 7.0.0-dev.20251208.1 by properly importing `ReasoningEffort` type
|
|
8
|
+
|
|
9
|
+
## [0.14.0] - 2025-12-08
|
|
10
|
+
|
|
11
|
+
### Breaking Changes
|
|
12
|
+
|
|
13
|
+
- **Custom themes require new color tokens**: Themes must now include `thinkingXhigh` and `bashMode` color tokens. The theme loader provides helpful error messages listing missing tokens. See built-in themes (dark.json, light.json) for reference values.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **OpenAI compatibility overrides in models.json**: Custom models using `openai-completions` API can now specify a `compat` object to override provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR)
|
|
18
|
+
|
|
19
|
+
- **xhigh thinking level**: Added `xhigh` thinking level for OpenAI codex-max models. Cycle through thinking levels with Shift+Tab; `xhigh` appears only when using a codex-max model. ([#143](https://github.com/badlogic/pi-mono/issues/143))
|
|
20
|
+
|
|
21
|
+
- **Collapse changelog setting**: Add `"collapseChangelog": true` to `~/.pi/agent/settings.json` to show a condensed "Updated to vX.Y.Z" message instead of the full changelog after updates. Use `/changelog` to view the full changelog. ([#148](https://github.com/badlogic/pi-mono/issues/148))
|
|
22
|
+
|
|
23
|
+
- **Bash mode**: Execute shell commands directly from the editor by prefixing with `!` (e.g., `!ls -la`). Output streams in real-time, is added to the LLM context, and persists in session history. Supports multiline commands, cancellation (Escape), truncation for large outputs, and preview/expand toggle (Ctrl+O). Also available in RPC mode via `{"type":"bash","command":"..."}`. ([#112](https://github.com/badlogic/pi-mono/pull/112), original implementation by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
|
4
24
|
|
|
5
25
|
## [0.13.2] - 2025-12-07
|
|
6
26
|
|
package/README.md
CHANGED
|
@@ -315,6 +315,47 @@ You can add custom HTTP headers to bypass Cloudflare bot detection, add authenti
|
|
|
315
315
|
- **Model-level `headers`**: Additional headers for specific models (merged with provider headers)
|
|
316
316
|
- Model headers override provider headers when keys conflict
|
|
317
317
|
|
|
318
|
+
### OpenAI Compatibility Settings
|
|
319
|
+
|
|
320
|
+
The `openai-completions` API is implemented by many providers with minor differences (Ollama, vLLM, LiteLLM, llama.cpp, etc.). By default, compatibility settings are auto-detected from the `baseUrl`. For custom proxies or unknown endpoints, you can override these via the `compat` field on models:
|
|
321
|
+
|
|
322
|
+
```json
|
|
323
|
+
{
|
|
324
|
+
"providers": {
|
|
325
|
+
"litellm": {
|
|
326
|
+
"baseUrl": "http://localhost:4000/v1",
|
|
327
|
+
"apiKey": "LITELLM_API_KEY",
|
|
328
|
+
"api": "openai-completions",
|
|
329
|
+
"models": [
|
|
330
|
+
{
|
|
331
|
+
"id": "gpt-4o",
|
|
332
|
+
"name": "GPT-4o (via LiteLLM)",
|
|
333
|
+
"reasoning": false,
|
|
334
|
+
"input": ["text", "image"],
|
|
335
|
+
"cost": {"input": 2.5, "output": 10, "cacheRead": 0, "cacheWrite": 0},
|
|
336
|
+
"contextWindow": 128000,
|
|
337
|
+
"maxTokens": 16384,
|
|
338
|
+
"compat": {
|
|
339
|
+
"supportsStore": false
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Available `compat` fields (all optional, auto-detected if not set):
|
|
349
|
+
|
|
350
|
+
| Field | Type | Default | Description |
|
|
351
|
+
|-------|------|---------|-------------|
|
|
352
|
+
| `supportsStore` | boolean | auto | Whether provider supports the `store` field |
|
|
353
|
+
| `supportsDeveloperRole` | boolean | auto | Whether provider supports `developer` role (vs `system`) |
|
|
354
|
+
| `supportsReasoningEffort` | boolean | auto | Whether provider supports `reasoning_effort` parameter |
|
|
355
|
+
| `maxTokensField` | string | auto | Use `"max_completion_tokens"` or `"max_tokens"` |
|
|
356
|
+
|
|
357
|
+
If `compat` is partially set, unspecified fields use auto-detected values.
|
|
358
|
+
|
|
318
359
|
### Authorization Header
|
|
319
360
|
|
|
320
361
|
Some providers require an explicit `Authorization: Bearer <token>` header. Set `authHeader: true` to automatically add this header using the resolved `apiKey`:
|
|
@@ -721,6 +762,27 @@ You can submit multiple messages while the agent is processing without waiting f
|
|
|
721
762
|
|
|
722
763
|
Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settings.json`.
|
|
723
764
|
|
|
765
|
+
### Bash Mode (`!`)
|
|
766
|
+
|
|
767
|
+
Execute shell commands directly and add output to the LLM context by prefixing with `!`:
|
|
768
|
+
|
|
769
|
+
```
|
|
770
|
+
!ls -la
|
|
771
|
+
!git status
|
|
772
|
+
!cat package.json | jq '.dependencies'
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
**Features:**
|
|
776
|
+
- **Streaming output**: Command output streams in real-time as it executes
|
|
777
|
+
- **Multiline commands**: Write complex commands across multiple lines
|
|
778
|
+
- **Cancellation**: Press **Escape** to cancel a running command
|
|
779
|
+
- **Truncation**: Large outputs are truncated (2000 lines / 50KB) with full output saved to a temp file
|
|
780
|
+
- **Preview mode**: Shows last 20 lines by default; press **Ctrl+O** to expand
|
|
781
|
+
- **History**: Commands are added to editor history (navigate with Up/Down arrows)
|
|
782
|
+
- **Visual feedback**: Editor border turns green in bash mode; cancelled commands show yellow warning
|
|
783
|
+
|
|
784
|
+
Output is automatically added to the conversation context, allowing the LLM to see command results without manual copy-paste.
|
|
785
|
+
|
|
724
786
|
### Keyboard Shortcuts
|
|
725
787
|
|
|
726
788
|
**Navigation:**
|
package/dist/compaction.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAoB,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAE1E,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAM1E,MAAM,WAAW,kBAAkB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;CACzB;AAED,eAAO,MAAM,2BAA2B,EAAE,kBAIzC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAE3D;AAeD;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,KAAK,GAAG,IAAI,CAS3E;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAGjH;AAoBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,YAAY,EAAE,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,GACtB,MAAM,CAgDR;AAiBD;;GAEG;AACH,wBAAsB,eAAe,CACpC,eAAe,EAAE,UAAU,EAAE,EAC7B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,MAAM,CAAC,CAwBjB;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,OAAO,CAC5B,OAAO,EAAE,YAAY,EAAE,EACvB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,QAAQ,EAAE,kBAAkB,EAC5B,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,eAAe,CAAC,CA6D1B","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n * Uses the native totalTokens field when available, falls back to computing from components.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\tconst summarizationMessages = [\n\t\t...currentMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: prompt,\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAoB,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAG1E,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAM1E,MAAM,WAAW,kBAAkB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;CACzB;AAED,eAAO,MAAM,2BAA2B,EAAE,kBAIzC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAE3D;AAeD;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,KAAK,GAAG,IAAI,CAS3E;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAGjH;AAoBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,YAAY,EAAE,EACvB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,GACtB,MAAM,CAgDR;AAiBD;;GAEG;AACH,wBAAsB,eAAe,CACpC,eAAe,EAAE,UAAU,EAAE,EAC7B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,MAAM,CAAC,CA2BjB;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,OAAO,CAC5B,OAAO,EAAE,YAAY,EAAE,EACvB,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACjB,QAAQ,EAAE,kBAAkB,EAC5B,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,WAAW,EACpB,kBAAkB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,eAAe,CAAC,CA6D1B","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport { messageTransformer } from \"./messages.js\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n * Uses the native totalTokens field when available, falls back to computing from components.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\t// Transform custom messages (like bashExecution) to LLM-compatible messages\n\tconst transformedMessages = messageTransformer(currentMessages);\n\n\tconst summarizationMessages = [\n\t\t...transformedMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: prompt }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
|
package/dist/compaction.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and after compaction the session is reloaded.
|
|
6
6
|
*/
|
|
7
7
|
import { complete } from "@mariozechner/pi-ai";
|
|
8
|
+
import { messageTransformer } from "./messages.js";
|
|
8
9
|
export const DEFAULT_COMPACTION_SETTINGS = {
|
|
9
10
|
enabled: true,
|
|
10
11
|
reserveTokens: 16384,
|
|
@@ -139,11 +140,13 @@ export async function generateSummary(currentMessages, model, reserveTokens, api
|
|
|
139
140
|
const prompt = customInstructions
|
|
140
141
|
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
|
|
141
142
|
: SUMMARIZATION_PROMPT;
|
|
143
|
+
// Transform custom messages (like bashExecution) to LLM-compatible messages
|
|
144
|
+
const transformedMessages = messageTransformer(currentMessages);
|
|
142
145
|
const summarizationMessages = [
|
|
143
|
-
...
|
|
146
|
+
...transformedMessages,
|
|
144
147
|
{
|
|
145
148
|
role: "user",
|
|
146
|
-
content: prompt,
|
|
149
|
+
content: [{ type: "text", text: prompt }],
|
|
147
150
|
timestamp: Date.now(),
|
|
148
151
|
},
|
|
149
152
|
];
|
package/dist/compaction.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compaction.js","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n * Uses the native totalTokens field when available, falls back to computing from components.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\tconst summarizationMessages = [\n\t\t...currentMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: prompt,\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"compaction.js","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Context compaction for long sessions.\n *\n * Pure functions for compaction logic. The session manager handles I/O,\n * and after compaction the session is reloaded.\n */\n\nimport type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Model, Usage } from \"@mariozechner/pi-ai\";\nimport { complete } from \"@mariozechner/pi-ai\";\nimport { messageTransformer } from \"./messages.js\";\nimport type { CompactionEntry, SessionEntry } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CompactionSettings {\n\tenabled: boolean;\n\treserveTokens: number;\n\tkeepRecentTokens: number;\n}\n\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n// ============================================================================\n// Token calculation\n// ============================================================================\n\n/**\n * Calculate total context tokens from usage.\n * Uses the native totalTokens field when available, falls back to computing from components.\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Get usage from an assistant message if available.\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\" && \"usage\" in msg) {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (assistantMsg.stopReason !== \"aborted\" && assistantMsg.usage) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n */\nexport function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if compaction should trigger based on context usage.\n */\nexport function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// ============================================================================\n// Cut point detection\n// ============================================================================\n\n/**\n * Find indices of message entries that are user messages (turn boundaries).\n */\nfunction findTurnBoundaries(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the cut point in session entries that keeps approximately `keepRecentTokens`.\n * Returns the entry index of the first message to keep (a user message for turn integrity).\n *\n * Only considers entries between `startIndex` and `endIndex` (exclusive).\n */\nexport function findCutPoint(\n\tentries: SessionEntry[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(entries, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message);\n\t\t\tif (usage) {\n\t\t\t\tassistantUsages.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1];\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0].tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i].tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1].index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tif (boundaries[b] <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundaries[b];\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n// ============================================================================\n// Summarization\n// ============================================================================\n\nconst SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- Absolute file paths of any relevant files that were read or modified\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.`;\n\n/**\n * Generate a summary of the conversation using the LLM.\n */\nexport async function generateSummary(\n\tcurrentMessages: AppMessage[],\n\tmodel: Model<any>,\n\treserveTokens: number,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<string> {\n\tconst maxTokens = Math.floor(0.8 * reserveTokens);\n\n\tconst prompt = customInstructions\n\t\t? `${SUMMARIZATION_PROMPT}\\n\\nAdditional focus: ${customInstructions}`\n\t\t: SUMMARIZATION_PROMPT;\n\n\t// Transform custom messages (like bashExecution) to LLM-compatible messages\n\tconst transformedMessages = messageTransformer(currentMessages);\n\n\tconst summarizationMessages = [\n\t\t...transformedMessages,\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: prompt }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\tconst response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });\n\n\tconst textContent = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\treturn textContent;\n}\n\n// ============================================================================\n// Main compaction function\n// ============================================================================\n\n/**\n * Calculate compaction and generate summary.\n * Returns the CompactionEntry to append to the session file.\n *\n * @param entries - All session entries\n * @param model - Model to use for summarization\n * @param settings - Compaction settings\n * @param apiKey - API key for LLM\n * @param signal - Optional abort signal\n * @param customInstructions - Optional custom focus for the summary\n */\nexport async function compact(\n\tentries: SessionEntry[],\n\tmodel: Model<any>,\n\tsettings: CompactionSettings,\n\tapiKey: string,\n\tsignal?: AbortSignal,\n\tcustomInstructions?: string,\n): Promise<CompactionEntry> {\n\t// Don't compact if the last entry is already a compaction\n\tif (entries.length > 0 && entries[entries.length - 1].type === \"compaction\") {\n\t\tthrow new Error(\"Already compacted\");\n\t}\n\n\t// Find previous compaction boundary\n\tlet prevCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tprevCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\tconst boundaryStart = prevCompactionIndex + 1;\n\tconst boundaryEnd = entries.length;\n\n\t// Get token count before compaction\n\tconst lastUsage = getLastAssistantUsage(entries);\n\tconst tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;\n\n\t// Find cut point (entry index) within the valid range\n\tconst firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);\n\n\t// Extract messages to summarize (before the cut point)\n\tconst messagesToSummarize: AppMessage[] = [];\n\tfor (let i = boundaryStart; i < firstKeptEntryIndex; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tmessagesToSummarize.push(entry.message);\n\t\t}\n\t}\n\n\t// Also include the previous summary if there was a compaction\n\tif (prevCompactionIndex >= 0) {\n\t\tconst prevCompaction = entries[prevCompactionIndex] as CompactionEntry;\n\t\t// Prepend the previous summary as context\n\t\tmessagesToSummarize.unshift({\n\t\t\trole: \"user\",\n\t\t\tcontent: `Previous session summary:\\n${prevCompaction.summary}`,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t// Generate summary from messages before the cut point\n\tconst summary = await generateSummary(\n\t\tmessagesToSummarize,\n\t\tmodel,\n\t\tsettings.reserveTokens,\n\t\tapiKey,\n\t\tsignal,\n\t\tcustomInstructions,\n\t);\n\n\treturn {\n\t\ttype: \"compaction\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tsummary,\n\t\tfirstKeptEntryIndex,\n\t\ttokensBefore,\n\t};\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"export-html.d.ts","sourceRoot":"","sources":["../src/export-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAovB3D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAoBlH;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAgB7E","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, ToolResultMessage, UserMessage } from \"@mariozechner/pi-ai\";\nimport { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename } from \"path\";\nimport { APP_NAME, VERSION } from \"./config.js\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface MessageEvent {\n\ttype: \"message\";\n\tmessage: Message;\n\ttimestamp?: number;\n}\n\ninterface ModelChangeEvent {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n\ttimestamp?: number;\n}\n\ninterface CompactionEvent {\n\ttype: \"compaction\";\n\ttimestamp: string;\n\tsummary: string;\n\ttokensBefore: number;\n}\n\ntype SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent;\n\ninterface ParsedSessionData {\n\tsessionId: string;\n\ttimestamp: string;\n\tsystemPrompt?: string;\n\tmodelsUsed: Set<string>;\n\tmessages: Message[];\n\ttoolResultsMap: Map<string, ToolResultMessage>;\n\tsessionEvents: SessionEvent[];\n\ttokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\tcostStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\ttools?: { name: string; description: string }[];\n\tcontextWindow?: number;\n\tisStreamingFormat?: boolean;\n}\n\n// ============================================================================\n// Color scheme (matching TUI)\n// ============================================================================\n\nconst COLORS = {\n\tuserMessageBg: \"rgb(52, 53, 65)\",\n\ttoolPendingBg: \"rgb(40, 40, 50)\",\n\ttoolSuccessBg: \"rgb(40, 50, 40)\",\n\ttoolErrorBg: \"rgb(60, 40, 40)\",\n\tbodyBg: \"rgb(24, 24, 30)\",\n\tcontainerBg: \"rgb(30, 30, 36)\",\n\ttext: \"rgb(229, 229, 231)\",\n\ttextDim: \"rgb(161, 161, 170)\",\n\tcyan: \"rgb(103, 232, 249)\",\n\tgreen: \"rgb(34, 197, 94)\",\n\tred: \"rgb(239, 68, 68)\",\n\tyellow: \"rgb(234, 179, 8)\",\n};\n\n// ============================================================================\n// Utility functions\n// ============================================================================\n\nfunction escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nfunction shortenPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? \"~\" + path.slice(home.length) : path;\n}\n\nfunction replaceTabs(text: string): string {\n\treturn text.replace(/\\t/g, \" \");\n}\n\nfunction formatTimestamp(timestamp: number | string | undefined): string {\n\tif (!timestamp) return \"\";\n\tconst date = new Date(typeof timestamp === \"string\" ? timestamp : timestamp);\n\treturn date.toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n}\n\n// ============================================================================\n// Parsing functions\n// ============================================================================\n\nfunction parseSessionManagerFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n\n\tfor (const line of lines) {\n\t\tlet entry: { type: string; [key: string]: unknown };\n\t\ttry {\n\t\t\tentry = JSON.parse(line) as { type: string; [key: string]: unknown };\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"session\":\n\t\t\t\tdata.sessionId = (entry.id as string) || \"unknown\";\n\t\t\t\tdata.timestamp = (entry.timestamp as string) || data.timestamp;\n\t\t\t\tdata.systemPrompt = entry.systemPrompt as string | undefined;\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string);\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message\": {\n\t\t\t\tconst message = entry.message as Message;\n\t\t\t\tdata.messages.push(message);\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"message\",\n\t\t\t\t\tmessage,\n\t\t\t\t\ttimestamp: entry.timestamp as number | undefined,\n\t\t\t\t});\n\n\t\t\t\tif (message.role === \"toolResult\") {\n\t\t\t\t\tconst toolResult = message as ToolResultMessage;\n\t\t\t\t\tdata.toolResultsMap.set(toolResult.toolCallId, toolResult);\n\t\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\t\tdata.tokenStats.input += assistantMsg.usage.input || 0;\n\t\t\t\t\t\tdata.tokenStats.output += assistantMsg.usage.output || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;\n\t\t\t\t\t\tif (assistantMsg.usage.cost) {\n\t\t\t\t\t\t\tdata.costStats.input += assistantMsg.usage.cost.input || 0;\n\t\t\t\t\t\t\tdata.costStats.output += assistantMsg.usage.cost.output || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"model_change\":\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"model_change\",\n\t\t\t\t\tprovider: entry.provider as string,\n\t\t\t\t\tmodelId: entry.modelId as string,\n\t\t\t\t\ttimestamp: entry.timestamp as number | undefined,\n\t\t\t\t});\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string);\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"compaction\":\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"compaction\",\n\t\t\t\t\ttimestamp: entry.timestamp as string,\n\t\t\t\t\tsummary: entry.summary as string,\n\t\t\t\t\ttokensBefore: entry.tokensBefore as number,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn data;\n}\n\nfunction parseStreamingEventFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tisStreamingFormat: true,\n\t};\n\n\tlet timestampSet = false;\n\n\tfor (const line of lines) {\n\t\tlet entry: { type: string; message?: Message };\n\t\ttry {\n\t\t\tentry = JSON.parse(line) as { type: string; message?: Message };\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.type === \"message_end\" && entry.message) {\n\t\t\tconst msg = entry.message;\n\t\t\tdata.messages.push(msg);\n\t\t\tdata.sessionEvents.push({\n\t\t\t\ttype: \"message\",\n\t\t\t\tmessage: msg,\n\t\t\t\ttimestamp: (msg as { timestamp?: number }).timestamp,\n\t\t\t});\n\n\t\t\tif (msg.role === \"toolResult\") {\n\t\t\t\tconst toolResult = msg as ToolResultMessage;\n\t\t\t\tdata.toolResultsMap.set(toolResult.toolCallId, toolResult);\n\t\t\t} else if (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.model) {\n\t\t\t\t\tconst modelInfo = assistantMsg.provider\n\t\t\t\t\t\t? `${assistantMsg.provider}/${assistantMsg.model}`\n\t\t\t\t\t\t: assistantMsg.model;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\tdata.tokenStats.input += assistantMsg.usage.input || 0;\n\t\t\t\t\tdata.tokenStats.output += assistantMsg.usage.output || 0;\n\t\t\t\t\tdata.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;\n\t\t\t\t\tdata.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;\n\t\t\t\t\tif (assistantMsg.usage.cost) {\n\t\t\t\t\t\tdata.costStats.input += assistantMsg.usage.cost.input || 0;\n\t\t\t\t\t\tdata.costStats.output += assistantMsg.usage.cost.output || 0;\n\t\t\t\t\t\tdata.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\tdata.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!timestampSet && (msg as { timestamp?: number }).timestamp) {\n\t\t\t\tdata.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString();\n\t\t\t\ttimestampSet = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tdata.sessionId = `stream-${data.timestamp.replace(/[:.]/g, \"-\")}`;\n\treturn data;\n}\n\nfunction detectFormat(lines: string[]): \"session-manager\" | \"streaming-events\" | \"unknown\" {\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as { type: string };\n\t\t\tif (entry.type === \"session\") return \"session-manager\";\n\t\t\tif (entry.type === \"agent_start\" || entry.type === \"message_start\" || entry.type === \"turn_start\") {\n\t\t\t\treturn \"streaming-events\";\n\t\t\t}\n\t\t} catch {}\n\t}\n\treturn \"unknown\";\n}\n\nfunction parseSessionFile(content: string): ParsedSessionData {\n\tconst lines = content\n\t\t.trim()\n\t\t.split(\"\\n\")\n\t\t.filter((l) => l.trim());\n\n\tif (lines.length === 0) {\n\t\tthrow new Error(\"Empty session file\");\n\t}\n\n\tconst format = detectFormat(lines);\n\tif (format === \"unknown\") {\n\t\tthrow new Error(\"Unknown session file format\");\n\t}\n\n\treturn format === \"session-manager\" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);\n}\n\n// ============================================================================\n// HTML formatting functions\n// ============================================================================\n\nfunction formatToolExecution(\n\ttoolName: string,\n\targs: Record<string, unknown>,\n\tresult?: ToolResultMessage,\n): { html: string; bgColor: string } {\n\tlet html = \"\";\n\tconst isError = result?.isError || false;\n\tconst bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;\n\n\tconst getTextOutput = (): string => {\n\t\tif (!result) return \"\";\n\t\tconst textBlocks = result.content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as { type: \"text\"; text: string }).text).join(\"\\n\");\n\t};\n\n\tconst formatExpandableOutput = (lines: string[], maxLines: number): string => {\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\n\t\tif (remaining > 0) {\n\t\t\tlet out = '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\tout += '<div class=\"output-preview\">';\n\t\t\tfor (const line of displayLines) {\n\t\t\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t}\n\t\t\tout += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\tout += \"</div>\";\n\t\t\tout += '<div class=\"output-full\">';\n\t\t\tfor (const line of lines) {\n\t\t\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t}\n\t\t\tout += \"</div></div>\";\n\t\t\treturn out;\n\t\t}\n\n\t\tlet out = '<div class=\"tool-output\">';\n\t\tfor (const line of displayLines) {\n\t\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t}\n\t\tout += \"</div>\";\n\t\treturn out;\n\t};\n\n\tswitch (toolName) {\n\t\tcase \"bash\": {\n\t\t\tconst command = (args?.command as string) || \"\";\n\t\t\thtml = `<div class=\"tool-command\">$ ${escapeHtml(command || \"...\")}</div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += formatExpandableOutput(output.split(\"\\n\"), 5);\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"read\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">read</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += formatExpandableOutput(output.split(\"\\n\"), 10);\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"write\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\tconst fileContent = (args?.content as string) || \"\";\n\t\t\tconst lines = fileContent ? fileContent.split(\"\\n\") : [];\n\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">write</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span>`;\n\t\t\tif (lines.length > 10) {\n\t\t\t\thtml += ` <span class=\"line-count\">(${lines.length} lines)</span>`;\n\t\t\t}\n\t\t\thtml += \"</div>\";\n\n\t\t\tif (fileContent) {\n\t\t\t\thtml += formatExpandableOutput(lines, 10);\n\t\t\t}\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"edit\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">edit</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\t\tif (result?.details?.diff) {\n\t\t\t\tconst diffLines = result.details.diff.split(\"\\n\");\n\t\t\t\thtml += '<div class=\"tool-diff\">';\n\t\t\t\tfor (const line of diffLines) {\n\t\t\t\t\tif (line.startsWith(\"+\")) {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-new\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t} else if (line.startsWith(\"-\")) {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-old\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-context\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tdefault: {\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">${escapeHtml(toolName)}</span></div>`;\n\t\t\thtml += `<div class=\"tool-output\"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { html, bgColor };\n}\n\nfunction formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {\n\tlet html = \"\";\n\tconst timestamp = (message as { timestamp?: number }).timestamp;\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${formatTimestamp(timestamp)}</div>` : \"\";\n\n\tif (message.role === \"user\") {\n\t\tconst userMsg = message as UserMessage;\n\t\tlet textContent = \"\";\n\n\t\tif (typeof userMsg.content === \"string\") {\n\t\t\ttextContent = userMsg.content;\n\t\t} else {\n\t\t\tconst textBlocks = userMsg.content.filter((c) => c.type === \"text\");\n\t\t\ttextContent = textBlocks.map((c) => (c as { type: \"text\"; text: string }).text).join(\"\");\n\t\t}\n\n\t\tif (textContent.trim()) {\n\t\t\thtml += `<div class=\"user-message\">${timestampHtml}${escapeHtml(textContent).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t}\n\t} else if (message.role === \"assistant\") {\n\t\tconst assistantMsg = message as AssistantMessage;\n\t\thtml += timestampHtml ? `<div class=\"assistant-message\">${timestampHtml}` : \"\";\n\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\thtml += `<div class=\"assistant-text\">${escapeHtml(content.text.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\thtml += `<div class=\"thinking-text\">${escapeHtml(content.thinking.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\tconst toolResult = toolResultsMap.get(content.id);\n\t\t\t\tconst { html: toolHtml, bgColor } = formatToolExecution(\n\t\t\t\t\tcontent.name,\n\t\t\t\t\tcontent.arguments as Record<string, unknown>,\n\t\t\t\t\ttoolResult,\n\t\t\t\t);\n\t\t\t\thtml += `<div class=\"tool-execution\" style=\"background-color: ${bgColor}\">${toolHtml}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tconst hasToolCalls = assistantMsg.content.some((c) => c.type === \"toolCall\");\n\t\tif (!hasToolCalls) {\n\t\t\tif (assistantMsg.stopReason === \"aborted\") {\n\t\t\t\thtml += '<div class=\"error-text\">Aborted</div>';\n\t\t\t} else if (assistantMsg.stopReason === \"error\") {\n\t\t\t\thtml += `<div class=\"error-text\">Error: ${escapeHtml(assistantMsg.errorMessage || \"Unknown error\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tif (timestampHtml) {\n\t\t\thtml += \"</div>\";\n\t\t}\n\t}\n\n\treturn html;\n}\n\nfunction formatModelChange(event: ModelChangeEvent): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst modelInfo = `${event.provider}/${event.modelId}`;\n\treturn `<div class=\"model-change\">${timestampHtml}<div class=\"model-change-text\">Switched to model: <span class=\"model-name\">${escapeHtml(modelInfo)}</span></div></div>`;\n}\n\nfunction formatCompaction(event: CompactionEvent): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst summaryHtml = escapeHtml(event.summary).replace(/\\n/g, \"<br>\");\n\n\treturn `<div class=\"compaction-container\">\n\t\t<div class=\"compaction-header\" onclick=\"this.parentElement.classList.toggle('expanded')\">\n\t\t\t${timestampHtml}\n\t\t\t<div class=\"compaction-header-row\">\n\t\t\t\t<span class=\"compaction-toggle\">▶</span>\n\t\t\t\t<span class=\"compaction-title\">Context compacted from ${event.tokensBefore.toLocaleString()} tokens</span>\n\t\t\t\t<span class=\"compaction-hint\">(click to expand summary)</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"compaction-content\">\n\t\t\t<div class=\"compaction-summary\">\n\t\t\t\t<div class=\"compaction-summary-header\">Summary sent to model</div>\n\t\t\t\t<div class=\"compaction-summary-content\">${summaryHtml}</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>`;\n}\n\n// ============================================================================\n// HTML generation\n// ============================================================================\n\nfunction generateHtml(data: ParsedSessionData, filename: string): string {\n\tconst userMessages = data.messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = data.messages.filter((m) => m.role === \"assistant\").length;\n\n\tlet toolCallsCount = 0;\n\tfor (const message of data.messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\ttoolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\tconst lastAssistantMessage = data.messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\tconst lastModel = lastAssistantMessage?.model || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\tconst contextWindow = data.contextWindow || 0;\n\tconst contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;\n\n\tlet messagesHtml = \"\";\n\tfor (const event of data.sessionEvents) {\n\t\tswitch (event.type) {\n\t\t\tcase \"message\":\n\t\t\t\tif (event.message.role !== \"toolResult\") {\n\t\t\t\t\tmessagesHtml += formatMessage(event.message, data.toolResultsMap);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tmessagesHtml += formatModelChange(event);\n\t\t\t\tbreak;\n\t\t\tcase \"compaction\":\n\t\t\t\tmessagesHtml += formatCompaction(event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst systemPromptHtml = data.systemPrompt\n\t\t? `<div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(data.systemPrompt)}</div>\n </div>`\n\t\t: \"\";\n\n\tconst toolsHtml = data.tools\n\t\t? `<div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${data.tools.map((tool) => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join(\"\")}\n </div>\n </div>`\n\t\t: \"\";\n\n\tconst streamingNotice = data.isStreamingFormat\n\t\t? `<div class=\"streaming-notice\">\n <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>\n </div>`\n\t\t: \"\";\n\n\tconst contextUsageText = contextPercent\n\t\t? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}`\n\t\t: `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`;\n\n\treturn `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${escapeHtml(filename)}</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n .container { max-width: 700px; margin: 0 auto; }\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n .header-info { display: flex; flex-direction: column; gap: 3px; font-size: 11px; }\n .info-item { color: ${COLORS.textDim}; display: flex; align-items: baseline; }\n .info-label { font-weight: 600; margin-right: 8px; min-width: 100px; }\n .info-value { color: ${COLORS.text}; flex: 1; }\n .info-value.cost { font-family: 'SF Mono', monospace; }\n .messages { display: flex; flex-direction: column; gap: 16px; }\n .message-timestamp { font-size: 10px; color: ${COLORS.textDim}; margin-bottom: 4px; opacity: 0.8; }\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n .assistant-message { padding: 0; }\n .assistant-text, .thinking-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n .thinking-text { color: ${COLORS.textDim}; font-style: italic; }\n .model-change { padding: 8px 16px; background: rgb(40, 40, 50); border-radius: 4px; }\n .model-change-text { color: ${COLORS.textDim}; font-size: 11px; }\n .model-name { color: ${COLORS.cyan}; font-weight: bold; }\n .compaction-container { background: rgb(60, 55, 35); border-radius: 4px; overflow: hidden; }\n .compaction-header { padding: 12px 16px; cursor: pointer; }\n .compaction-header:hover { background: rgba(255, 255, 255, 0.05); }\n .compaction-header-row { display: flex; align-items: center; gap: 8px; }\n .compaction-toggle { color: ${COLORS.cyan}; font-size: 10px; transition: transform 0.2s; }\n .compaction-container.expanded .compaction-toggle { transform: rotate(90deg); }\n .compaction-title { color: ${COLORS.text}; font-weight: bold; }\n .compaction-hint { color: ${COLORS.textDim}; font-size: 11px; }\n .compaction-content { display: none; padding: 0 16px 16px 16px; }\n .compaction-container.expanded .compaction-content { display: block; }\n .compaction-summary { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 12px; }\n .compaction-summary-header { font-weight: bold; color: ${COLORS.cyan}; margin-bottom: 8px; font-size: 11px; }\n .compaction-summary-content { color: ${COLORS.text}; white-space: pre-wrap; word-wrap: break-word; }\n .tool-execution { padding: 12px 16px; border-radius: 4px; margin-top: 8px; }\n .tool-header, .tool-name { font-weight: bold; }\n .tool-path { color: ${COLORS.cyan}; word-break: break-all; }\n .line-count { color: ${COLORS.textDim}; }\n .tool-command { font-weight: bold; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n .tool-output > div { line-height: 1.4; }\n .tool-output pre { margin: 0; font-family: inherit; color: inherit; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .tool-output.expandable { cursor: pointer; }\n .tool-output.expandable:hover { opacity: 0.9; }\n .tool-output.expandable .output-full { display: none; }\n .tool-output.expandable.expanded .output-preview { display: none; }\n .tool-output.expandable.expanded .output-full { display: block; }\n .expand-hint { color: ${COLORS.cyan}; font-style: italic; margin-top: 4px; }\n .system-prompt, .tools-list { background: rgb(60, 55, 40); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }\n .system-prompt-header, .tools-header { font-weight: bold; color: ${COLORS.yellow}; margin-bottom: 8px; }\n .system-prompt-content, .tools-content { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-size: 11px; }\n .tool-item { margin: 4px 0; }\n .tool-item-name { font-weight: bold; color: ${COLORS.text}; }\n .tool-diff { margin-top: 12px; font-size: 11px; font-family: inherit; overflow-x: auto; max-width: 100%; }\n .diff-line-old { color: ${COLORS.red}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .diff-line-new { color: ${COLORS.green}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .diff-line-context { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .error-text { color: ${COLORS.red}; padding: 12px 16px; }\n .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${COLORS.textDim}; font-size: 10px; }\n .streaming-notice { background: rgb(50, 45, 35); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${COLORS.textDim}; font-size: 11px; }\n @media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>${APP_NAME} v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Session:</span><span class=\"info-value\">${escapeHtml(data.sessionId)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Date:</span><span class=\"info-value\">${new Date(data.timestamp).toLocaleString()}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Models:</span><span class=\"info-value\">${\n\t\t\t\t\t\t\tArray.from(data.modelsUsed)\n\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t.join(\", \") || \"unknown\"\n\t\t\t\t\t\t}</span></div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">User:</span><span class=\"info-value\">${userMessages}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Assistant:</span><span class=\"info-value\">${assistantMessages}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tool Calls:</span><span class=\"info-value\">${toolCallsCount}</span></div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Input:</span><span class=\"info-value\">${data.tokenStats.input.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Output:</span><span class=\"info-value\">${data.tokenStats.output.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Read:</span><span class=\"info-value\">${data.tokenStats.cacheRead.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Write:</span><span class=\"info-value\">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Total:</span><span class=\"info-value\">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Input Cost:</span><span class=\"info-value cost\">$${data.costStats.input.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Output Cost:</span><span class=\"info-value cost\">$${data.costStats.output.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Read Cost:</span><span class=\"info-value cost\">$${data.costStats.cacheRead.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Write Cost:</span><span class=\"info-value cost\">$${data.costStats.cacheWrite.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Total Cost:</span><span class=\"info-value cost\"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span></div>\n <div class=\"info-item\"><span class=\"info-label\">Context Usage:</span><span class=\"info-value\">${contextUsageText}</span></div>\n </div>\n </div>\n\n ${systemPromptHtml}\n ${toolsHtml}\n ${streamingNotice}\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by ${APP_NAME} coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string {\n\tconst sessionFile = sessionManager.getSessionFile();\n\tconst content = readFileSync(sessionFile, \"utf8\");\n\tconst data = parseSessionFile(content);\n\n\t// Enrich with data from AgentState (tools, context window)\n\tdata.tools = state.tools.map((t) => ({ name: t.name, description: t.description }));\n\tdata.contextWindow = state.model?.contextWindow;\n\tif (!data.systemPrompt) {\n\t\tdata.systemPrompt = state.systemPrompt;\n\t}\n\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(sessionFile));\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Auto-detects format: session manager format or streaming event format.\n * Used by CLI for exporting arbitrary session files.\n */\nexport function exportFromFile(inputPath: string, outputPath?: string): string {\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst content = readFileSync(inputPath, \"utf8\");\n\tconst data = parseSessionFile(content);\n\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(inputPath));\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"export-html.d.ts","sourceRoot":"","sources":["../src/export-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAO9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmxB3D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAoBlH;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAgB7E","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, ToolResultMessage, UserMessage } from \"@mariozechner/pi-ai\";\nimport { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename } from \"path\";\nimport { APP_NAME, VERSION } from \"./config.js\";\nimport { type BashExecutionMessage, isBashExecutionMessage } from \"./messages.js\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface MessageEvent {\n\ttype: \"message\";\n\tmessage: Message;\n\ttimestamp?: number;\n}\n\ninterface ModelChangeEvent {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n\ttimestamp?: number;\n}\n\ninterface CompactionEvent {\n\ttype: \"compaction\";\n\ttimestamp: string;\n\tsummary: string;\n\ttokensBefore: number;\n}\n\ntype SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent;\n\ninterface ParsedSessionData {\n\tsessionId: string;\n\ttimestamp: string;\n\tsystemPrompt?: string;\n\tmodelsUsed: Set<string>;\n\tmessages: Message[];\n\ttoolResultsMap: Map<string, ToolResultMessage>;\n\tsessionEvents: SessionEvent[];\n\ttokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\tcostStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\ttools?: { name: string; description: string }[];\n\tcontextWindow?: number;\n\tisStreamingFormat?: boolean;\n}\n\n// ============================================================================\n// Color scheme (matching TUI)\n// ============================================================================\n\nconst COLORS = {\n\tuserMessageBg: \"rgb(52, 53, 65)\",\n\ttoolPendingBg: \"rgb(40, 40, 50)\",\n\ttoolSuccessBg: \"rgb(40, 50, 40)\",\n\ttoolErrorBg: \"rgb(60, 40, 40)\",\n\tuserBashBg: \"rgb(50, 48, 35)\", // Faint yellow/brown for user-executed bash\n\tuserBashErrorBg: \"rgb(60, 45, 35)\", // Slightly more orange for errors\n\tbodyBg: \"rgb(24, 24, 30)\",\n\tcontainerBg: \"rgb(30, 30, 36)\",\n\ttext: \"rgb(229, 229, 231)\",\n\ttextDim: \"rgb(161, 161, 170)\",\n\tcyan: \"rgb(103, 232, 249)\",\n\tgreen: \"rgb(34, 197, 94)\",\n\tred: \"rgb(239, 68, 68)\",\n\tyellow: \"rgb(234, 179, 8)\",\n};\n\n// ============================================================================\n// Utility functions\n// ============================================================================\n\nfunction escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nfunction shortenPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? \"~\" + path.slice(home.length) : path;\n}\n\nfunction replaceTabs(text: string): string {\n\treturn text.replace(/\\t/g, \" \");\n}\n\nfunction formatTimestamp(timestamp: number | string | undefined): string {\n\tif (!timestamp) return \"\";\n\tconst date = new Date(typeof timestamp === \"string\" ? timestamp : timestamp);\n\treturn date.toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n}\n\nfunction formatExpandableOutput(lines: string[], maxLines: number): string {\n\tconst displayLines = lines.slice(0, maxLines);\n\tconst remaining = lines.length - maxLines;\n\n\tif (remaining > 0) {\n\t\tlet out = '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\tout += '<div class=\"output-preview\">';\n\t\tfor (const line of displayLines) {\n\t\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t}\n\t\tout += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\tout += \"</div>\";\n\t\tout += '<div class=\"output-full\">';\n\t\tfor (const line of lines) {\n\t\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t}\n\t\tout += \"</div></div>\";\n\t\treturn out;\n\t}\n\n\tlet out = '<div class=\"tool-output\">';\n\tfor (const line of displayLines) {\n\t\tout += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t}\n\tout += \"</div>\";\n\treturn out;\n}\n\n// ============================================================================\n// Parsing functions\n// ============================================================================\n\nfunction parseSessionManagerFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n\n\tfor (const line of lines) {\n\t\tlet entry: { type: string; [key: string]: unknown };\n\t\ttry {\n\t\t\tentry = JSON.parse(line) as { type: string; [key: string]: unknown };\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"session\":\n\t\t\t\tdata.sessionId = (entry.id as string) || \"unknown\";\n\t\t\t\tdata.timestamp = (entry.timestamp as string) || data.timestamp;\n\t\t\t\tdata.systemPrompt = entry.systemPrompt as string | undefined;\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string);\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message\": {\n\t\t\t\tconst message = entry.message as Message;\n\t\t\t\tdata.messages.push(message);\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"message\",\n\t\t\t\t\tmessage,\n\t\t\t\t\ttimestamp: entry.timestamp as number | undefined,\n\t\t\t\t});\n\n\t\t\t\tif (message.role === \"toolResult\") {\n\t\t\t\t\tconst toolResult = message as ToolResultMessage;\n\t\t\t\t\tdata.toolResultsMap.set(toolResult.toolCallId, toolResult);\n\t\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\t\tdata.tokenStats.input += assistantMsg.usage.input || 0;\n\t\t\t\t\t\tdata.tokenStats.output += assistantMsg.usage.output || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;\n\t\t\t\t\t\tif (assistantMsg.usage.cost) {\n\t\t\t\t\t\t\tdata.costStats.input += assistantMsg.usage.cost.input || 0;\n\t\t\t\t\t\t\tdata.costStats.output += assistantMsg.usage.cost.output || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"model_change\":\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"model_change\",\n\t\t\t\t\tprovider: entry.provider as string,\n\t\t\t\t\tmodelId: entry.modelId as string,\n\t\t\t\t\ttimestamp: entry.timestamp as number | undefined,\n\t\t\t\t});\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string);\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"compaction\":\n\t\t\t\tdata.sessionEvents.push({\n\t\t\t\t\ttype: \"compaction\",\n\t\t\t\t\ttimestamp: entry.timestamp as string,\n\t\t\t\t\tsummary: entry.summary as string,\n\t\t\t\t\ttokensBefore: entry.tokensBefore as number,\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn data;\n}\n\nfunction parseStreamingEventFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tisStreamingFormat: true,\n\t};\n\n\tlet timestampSet = false;\n\n\tfor (const line of lines) {\n\t\tlet entry: { type: string; message?: Message };\n\t\ttry {\n\t\t\tentry = JSON.parse(line) as { type: string; message?: Message };\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.type === \"message_end\" && entry.message) {\n\t\t\tconst msg = entry.message;\n\t\t\tdata.messages.push(msg);\n\t\t\tdata.sessionEvents.push({\n\t\t\t\ttype: \"message\",\n\t\t\t\tmessage: msg,\n\t\t\t\ttimestamp: (msg as { timestamp?: number }).timestamp,\n\t\t\t});\n\n\t\t\tif (msg.role === \"toolResult\") {\n\t\t\t\tconst toolResult = msg as ToolResultMessage;\n\t\t\t\tdata.toolResultsMap.set(toolResult.toolCallId, toolResult);\n\t\t\t} else if (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.model) {\n\t\t\t\t\tconst modelInfo = assistantMsg.provider\n\t\t\t\t\t\t? `${assistantMsg.provider}/${assistantMsg.model}`\n\t\t\t\t\t\t: assistantMsg.model;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\tdata.tokenStats.input += assistantMsg.usage.input || 0;\n\t\t\t\t\tdata.tokenStats.output += assistantMsg.usage.output || 0;\n\t\t\t\t\tdata.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;\n\t\t\t\t\tdata.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;\n\t\t\t\t\tif (assistantMsg.usage.cost) {\n\t\t\t\t\t\tdata.costStats.input += assistantMsg.usage.cost.input || 0;\n\t\t\t\t\t\tdata.costStats.output += assistantMsg.usage.cost.output || 0;\n\t\t\t\t\t\tdata.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\tdata.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!timestampSet && (msg as { timestamp?: number }).timestamp) {\n\t\t\t\tdata.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString();\n\t\t\t\ttimestampSet = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tdata.sessionId = `stream-${data.timestamp.replace(/[:.]/g, \"-\")}`;\n\treturn data;\n}\n\nfunction detectFormat(lines: string[]): \"session-manager\" | \"streaming-events\" | \"unknown\" {\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as { type: string };\n\t\t\tif (entry.type === \"session\") return \"session-manager\";\n\t\t\tif (entry.type === \"agent_start\" || entry.type === \"message_start\" || entry.type === \"turn_start\") {\n\t\t\t\treturn \"streaming-events\";\n\t\t\t}\n\t\t} catch {}\n\t}\n\treturn \"unknown\";\n}\n\nfunction parseSessionFile(content: string): ParsedSessionData {\n\tconst lines = content\n\t\t.trim()\n\t\t.split(\"\\n\")\n\t\t.filter((l) => l.trim());\n\n\tif (lines.length === 0) {\n\t\tthrow new Error(\"Empty session file\");\n\t}\n\n\tconst format = detectFormat(lines);\n\tif (format === \"unknown\") {\n\t\tthrow new Error(\"Unknown session file format\");\n\t}\n\n\treturn format === \"session-manager\" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);\n}\n\n// ============================================================================\n// HTML formatting functions\n// ============================================================================\n\nfunction formatToolExecution(\n\ttoolName: string,\n\targs: Record<string, unknown>,\n\tresult?: ToolResultMessage,\n): { html: string; bgColor: string } {\n\tlet html = \"\";\n\tconst isError = result?.isError || false;\n\tconst bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;\n\n\tconst getTextOutput = (): string => {\n\t\tif (!result) return \"\";\n\t\tconst textBlocks = result.content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as { type: \"text\"; text: string }).text).join(\"\\n\");\n\t};\n\n\tswitch (toolName) {\n\t\tcase \"bash\": {\n\t\t\tconst command = (args?.command as string) || \"\";\n\t\t\thtml = `<div class=\"tool-command\">$ ${escapeHtml(command || \"...\")}</div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += formatExpandableOutput(output.split(\"\\n\"), 5);\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"read\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">read</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += formatExpandableOutput(output.split(\"\\n\"), 10);\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"write\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\tconst fileContent = (args?.content as string) || \"\";\n\t\t\tconst lines = fileContent ? fileContent.split(\"\\n\") : [];\n\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">write</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span>`;\n\t\t\tif (lines.length > 10) {\n\t\t\t\thtml += ` <span class=\"line-count\">(${lines.length} lines)</span>`;\n\t\t\t}\n\t\t\thtml += \"</div>\";\n\n\t\t\tif (fileContent) {\n\t\t\t\thtml += formatExpandableOutput(lines, 10);\n\t\t\t}\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tcase \"edit\": {\n\t\t\tconst path = shortenPath((args?.file_path as string) || (args?.path as string) || \"\");\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">edit</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\t\tif (result?.details?.diff) {\n\t\t\t\tconst diffLines = result.details.diff.split(\"\\n\");\n\t\t\t\thtml += '<div class=\"tool-diff\">';\n\t\t\t\tfor (const line of diffLines) {\n\t\t\t\t\tif (line.startsWith(\"+\")) {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-new\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t} else if (line.startsWith(\"-\")) {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-old\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtml += `<div class=\"diff-line-context\">${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput().trim();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\tdefault: {\n\t\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">${escapeHtml(toolName)}</span></div>`;\n\t\t\thtml += `<div class=\"tool-output\"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;\n\t\t\tif (result) {\n\t\t\t\tconst output = getTextOutput();\n\t\t\t\tif (output) {\n\t\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { html, bgColor };\n}\n\nfunction formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {\n\tlet html = \"\";\n\tconst timestamp = (message as { timestamp?: number }).timestamp;\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${formatTimestamp(timestamp)}</div>` : \"\";\n\n\t// Handle bash execution messages (user-executed via ! command)\n\tif (isBashExecutionMessage(message)) {\n\t\tconst bashMsg = message as unknown as BashExecutionMessage;\n\t\tconst isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);\n\t\tconst bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;\n\n\t\thtml += `<div class=\"tool-execution\" style=\"background-color: ${bgColor}\">`;\n\t\thtml += timestampHtml;\n\t\thtml += `<div class=\"tool-command\">$ ${escapeHtml(bashMsg.command)}</div>`;\n\n\t\tif (bashMsg.output) {\n\t\t\tconst lines = bashMsg.output.split(\"\\n\");\n\t\t\thtml += formatExpandableOutput(lines, 10);\n\t\t}\n\n\t\tif (bashMsg.cancelled) {\n\t\t\thtml += `<div class=\"bash-status\" style=\"color: ${COLORS.yellow}\">(cancelled)</div>`;\n\t\t} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {\n\t\t\thtml += `<div class=\"bash-status\" style=\"color: ${COLORS.red}\">(exit ${bashMsg.exitCode})</div>`;\n\t\t}\n\n\t\tif (bashMsg.truncated && bashMsg.fullOutputPath) {\n\t\t\thtml += `<div class=\"bash-truncation\" style=\"color: ${COLORS.yellow}\">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;\n\t\t}\n\n\t\thtml += `</div>`;\n\t\treturn html;\n\t}\n\n\tif (message.role === \"user\") {\n\t\tconst userMsg = message as UserMessage;\n\t\tlet textContent = \"\";\n\n\t\tif (typeof userMsg.content === \"string\") {\n\t\t\ttextContent = userMsg.content;\n\t\t} else {\n\t\t\tconst textBlocks = userMsg.content.filter((c) => c.type === \"text\");\n\t\t\ttextContent = textBlocks.map((c) => (c as { type: \"text\"; text: string }).text).join(\"\");\n\t\t}\n\n\t\tif (textContent.trim()) {\n\t\t\thtml += `<div class=\"user-message\">${timestampHtml}${escapeHtml(textContent).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t}\n\t} else if (message.role === \"assistant\") {\n\t\tconst assistantMsg = message as AssistantMessage;\n\t\thtml += timestampHtml ? `<div class=\"assistant-message\">${timestampHtml}` : \"\";\n\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\thtml += `<div class=\"assistant-text\">${escapeHtml(content.text.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\thtml += `<div class=\"thinking-text\">${escapeHtml(content.thinking.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\tconst toolResult = toolResultsMap.get(content.id);\n\t\t\t\tconst { html: toolHtml, bgColor } = formatToolExecution(\n\t\t\t\t\tcontent.name,\n\t\t\t\t\tcontent.arguments as Record<string, unknown>,\n\t\t\t\t\ttoolResult,\n\t\t\t\t);\n\t\t\t\thtml += `<div class=\"tool-execution\" style=\"background-color: ${bgColor}\">${toolHtml}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tconst hasToolCalls = assistantMsg.content.some((c) => c.type === \"toolCall\");\n\t\tif (!hasToolCalls) {\n\t\t\tif (assistantMsg.stopReason === \"aborted\") {\n\t\t\t\thtml += '<div class=\"error-text\">Aborted</div>';\n\t\t\t} else if (assistantMsg.stopReason === \"error\") {\n\t\t\t\thtml += `<div class=\"error-text\">Error: ${escapeHtml(assistantMsg.errorMessage || \"Unknown error\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\tif (timestampHtml) {\n\t\t\thtml += \"</div>\";\n\t\t}\n\t}\n\n\treturn html;\n}\n\nfunction formatModelChange(event: ModelChangeEvent): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst modelInfo = `${event.provider}/${event.modelId}`;\n\treturn `<div class=\"model-change\">${timestampHtml}<div class=\"model-change-text\">Switched to model: <span class=\"model-name\">${escapeHtml(modelInfo)}</span></div></div>`;\n}\n\nfunction formatCompaction(event: CompactionEvent): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst summaryHtml = escapeHtml(event.summary).replace(/\\n/g, \"<br>\");\n\n\treturn `<div class=\"compaction-container\">\n\t\t<div class=\"compaction-header\" onclick=\"this.parentElement.classList.toggle('expanded')\">\n\t\t\t${timestampHtml}\n\t\t\t<div class=\"compaction-header-row\">\n\t\t\t\t<span class=\"compaction-toggle\">▶</span>\n\t\t\t\t<span class=\"compaction-title\">Context compacted from ${event.tokensBefore.toLocaleString()} tokens</span>\n\t\t\t\t<span class=\"compaction-hint\">(click to expand summary)</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"compaction-content\">\n\t\t\t<div class=\"compaction-summary\">\n\t\t\t\t<div class=\"compaction-summary-header\">Summary sent to model</div>\n\t\t\t\t<div class=\"compaction-summary-content\">${summaryHtml}</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>`;\n}\n\n// ============================================================================\n// HTML generation\n// ============================================================================\n\nfunction generateHtml(data: ParsedSessionData, filename: string): string {\n\tconst userMessages = data.messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = data.messages.filter((m) => m.role === \"assistant\").length;\n\n\tlet toolCallsCount = 0;\n\tfor (const message of data.messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\ttoolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\tconst lastAssistantMessage = data.messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\tconst lastModel = lastAssistantMessage?.model || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\tconst contextWindow = data.contextWindow || 0;\n\tconst contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;\n\n\tlet messagesHtml = \"\";\n\tfor (const event of data.sessionEvents) {\n\t\tswitch (event.type) {\n\t\t\tcase \"message\":\n\t\t\t\tif (event.message.role !== \"toolResult\") {\n\t\t\t\t\tmessagesHtml += formatMessage(event.message, data.toolResultsMap);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tmessagesHtml += formatModelChange(event);\n\t\t\t\tbreak;\n\t\t\tcase \"compaction\":\n\t\t\t\tmessagesHtml += formatCompaction(event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst systemPromptHtml = data.systemPrompt\n\t\t? `<div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(data.systemPrompt)}</div>\n </div>`\n\t\t: \"\";\n\n\tconst toolsHtml = data.tools\n\t\t? `<div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${data.tools.map((tool) => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join(\"\")}\n </div>\n </div>`\n\t\t: \"\";\n\n\tconst streamingNotice = data.isStreamingFormat\n\t\t? `<div class=\"streaming-notice\">\n <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>\n </div>`\n\t\t: \"\";\n\n\tconst contextUsageText = contextPercent\n\t\t? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}`\n\t\t: `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`;\n\n\treturn `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${escapeHtml(filename)}</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n .container { max-width: 700px; margin: 0 auto; }\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n .header-info { display: flex; flex-direction: column; gap: 3px; font-size: 11px; }\n .info-item { color: ${COLORS.textDim}; display: flex; align-items: baseline; }\n .info-label { font-weight: 600; margin-right: 8px; min-width: 100px; }\n .info-value { color: ${COLORS.text}; flex: 1; }\n .info-value.cost { font-family: 'SF Mono', monospace; }\n .messages { display: flex; flex-direction: column; gap: 16px; }\n .message-timestamp { font-size: 10px; color: ${COLORS.textDim}; margin-bottom: 4px; opacity: 0.8; }\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n .assistant-message { padding: 0; }\n .assistant-text, .thinking-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n .thinking-text { color: ${COLORS.textDim}; font-style: italic; }\n .model-change { padding: 8px 16px; background: rgb(40, 40, 50); border-radius: 4px; }\n .model-change-text { color: ${COLORS.textDim}; font-size: 11px; }\n .model-name { color: ${COLORS.cyan}; font-weight: bold; }\n .compaction-container { background: rgb(60, 55, 35); border-radius: 4px; overflow: hidden; }\n .compaction-header { padding: 12px 16px; cursor: pointer; }\n .compaction-header:hover { background: rgba(255, 255, 255, 0.05); }\n .compaction-header-row { display: flex; align-items: center; gap: 8px; }\n .compaction-toggle { color: ${COLORS.cyan}; font-size: 10px; transition: transform 0.2s; }\n .compaction-container.expanded .compaction-toggle { transform: rotate(90deg); }\n .compaction-title { color: ${COLORS.text}; font-weight: bold; }\n .compaction-hint { color: ${COLORS.textDim}; font-size: 11px; }\n .compaction-content { display: none; padding: 0 16px 16px 16px; }\n .compaction-container.expanded .compaction-content { display: block; }\n .compaction-summary { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 12px; }\n .compaction-summary-header { font-weight: bold; color: ${COLORS.cyan}; margin-bottom: 8px; font-size: 11px; }\n .compaction-summary-content { color: ${COLORS.text}; white-space: pre-wrap; word-wrap: break-word; }\n .tool-execution { padding: 12px 16px; border-radius: 4px; margin-top: 8px; }\n .tool-header, .tool-name { font-weight: bold; }\n .tool-path { color: ${COLORS.cyan}; word-break: break-all; }\n .line-count { color: ${COLORS.textDim}; }\n .tool-command { font-weight: bold; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n .tool-output > div { line-height: 1.4; }\n .tool-output pre { margin: 0; font-family: inherit; color: inherit; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .tool-output.expandable { cursor: pointer; }\n .tool-output.expandable:hover { opacity: 0.9; }\n .tool-output.expandable .output-full { display: none; }\n .tool-output.expandable.expanded .output-preview { display: none; }\n .tool-output.expandable.expanded .output-full { display: block; }\n .expand-hint { color: ${COLORS.cyan}; font-style: italic; margin-top: 4px; }\n .system-prompt, .tools-list { background: rgb(60, 55, 40); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }\n .system-prompt-header, .tools-header { font-weight: bold; color: ${COLORS.yellow}; margin-bottom: 8px; }\n .system-prompt-content, .tools-content { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-size: 11px; }\n .tool-item { margin: 4px 0; }\n .tool-item-name { font-weight: bold; color: ${COLORS.text}; }\n .tool-diff { margin-top: 12px; font-size: 11px; font-family: inherit; overflow-x: auto; max-width: 100%; }\n .diff-line-old { color: ${COLORS.red}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .diff-line-new { color: ${COLORS.green}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .diff-line-context { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }\n .error-text { color: ${COLORS.red}; padding: 12px 16px; }\n .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${COLORS.textDim}; font-size: 10px; }\n .streaming-notice { background: rgb(50, 45, 35); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${COLORS.textDim}; font-size: 11px; }\n @media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>${APP_NAME} v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Session:</span><span class=\"info-value\">${escapeHtml(data.sessionId)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Date:</span><span class=\"info-value\">${new Date(data.timestamp).toLocaleString()}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Models:</span><span class=\"info-value\">${\n\t\t\t\t\t\t\tArray.from(data.modelsUsed)\n\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t.join(\", \") || \"unknown\"\n\t\t\t\t\t\t}</span></div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">User:</span><span class=\"info-value\">${userMessages}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Assistant:</span><span class=\"info-value\">${assistantMessages}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tool Calls:</span><span class=\"info-value\">${toolCallsCount}</span></div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Input:</span><span class=\"info-value\">${data.tokenStats.input.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Output:</span><span class=\"info-value\">${data.tokenStats.output.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Read:</span><span class=\"info-value\">${data.tokenStats.cacheRead.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Write:</span><span class=\"info-value\">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Total:</span><span class=\"info-value\">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Input Cost:</span><span class=\"info-value cost\">$${data.costStats.input.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Output Cost:</span><span class=\"info-value cost\">$${data.costStats.output.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Read Cost:</span><span class=\"info-value cost\">$${data.costStats.cacheRead.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cache Write Cost:</span><span class=\"info-value cost\">$${data.costStats.cacheWrite.toFixed(4)}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Total Cost:</span><span class=\"info-value cost\"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span></div>\n <div class=\"info-item\"><span class=\"info-label\">Context Usage:</span><span class=\"info-value\">${contextUsageText}</span></div>\n </div>\n </div>\n\n ${systemPromptHtml}\n ${toolsHtml}\n ${streamingNotice}\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by ${APP_NAME} coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Export session to HTML using SessionManager and AgentState.\n * Used by TUI's /export command.\n */\nexport function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string {\n\tconst sessionFile = sessionManager.getSessionFile();\n\tconst content = readFileSync(sessionFile, \"utf8\");\n\tconst data = parseSessionFile(content);\n\n\t// Enrich with data from AgentState (tools, context window)\n\tdata.tools = state.tools.map((t) => ({ name: t.name, description: t.description }));\n\tdata.contextWindow = state.model?.contextWindow;\n\tif (!data.systemPrompt) {\n\t\tdata.systemPrompt = state.systemPrompt;\n\t}\n\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${sessionBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(sessionFile));\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n\n/**\n * Export session file to HTML (standalone, without AgentState).\n * Auto-detects format: session manager format or streaming event format.\n * Used by CLI for exporting arbitrary session files.\n */\nexport function exportFromFile(inputPath: string, outputPath?: string): string {\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst content = readFileSync(inputPath, \"utf8\");\n\tconst data = parseSessionFile(content);\n\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `${APP_NAME}-session-${inputBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(inputPath));\n\twriteFileSync(outputPath, html, \"utf8\");\n\treturn outputPath;\n}\n"]}
|
package/dist/export-html.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { basename } from "path";
|
|
4
4
|
import { APP_NAME, VERSION } from "./config.js";
|
|
5
|
+
import { isBashExecutionMessage } from "./messages.js";
|
|
5
6
|
// ============================================================================
|
|
6
7
|
// Color scheme (matching TUI)
|
|
7
8
|
// ============================================================================
|
|
@@ -10,6 +11,8 @@ const COLORS = {
|
|
|
10
11
|
toolPendingBg: "rgb(40, 40, 50)",
|
|
11
12
|
toolSuccessBg: "rgb(40, 50, 40)",
|
|
12
13
|
toolErrorBg: "rgb(60, 40, 40)",
|
|
14
|
+
userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash
|
|
15
|
+
userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors
|
|
13
16
|
bodyBg: "rgb(24, 24, 30)",
|
|
14
17
|
containerBg: "rgb(30, 30, 36)",
|
|
15
18
|
text: "rgb(229, 229, 231)",
|
|
@@ -43,6 +46,31 @@ function formatTimestamp(timestamp) {
|
|
|
43
46
|
const date = new Date(typeof timestamp === "string" ? timestamp : timestamp);
|
|
44
47
|
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
45
48
|
}
|
|
49
|
+
function formatExpandableOutput(lines, maxLines) {
|
|
50
|
+
const displayLines = lines.slice(0, maxLines);
|
|
51
|
+
const remaining = lines.length - maxLines;
|
|
52
|
+
if (remaining > 0) {
|
|
53
|
+
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
|
|
54
|
+
out += '<div class="output-preview">';
|
|
55
|
+
for (const line of displayLines) {
|
|
56
|
+
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
57
|
+
}
|
|
58
|
+
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
|
|
59
|
+
out += "</div>";
|
|
60
|
+
out += '<div class="output-full">';
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
63
|
+
}
|
|
64
|
+
out += "</div></div>";
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
let out = '<div class="tool-output">';
|
|
68
|
+
for (const line of displayLines) {
|
|
69
|
+
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
70
|
+
}
|
|
71
|
+
out += "</div>";
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
46
74
|
// ============================================================================
|
|
47
75
|
// Parsing functions
|
|
48
76
|
// ============================================================================
|
|
@@ -232,31 +260,6 @@ function formatToolExecution(toolName, args, result) {
|
|
|
232
260
|
const textBlocks = result.content.filter((c) => c.type === "text");
|
|
233
261
|
return textBlocks.map((c) => c.text).join("\n");
|
|
234
262
|
};
|
|
235
|
-
const formatExpandableOutput = (lines, maxLines) => {
|
|
236
|
-
const displayLines = lines.slice(0, maxLines);
|
|
237
|
-
const remaining = lines.length - maxLines;
|
|
238
|
-
if (remaining > 0) {
|
|
239
|
-
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
|
|
240
|
-
out += '<div class="output-preview">';
|
|
241
|
-
for (const line of displayLines) {
|
|
242
|
-
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
243
|
-
}
|
|
244
|
-
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
|
|
245
|
-
out += "</div>";
|
|
246
|
-
out += '<div class="output-full">';
|
|
247
|
-
for (const line of lines) {
|
|
248
|
-
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
249
|
-
}
|
|
250
|
-
out += "</div></div>";
|
|
251
|
-
return out;
|
|
252
|
-
}
|
|
253
|
-
let out = '<div class="tool-output">';
|
|
254
|
-
for (const line of displayLines) {
|
|
255
|
-
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
256
|
-
}
|
|
257
|
-
out += "</div>";
|
|
258
|
-
return out;
|
|
259
|
-
};
|
|
260
263
|
switch (toolName) {
|
|
261
264
|
case "bash": {
|
|
262
265
|
const command = args?.command || "";
|
|
@@ -344,6 +347,30 @@ function formatMessage(message, toolResultsMap) {
|
|
|
344
347
|
let html = "";
|
|
345
348
|
const timestamp = message.timestamp;
|
|
346
349
|
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
|
|
350
|
+
// Handle bash execution messages (user-executed via ! command)
|
|
351
|
+
if (isBashExecutionMessage(message)) {
|
|
352
|
+
const bashMsg = message;
|
|
353
|
+
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
|
|
354
|
+
const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;
|
|
355
|
+
html += `<div class="tool-execution" style="background-color: ${bgColor}">`;
|
|
356
|
+
html += timestampHtml;
|
|
357
|
+
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
|
|
358
|
+
if (bashMsg.output) {
|
|
359
|
+
const lines = bashMsg.output.split("\n");
|
|
360
|
+
html += formatExpandableOutput(lines, 10);
|
|
361
|
+
}
|
|
362
|
+
if (bashMsg.cancelled) {
|
|
363
|
+
html += `<div class="bash-status" style="color: ${COLORS.yellow}">(cancelled)</div>`;
|
|
364
|
+
}
|
|
365
|
+
else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
|
|
366
|
+
html += `<div class="bash-status" style="color: ${COLORS.red}">(exit ${bashMsg.exitCode})</div>`;
|
|
367
|
+
}
|
|
368
|
+
if (bashMsg.truncated && bashMsg.fullOutputPath) {
|
|
369
|
+
html += `<div class="bash-truncation" style="color: ${COLORS.yellow}">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
|
|
370
|
+
}
|
|
371
|
+
html += `</div>`;
|
|
372
|
+
return html;
|
|
373
|
+
}
|
|
347
374
|
if (message.role === "user") {
|
|
348
375
|
const userMsg = message;
|
|
349
376
|
let textContent = "";
|