@hellcoder/companion 0.100.0 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentsPage-7oHDiJoh.js → AgentsPage-BhKdcyXZ.js} +1 -1
- package/dist/assets/{CronManager-fsEUcByi.js → CronManager-DRZ66TkG.js} +1 -1
- package/dist/assets/{IntegrationsPage-DhiOq9T9.js → IntegrationsPage-ClTWLg6W.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-DJ3p4Zyh.js → LinearOAuthSettingsPage-CWz5AdGt.js} +1 -1
- package/dist/assets/{LinearSettingsPage-EJXrPlao.js → LinearSettingsPage-di0JH5lO.js} +1 -1
- package/dist/assets/{Playground-BJi1T7KP.js → Playground-BVqqQ5J3.js} +1 -1
- package/dist/assets/{PromptsPage-DiMm5U1r.js → PromptsPage-C1oMpJK9.js} +1 -1
- package/dist/assets/{RunsPage-LPrcOaUc.js → RunsPage-B2Z2eRQO.js} +1 -1
- package/dist/assets/{SandboxManager-9KiHL5rI.js → SandboxManager-CaBqRrPE.js} +1 -1
- package/dist/assets/{SettingsPage-BXy1ZF1F.js → SettingsPage-C4WdQ-ba.js} +1 -1
- package/dist/assets/{TailscalePage-ycECxxya.js → TailscalePage-trYybm9W.js} +1 -1
- package/dist/assets/index-BCS1TCne.css +1 -0
- package/dist/assets/index-BY3_XaK9.js +134 -0
- package/dist/assets/{sw-register-Duj0Mw6k.js → sw-register-Cf5LH9-W.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-adapter.test.ts +114 -0
- package/server/claude-adapter.ts +56 -8
- package/server/claude-session-history.ts +15 -0
- package/server/routes.ts +20 -0
- package/server/session-export.test.ts +169 -0
- package/server/session-export.ts +382 -0
- package/dist/assets/index-D-JiBkdW.js +0 -134
- package/dist/assets/index-DwVmncqT.css +0 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolveClaudeSessionFilePath } from "./claude-session-history.js";
|
|
3
|
+
|
|
4
|
+
export type SessionExportFormat = "html" | "txt";
|
|
5
|
+
|
|
6
|
+
export interface SessionExportResult {
|
|
7
|
+
filename: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BuildSessionExportOptions {
|
|
13
|
+
/** Claude session id (the `.jsonl` file name under ~/.claude/projects). */
|
|
14
|
+
sessionId: string;
|
|
15
|
+
format: SessionExportFormat;
|
|
16
|
+
/** Human-friendly title used in the document header and filename. */
|
|
17
|
+
title: string;
|
|
18
|
+
projectsRoot?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ExportImage {
|
|
22
|
+
mediaType: string;
|
|
23
|
+
data: string; // base64
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ExportToolResult {
|
|
27
|
+
text: string;
|
|
28
|
+
isError: boolean;
|
|
29
|
+
images: ExportImage[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ExportItem {
|
|
33
|
+
role: "user" | "assistant";
|
|
34
|
+
ts: number;
|
|
35
|
+
texts: string[];
|
|
36
|
+
thinking: string[];
|
|
37
|
+
toolUses: { name: string; input: string }[];
|
|
38
|
+
toolResults: ExportToolResult[];
|
|
39
|
+
images: ExportImage[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_TOOL_RESULT_CHARS = 4000;
|
|
43
|
+
const MAX_TOOL_INPUT_CHARS = 1500;
|
|
44
|
+
|
|
45
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
46
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
47
|
+
? (value as Record<string, unknown>)
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractImage(block: Record<string, unknown>): ExportImage | null {
|
|
52
|
+
const source = asRecord(block.source);
|
|
53
|
+
if (!source) return null;
|
|
54
|
+
if (source.type !== "base64") return null;
|
|
55
|
+
const data = typeof source.data === "string" ? source.data : "";
|
|
56
|
+
if (!data) return null;
|
|
57
|
+
const mediaType = typeof source.media_type === "string" ? source.media_type : "image/png";
|
|
58
|
+
return { mediaType, data };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isCommandNoise(text: string): boolean {
|
|
62
|
+
const t = text.trim();
|
|
63
|
+
return (
|
|
64
|
+
t.startsWith("<command-name>") ||
|
|
65
|
+
t.startsWith("<command-message>") ||
|
|
66
|
+
t.startsWith("<local-command-stdout>") ||
|
|
67
|
+
t.startsWith("Caveat: The messages below were generated")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Flatten a tool_result `content` (string | block[]) into text + images. */
|
|
72
|
+
function parseToolResultContent(content: unknown): { text: string; images: ExportImage[] } {
|
|
73
|
+
if (typeof content === "string") return { text: content, images: [] };
|
|
74
|
+
if (!Array.isArray(content)) return { text: "", images: [] };
|
|
75
|
+
const texts: string[] = [];
|
|
76
|
+
const images: ExportImage[] = [];
|
|
77
|
+
for (const raw of content) {
|
|
78
|
+
const block = asRecord(raw);
|
|
79
|
+
if (!block) continue;
|
|
80
|
+
if (block.type === "text" && typeof block.text === "string") texts.push(block.text);
|
|
81
|
+
else if (block.type === "image") {
|
|
82
|
+
const img = extractImage(block);
|
|
83
|
+
if (img) images.push(img);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { text: texts.join("\n"), images };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseUserContent(content: unknown, item: ExportItem): void {
|
|
90
|
+
if (typeof content === "string") {
|
|
91
|
+
if (content.trim() && !isCommandNoise(content)) item.texts.push(content);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(content)) return;
|
|
95
|
+
for (const raw of content) {
|
|
96
|
+
const block = asRecord(raw);
|
|
97
|
+
if (!block) continue;
|
|
98
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
99
|
+
if (block.text.trim() && !isCommandNoise(block.text)) item.texts.push(block.text);
|
|
100
|
+
} else if (block.type === "image") {
|
|
101
|
+
const img = extractImage(block);
|
|
102
|
+
if (img) item.images.push(img);
|
|
103
|
+
} else if (block.type === "tool_result") {
|
|
104
|
+
const parsed = parseToolResultContent(block.content);
|
|
105
|
+
item.toolResults.push({
|
|
106
|
+
text: parsed.text,
|
|
107
|
+
isError: block.is_error === true,
|
|
108
|
+
images: parsed.images,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseAssistantContent(content: unknown, item: ExportItem): void {
|
|
115
|
+
if (typeof content === "string") {
|
|
116
|
+
if (content.trim()) item.texts.push(content);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(content)) return;
|
|
120
|
+
for (const raw of content) {
|
|
121
|
+
const block = asRecord(raw);
|
|
122
|
+
if (!block) continue;
|
|
123
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
124
|
+
if (block.text.trim()) item.texts.push(block.text);
|
|
125
|
+
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
126
|
+
if (block.thinking.trim()) item.thinking.push(block.thinking);
|
|
127
|
+
} else if (block.type === "tool_use") {
|
|
128
|
+
const name = typeof block.name === "string" ? block.name : "tool";
|
|
129
|
+
let input = "";
|
|
130
|
+
try {
|
|
131
|
+
input = JSON.stringify(block.input ?? {}, null, 2);
|
|
132
|
+
} catch {
|
|
133
|
+
input = String(block.input ?? "");
|
|
134
|
+
}
|
|
135
|
+
item.toolUses.push({ name, input });
|
|
136
|
+
} else if (block.type === "image") {
|
|
137
|
+
const img = extractImage(block);
|
|
138
|
+
if (img) item.images.push(img);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isEmpty(item: ExportItem): boolean {
|
|
144
|
+
return (
|
|
145
|
+
item.texts.length === 0 &&
|
|
146
|
+
item.thinking.length === 0 &&
|
|
147
|
+
item.toolUses.length === 0 &&
|
|
148
|
+
item.toolResults.length === 0 &&
|
|
149
|
+
item.images.length === 0
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseTranscript(filePath: string): ExportItem[] {
|
|
154
|
+
const raw = readFileSync(filePath, "utf8");
|
|
155
|
+
const items: ExportItem[] = [];
|
|
156
|
+
for (const line of raw.split("\n")) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (!trimmed) continue;
|
|
159
|
+
let obj: Record<string, unknown> | null;
|
|
160
|
+
try {
|
|
161
|
+
obj = asRecord(JSON.parse(trimmed));
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!obj) continue;
|
|
166
|
+
const message = asRecord(obj.message);
|
|
167
|
+
const role = message && typeof message.role === "string" ? message.role : null;
|
|
168
|
+
const ts = typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : NaN;
|
|
169
|
+
const item: ExportItem = {
|
|
170
|
+
role: role === "assistant" ? "assistant" : "user",
|
|
171
|
+
ts: Number.isFinite(ts) ? ts : 0,
|
|
172
|
+
texts: [],
|
|
173
|
+
thinking: [],
|
|
174
|
+
toolUses: [],
|
|
175
|
+
toolResults: [],
|
|
176
|
+
images: [],
|
|
177
|
+
};
|
|
178
|
+
if (obj.type === "user" && role === "user") {
|
|
179
|
+
parseUserContent(message?.content, item);
|
|
180
|
+
} else if (obj.type === "assistant" && role === "assistant") {
|
|
181
|
+
parseAssistantContent(message?.content, item);
|
|
182
|
+
} else {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!isEmpty(item)) items.push(item);
|
|
186
|
+
}
|
|
187
|
+
return items;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function escapeHtml(s: string): string {
|
|
193
|
+
return s
|
|
194
|
+
.replace(/&/g, "&")
|
|
195
|
+
.replace(/</g, "<")
|
|
196
|
+
.replace(/>/g, ">")
|
|
197
|
+
.replace(/"/g, """);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatTime(ts: number): string {
|
|
201
|
+
if (!ts) return "";
|
|
202
|
+
try {
|
|
203
|
+
return new Date(ts).toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
204
|
+
} catch {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function approxBytes(base64: string): number {
|
|
210
|
+
// 4 base64 chars ≈ 3 bytes.
|
|
211
|
+
return Math.floor((base64.length * 3) / 4);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function truncate(text: string, max: number): string {
|
|
215
|
+
if (text.length <= max) return text;
|
|
216
|
+
return text.slice(0, max) + `\n… [truncated, ${text.length - max} more chars]`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderHtml(title: string, items: ExportItem[]): string {
|
|
220
|
+
const imgCount = items.reduce(
|
|
221
|
+
(n, it) => n + it.images.length + it.toolResults.reduce((m, r) => m + r.images.length, 0),
|
|
222
|
+
0,
|
|
223
|
+
);
|
|
224
|
+
const parts: string[] = [];
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
const roleLabel = item.role === "assistant" ? "Claude" : "User";
|
|
227
|
+
const time = formatTime(item.ts);
|
|
228
|
+
const blocks: string[] = [];
|
|
229
|
+
|
|
230
|
+
for (const think of item.thinking) {
|
|
231
|
+
blocks.push(
|
|
232
|
+
`<details class="thinking"><summary>Thinking</summary><pre>${escapeHtml(think)}</pre></details>`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
for (const text of item.texts) {
|
|
236
|
+
blocks.push(`<div class="text">${escapeHtml(text)}</div>`);
|
|
237
|
+
}
|
|
238
|
+
for (const img of item.images) {
|
|
239
|
+
blocks.push(`<img class="img" alt="attachment" src="data:${img.mediaType};base64,${img.data}">`);
|
|
240
|
+
}
|
|
241
|
+
for (const tool of item.toolUses) {
|
|
242
|
+
blocks.push(
|
|
243
|
+
`<div class="tool"><span class="tool-name">→ ${escapeHtml(tool.name)}</span><pre>${escapeHtml(
|
|
244
|
+
truncate(tool.input, MAX_TOOL_INPUT_CHARS),
|
|
245
|
+
)}</pre></div>`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
for (const result of item.toolResults) {
|
|
249
|
+
const cls = result.isError ? "toolresult error" : "toolresult";
|
|
250
|
+
const inner: string[] = [];
|
|
251
|
+
if (result.text.trim()) {
|
|
252
|
+
inner.push(`<pre>${escapeHtml(truncate(result.text, MAX_TOOL_RESULT_CHARS))}</pre>`);
|
|
253
|
+
}
|
|
254
|
+
for (const img of result.images) {
|
|
255
|
+
inner.push(`<img class="img" alt="tool output" src="data:${img.mediaType};base64,${img.data}">`);
|
|
256
|
+
}
|
|
257
|
+
if (inner.length) {
|
|
258
|
+
blocks.push(`<div class="${cls}"><span class="tool-label">tool result</span>${inner.join("")}</div>`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!blocks.length) continue;
|
|
263
|
+
parts.push(
|
|
264
|
+
`<section class="msg ${item.role}"><header class="meta"><span class="role">${roleLabel}</span>` +
|
|
265
|
+
`${time ? `<span class="time">${escapeHtml(time)}</span>` : ""}</header>${blocks.join("")}</section>`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const exportedAt = formatTime(Date.now());
|
|
270
|
+
return `<!doctype html>
|
|
271
|
+
<html lang="en">
|
|
272
|
+
<head>
|
|
273
|
+
<meta charset="utf-8">
|
|
274
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
275
|
+
<title>${escapeHtml(title)}</title>
|
|
276
|
+
<style>
|
|
277
|
+
:root { color-scheme: light dark; }
|
|
278
|
+
* { box-sizing: border-box; }
|
|
279
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
280
|
+
background: #f6f7f9; color: #1c1e21; line-height: 1.55; }
|
|
281
|
+
@media (prefers-color-scheme: dark) { body { background: #16181c; color: #e6e6e6; } }
|
|
282
|
+
.page { max-width: 860px; margin: 0 auto; padding: 24px 16px 96px; }
|
|
283
|
+
.doc-header { padding: 16px 0 20px; border-bottom: 1px solid rgba(128,128,128,.25); margin-bottom: 24px; }
|
|
284
|
+
.doc-header h1 { font-size: 20px; margin: 0 0 6px; }
|
|
285
|
+
.doc-header .sub { font-size: 12px; opacity: .6; }
|
|
286
|
+
.msg { padding: 14px 16px; border-radius: 12px; margin: 14px 0; border: 1px solid rgba(128,128,128,.18); }
|
|
287
|
+
.msg.user { background: rgba(99,102,241,.08); }
|
|
288
|
+
.msg.assistant { background: rgba(128,128,128,.06); }
|
|
289
|
+
.meta { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }
|
|
290
|
+
.role { font-weight: 600; font-size: 13px; }
|
|
291
|
+
.msg.user .role { color: #6366f1; }
|
|
292
|
+
.time { font-size: 11px; opacity: .5; }
|
|
293
|
+
.text { white-space: pre-wrap; word-break: break-word; font-size: 14px; }
|
|
294
|
+
.img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; display: block;
|
|
295
|
+
border: 1px solid rgba(128,128,128,.25); }
|
|
296
|
+
pre { white-space: pre-wrap; word-break: break-word; background: rgba(128,128,128,.12);
|
|
297
|
+
padding: 10px; border-radius: 8px; font-size: 12px; overflow-x: auto; margin: 6px 0; }
|
|
298
|
+
.thinking summary { cursor: pointer; font-size: 12px; opacity: .6; }
|
|
299
|
+
.tool, .toolresult { margin: 8px 0; font-size: 12px; }
|
|
300
|
+
.tool-name { font-weight: 600; opacity: .8; }
|
|
301
|
+
.tool-label { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
|
|
302
|
+
opacity: .5; margin-bottom: 2px; }
|
|
303
|
+
.toolresult.error pre { background: rgba(239,68,68,.14); }
|
|
304
|
+
</style>
|
|
305
|
+
</head>
|
|
306
|
+
<body>
|
|
307
|
+
<div class="page">
|
|
308
|
+
<div class="doc-header">
|
|
309
|
+
<h1>${escapeHtml(title)}</h1>
|
|
310
|
+
<div class="sub">${items.length} messages${imgCount ? ` · ${imgCount} image${imgCount === 1 ? "" : "s"}` : ""} · exported ${escapeHtml(exportedAt)}</div>
|
|
311
|
+
</div>
|
|
312
|
+
${parts.join("\n")}
|
|
313
|
+
</div>
|
|
314
|
+
</body>
|
|
315
|
+
</html>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderTxt(title: string, items: ExportItem[]): string {
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
lines.push(title);
|
|
321
|
+
lines.push(`Exported: ${formatTime(Date.now())}`);
|
|
322
|
+
lines.push(`Messages: ${items.length}`);
|
|
323
|
+
lines.push("=".repeat(60));
|
|
324
|
+
lines.push("");
|
|
325
|
+
|
|
326
|
+
for (const item of items) {
|
|
327
|
+
const roleLabel = item.role === "assistant" ? "CLAUDE" : "USER";
|
|
328
|
+
const time = formatTime(item.ts);
|
|
329
|
+
lines.push(`[${time}] ${roleLabel}:`);
|
|
330
|
+
for (const think of item.thinking) {
|
|
331
|
+
lines.push("(thinking)");
|
|
332
|
+
lines.push(think);
|
|
333
|
+
}
|
|
334
|
+
for (const text of item.texts) lines.push(text);
|
|
335
|
+
for (const img of item.images) {
|
|
336
|
+
lines.push(`[image: ${img.mediaType}, ~${approxBytes(img.data)} bytes]`);
|
|
337
|
+
}
|
|
338
|
+
for (const tool of item.toolUses) {
|
|
339
|
+
lines.push(`-> tool: ${tool.name}`);
|
|
340
|
+
lines.push(truncate(tool.input, MAX_TOOL_INPUT_CHARS));
|
|
341
|
+
}
|
|
342
|
+
for (const result of item.toolResults) {
|
|
343
|
+
lines.push(result.isError ? "[tool result · error]" : "[tool result]");
|
|
344
|
+
if (result.text.trim()) lines.push(truncate(result.text, MAX_TOOL_RESULT_CHARS));
|
|
345
|
+
for (const img of result.images) {
|
|
346
|
+
lines.push(`[image: ${img.mediaType}, ~${approxBytes(img.data)} bytes]`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
lines.push("");
|
|
350
|
+
lines.push("-".repeat(60));
|
|
351
|
+
lines.push("");
|
|
352
|
+
}
|
|
353
|
+
return lines.join("\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function sanitizeFilename(name: string): string {
|
|
357
|
+
const cleaned = name.replace(/[^a-zA-Z0-9-_ ]+/g, "").trim().replace(/\s+/g, "-");
|
|
358
|
+
return cleaned || "session";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function buildSessionExport(options: BuildSessionExportOptions): SessionExportResult | null {
|
|
362
|
+
const filePath = resolveClaudeSessionFilePath(options.sessionId, options.projectsRoot);
|
|
363
|
+
if (!filePath || !existsSync(filePath)) return null;
|
|
364
|
+
|
|
365
|
+
const items = parseTranscript(filePath);
|
|
366
|
+
const title = options.title?.trim() || "Session export";
|
|
367
|
+
const datePart = new Date(Date.now()).toISOString().slice(0, 10);
|
|
368
|
+
const base = `${sanitizeFilename(title)}-${datePart}`;
|
|
369
|
+
|
|
370
|
+
if (options.format === "txt") {
|
|
371
|
+
return {
|
|
372
|
+
filename: `${base}.txt`,
|
|
373
|
+
contentType: "text/plain; charset=utf-8",
|
|
374
|
+
body: renderTxt(title, items),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
filename: `${base}.html`,
|
|
379
|
+
contentType: "text/html; charset=utf-8",
|
|
380
|
+
body: renderHtml(title, items),
|
|
381
|
+
};
|
|
382
|
+
}
|