kimaki 0.29.0 → 0.30.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.
Files changed (135) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bin.js +7 -3
  7. package/dist/bot-lifecycle.test.js +206 -0
  8. package/dist/cache-drift-plugin.js +25 -18
  9. package/dist/cache-rewrite.js +39 -0
  10. package/dist/cache-rewrite.test.js +41 -0
  11. package/dist/cli-commands/session.js +37 -6
  12. package/dist/cli-parsing.test.js +3 -3
  13. package/dist/cli-runner.js +11 -1
  14. package/dist/cli-send-thread.e2e.test.js +2 -2
  15. package/dist/cli.js +4 -4
  16. package/dist/commands/ask-question.js +72 -23
  17. package/dist/commands/queue.js +7 -7
  18. package/dist/database.js +73 -6
  19. package/dist/db.test.js +104 -1
  20. package/dist/discord-bot.js +212 -144
  21. package/dist/external-directory-permissions.e2e.test.js +3 -3
  22. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  23. package/dist/forum-sync/watchers.js +15 -0
  24. package/dist/gateway-proxy.e2e.test.js +9 -9
  25. package/dist/hrana-server.js +68 -14
  26. package/dist/markdown.js +164 -102
  27. package/dist/markdown.test.js +88 -20
  28. package/dist/message-formatting.js +20 -2
  29. package/dist/message-formatting.test.js +20 -1
  30. package/dist/opencode.js +27 -12
  31. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  32. package/dist/question-abort-resume.e2e.test.js +100 -0
  33. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  34. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  35. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  36. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  37. package/dist/queue-advanced-e2e-setup.js +63 -0
  38. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  39. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  40. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  41. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  42. package/dist/queue-delete-message.e2e.test.js +4 -4
  43. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  44. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  45. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  46. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  47. package/dist/session-handler/event-stream-state.js +187 -1
  48. package/dist/session-handler/event-stream-state.test.js +764 -1
  49. package/dist/session-handler/thread-session-runtime.js +185 -16
  50. package/dist/sleep.e2e.test.js +7 -7
  51. package/dist/store.js +1 -1
  52. package/dist/system-message.js +9 -10
  53. package/dist/system-message.test.js +19 -10
  54. package/dist/task-runner.js +7 -0
  55. package/dist/task-schedule.test.js +1 -1
  56. package/dist/test-utils.js +5 -3
  57. package/dist/thread-message-queue.e2e.test.js +30 -31
  58. package/dist/undo-redo.e2e.test.js +3 -3
  59. package/dist/voice-message.e2e.test.js +64 -26
  60. package/dist/voice-routing-input.e2e.test.js +4 -4
  61. package/dist/voice-routing-question.e2e.test.js +4 -4
  62. package/dist/voice.js +107 -21
  63. package/dist/voice.test.js +52 -1
  64. package/dist/wait-session.js +24 -2
  65. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  66. package/package.json +5 -5
  67. package/skills/egaki/SKILL.md +14 -0
  68. package/skills/holocron/SKILL.md +125 -42
  69. package/skills/opensrc/SKILL.md +20 -3
  70. package/skills/spiceflow/SKILL.md +53 -172
  71. package/src/agent-model.e2e.test.ts +33 -33
  72. package/src/anthropic-account-identity.ts +1 -1
  73. package/src/anthropic-auth-plugin.test.ts +37 -2
  74. package/src/anthropic-auth-plugin.ts +9 -7
  75. package/src/anthropic-auth-state.ts +1 -0
  76. package/src/bin.ts +7 -3
  77. package/src/bot-lifecycle.test.ts +252 -0
  78. package/src/cache-drift-plugin.ts +34 -24
  79. package/src/cache-rewrite.test.ts +49 -0
  80. package/src/cache-rewrite.ts +96 -0
  81. package/src/cli-commands/session.ts +48 -8
  82. package/src/cli-parsing.test.ts +3 -3
  83. package/src/cli-runner.ts +11 -0
  84. package/src/cli-send-thread.e2e.test.ts +2 -2
  85. package/src/cli.ts +6 -6
  86. package/src/commands/ask-question.ts +88 -25
  87. package/src/commands/queue.ts +7 -7
  88. package/src/database.ts +105 -6
  89. package/src/db.test.ts +124 -0
  90. package/src/discord-bot.ts +261 -183
  91. package/src/external-directory-permissions.e2e.test.ts +3 -3
  92. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  93. package/src/forum-sync/watchers.ts +20 -0
  94. package/src/gateway-proxy.e2e.test.ts +9 -9
  95. package/src/hrana-server.ts +96 -16
  96. package/src/markdown.test.ts +108 -22
  97. package/src/markdown.ts +216 -103
  98. package/src/message-formatting.test.ts +24 -1
  99. package/src/message-formatting.ts +21 -2
  100. package/src/opencode.ts +28 -13
  101. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  102. package/src/question-abort-resume.e2e.test.ts +121 -0
  103. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  104. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  105. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  106. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  107. package/src/queue-advanced-e2e-setup.ts +65 -0
  108. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  109. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  110. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  111. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  112. package/src/queue-delete-message.e2e.test.ts +4 -4
  113. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  114. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  115. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  116. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  117. package/src/session-handler/event-stream-state.test.ts +825 -0
  118. package/src/session-handler/event-stream-state.ts +265 -3
  119. package/src/session-handler/thread-session-runtime.ts +217 -14
  120. package/src/sleep.e2e.test.ts +7 -7
  121. package/src/store.ts +2 -2
  122. package/src/system-message.test.ts +20 -10
  123. package/src/system-message.ts +9 -10
  124. package/src/task-runner.ts +8 -0
  125. package/src/task-schedule.test.ts +1 -1
  126. package/src/test-utils.ts +5 -3
  127. package/src/thread-message-queue.e2e.test.ts +30 -31
  128. package/src/undo-redo.e2e.test.ts +3 -3
  129. package/src/voice-message.e2e.test.ts +70 -26
  130. package/src/voice-routing-input.e2e.test.ts +4 -4
  131. package/src/voice-routing-question.e2e.test.ts +4 -4
  132. package/src/voice.test.ts +56 -0
  133. package/src/voice.ts +135 -25
  134. package/src/wait-session.ts +25 -2
  135. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -323,9 +323,9 @@ describeIf('gateway-proxy e2e', () => {
323
323
  "--- from: user (proxy-tester)
324
324
  hello from gateway proxy test
325
325
  --- from: assistant (TestBot)
326
- > *using deterministic-provider/deterministic-v2*
326
+ -# *using deterministic-provider/deterministic-v2*
327
327
  > gateway-proxy-reply
328
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
328
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
329
329
  `);
330
330
  expect(reply).toBeDefined();
331
331
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);
@@ -350,14 +350,14 @@ describeIf('gateway-proxy e2e', () => {
350
350
  "--- from: user (proxy-tester)
351
351
  hello from gateway proxy test
352
352
  --- from: assistant (TestBot)
353
- > *using deterministic-provider/deterministic-v2*
353
+ -# *using deterministic-provider/deterministic-v2*
354
354
  > gateway-proxy-reply
355
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
355
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
356
356
  --- from: user (proxy-tester)
357
357
  follow up through proxy
358
358
  --- from: assistant (TestBot)
359
359
  > gateway-proxy-reply
360
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
360
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
361
361
  `);
362
362
  expect(reply).toBeDefined();
363
363
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);
@@ -382,14 +382,14 @@ describeIf('gateway-proxy e2e', () => {
382
382
  "--- from: user (proxy-tester)
383
383
  hello from gateway proxy test
384
384
  --- from: assistant (TestBot)
385
- > *using deterministic-provider/deterministic-v2*
385
+ -# *using deterministic-provider/deterministic-v2*
386
386
  > gateway-proxy-reply
387
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
387
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
388
388
  --- from: user (proxy-tester)
389
389
  follow up through proxy
390
390
  --- from: assistant (TestBot)
391
391
  > gateway-proxy-reply
392
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
392
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
393
393
  --- from: user (proxy-tester)
394
394
  !echo proxy-shell-test
395
395
  --- from: assistant (TestBot)
@@ -417,7 +417,7 @@ describeIf('gateway-proxy e2e', () => {
417
417
  "--- from: user (proxy-tester)
418
418
  second message through proxy
419
419
  --- from: assistant (TestBot)
420
- > *using deterministic-provider/deterministic-v2*"
420
+ -# *using deterministic-provider/deterministic-v2*"
421
421
  `);
422
422
  expect(reply).toBeDefined();
423
423
  expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0);
@@ -148,7 +148,7 @@ export async function startHranaServer({ dbPath, bindAll = false, }) {
148
148
  // Health check — no auth required
149
149
  if (pathname === '/health') {
150
150
  res.writeHead(200, { 'content-type': 'application/json' });
151
- res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
151
+ res.end(JSON.stringify({ status: 'ok', pid: process.pid, wrapperPid: getWrapperPid() }));
152
152
  return;
153
153
  }
154
154
  // OpenCode server port discovery — no auth required (localhost only).
@@ -230,8 +230,12 @@ export async function stopHranaServer() {
230
230
  * Evict a previous kimaki instance on the lock port.
231
231
  * Fetches /health to get the running process PID, then kills it directly.
232
232
  * No lsof/netstat/spawnSync needed — the PID comes from the health response.
233
+ *
234
+ * SIGTERM first so the old bot can clean up. Its own shutdown deadline is
235
+ * 15s, so after a longer grace period we SIGKILL: a stuck process must never
236
+ * block every future start. Safe because the PID comes from our own /health.
233
237
  */
234
- export async function evictExistingInstance({ port }) {
238
+ export async function evictExistingInstance({ port, gracePeriodMs = 20_000, }) {
235
239
  const url = `http://127.0.0.1:${port}/health`;
236
240
  const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch((e) => new FetchError({ url, cause: e }));
237
241
  if (probe instanceof Error)
@@ -242,9 +246,15 @@ export async function evictExistingInstance({ port }) {
242
246
  const targetPid = body.pid;
243
247
  if (!targetPid || targetPid === process.pid)
244
248
  return;
245
- hranaLogger.log(`Evicting existing kimaki process (PID: ${targetPid}) on port ${port}`);
249
+ // Signal the bin.ts wrapper, not only the child: killing just the child
250
+ // looks like a crash and the old wrapper respawns it, which then evicts us.
251
+ // The wrapper forwards SIGTERM and does not restart after it.
252
+ const wrapperPid = body.wrapperPid && body.wrapperPid !== process.ppid
253
+ ? body.wrapperPid
254
+ : null;
255
+ hranaLogger.log(`Evicting existing kimaki process (PID: ${targetPid}, wrapper: ${wrapperPid ?? 'none'}) on port ${port}`);
246
256
  const killResult = errore.try(() => {
247
- process.kill(targetPid, 'SIGTERM');
257
+ process.kill(wrapperPid ?? targetPid, 'SIGTERM');
248
258
  }, (e) => new Error('Failed to send SIGTERM to existing kimaki process', {
249
259
  cause: e,
250
260
  }));
@@ -252,17 +262,61 @@ export async function evictExistingInstance({ port }) {
252
262
  hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
253
263
  return;
254
264
  }
255
- for (let attempt = 0; attempt < 10; attempt += 1) {
265
+ // Wait for process exit, not just a failed probe: a process that already
266
+ // closed its HTTP server can still be alive and holding the socket briefly.
267
+ if (await waitForProcessExit({ pid: targetPid, timeoutMs: gracePeriodMs })) {
268
+ return;
269
+ }
270
+ hranaLogger.log(`PID ${targetPid} still alive after ${gracePeriodMs / 1000}s SIGTERM grace period, sending SIGKILL`);
271
+ // Wrapper first so it cannot respawn the child we are about to kill.
272
+ const wrapperKillResult = wrapperPid
273
+ ? errore.try(() => {
274
+ process.kill(wrapperPid, 'SIGKILL');
275
+ }, (e) => new Error('Failed to send SIGKILL to kimaki wrapper', { cause: e }))
276
+ : null;
277
+ if (wrapperKillResult instanceof Error) {
278
+ hranaLogger.log(`Failed to kill wrapper PID ${wrapperPid}: ${wrapperKillResult.message}`);
279
+ }
280
+ const forceKillResult = errore.try(() => {
281
+ process.kill(targetPid, 'SIGKILL');
282
+ }, (e) => new Error('Failed to send SIGKILL to existing kimaki process', {
283
+ cause: e,
284
+ }));
285
+ if (forceKillResult instanceof Error) {
286
+ hranaLogger.log(`Failed to kill PID ${targetPid}: ${forceKillResult.message}`);
287
+ return;
288
+ }
289
+ if (await waitForProcessExit({ pid: targetPid, timeoutMs: 5_000 })) {
290
+ return;
291
+ }
292
+ hranaLogger.log(`PID ${targetPid} still alive after SIGKILL`);
293
+ }
294
+ // PID of the bin.ts respawn wrapper, only while it is alive (IPC connected).
295
+ // Without the connected check an orphan would report ppid 1.
296
+ function getWrapperPid() {
297
+ if (!process.env.__KIMAKI_CHILD || !process.connected) {
298
+ return null;
299
+ }
300
+ return process.ppid;
301
+ }
302
+ function isProcessAlive(pid) {
303
+ const result = errore.try(() => {
304
+ process.kill(pid, 0);
305
+ }, (e) => new Error('Process liveness check failed', { cause: e }));
306
+ if (result instanceof Error) {
307
+ // EPERM means the PID exists but belongs to another user.
308
+ return result.cause instanceof Error && Reflect.get(result.cause, 'code') === 'EPERM';
309
+ }
310
+ return true;
311
+ }
312
+ async function waitForProcessExit({ pid, timeoutMs, }) {
313
+ const deadline = Date.now() + timeoutMs;
314
+ while (Date.now() < deadline) {
315
+ if (!isProcessAlive(pid))
316
+ return true;
256
317
  await new Promise((resolve) => {
257
- setTimeout(resolve, 1000);
318
+ setTimeout(resolve, 200);
258
319
  });
259
- // Verify it's gone. Some shutdown paths need a few seconds to run cleanup,
260
- // so we avoid SIGKILL and just poll for up to 10 seconds.
261
- const secondProbe = await fetch(url, {
262
- signal: AbortSignal.timeout(2000),
263
- }).catch((e) => new FetchError({ url, cause: e }));
264
- if (secondProbe instanceof Error)
265
- return;
266
320
  }
267
- hranaLogger.log(`PID ${targetPid} still alive after 10s SIGTERM grace period`);
321
+ return !isProcessAlive(pid);
268
322
  }
package/dist/markdown.js CHANGED
@@ -15,6 +15,112 @@ class UnexpectedError extends errore.createTaggedError({
15
15
  }
16
16
  const markdownLogger = createLogger(LogPrefix.MARKDOWN);
17
17
  const TOOL_OUTPUT_MAX_CHARS = 30_000;
18
+ export const DEFAULT_TOOL_INPUT_MAX_CHARS = 80;
19
+ function stringifyToolValue(value) {
20
+ if (typeof value === 'string')
21
+ return value;
22
+ return JSON.stringify(value);
23
+ }
24
+ export function truncateChars(value, maxChars) {
25
+ const collapsed = value.replace(/\s+/g, ' ').trim();
26
+ if (maxChars <= 0)
27
+ return '';
28
+ if (collapsed.length <= maxChars)
29
+ return collapsed;
30
+ if (maxChars === 1)
31
+ return '…';
32
+ return `${collapsed.slice(0, maxChars - 1)}…`;
33
+ }
34
+ export function formatDuration(ms) {
35
+ if (ms < 1000)
36
+ return `${ms}ms`;
37
+ if (ms < 60_000)
38
+ return `${(ms / 1000).toFixed(1)}s`;
39
+ const minutes = Math.floor(ms / 60_000);
40
+ const seconds = Math.floor((ms % 60_000) / 1000);
41
+ return `${minutes}m ${seconds}s`;
42
+ }
43
+ function messageCreatedAt(message) {
44
+ const created = message.time?.created;
45
+ return typeof created === 'number' ? created : undefined;
46
+ }
47
+ function messageEndedAt(message) {
48
+ const completed = message.time?.completed;
49
+ if (typeof completed === 'number')
50
+ return completed;
51
+ return messageCreatedAt(message);
52
+ }
53
+ /** User sent → last assistant finished, until the next user message. */
54
+ export function userPromptDurationMs({ messages, userIndex, }) {
55
+ const userCreated = messageCreatedAt(messages[userIndex]?.info ?? {});
56
+ if (userCreated === undefined)
57
+ return undefined;
58
+ let end = userCreated;
59
+ for (let i = userIndex + 1; i < messages.length; i++) {
60
+ const info = messages[i]?.info;
61
+ if (info?.role === 'user')
62
+ break;
63
+ if (info?.role !== 'assistant')
64
+ continue;
65
+ const ended = messageEndedAt(info);
66
+ if (ended !== undefined && ended > end)
67
+ end = ended;
68
+ }
69
+ const ms = end - userCreated;
70
+ return ms > 0 ? ms : undefined;
71
+ }
72
+ export function fileBaseName(filePath) {
73
+ const normalized = filePath.replaceAll('\\', '/');
74
+ const segments = normalized.split('/');
75
+ return segments[segments.length - 1] || filePath;
76
+ }
77
+ function readTaskSessionId(metadata) {
78
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
79
+ return undefined;
80
+ const sessionId = metadata.sessionId;
81
+ return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined;
82
+ }
83
+ function taskChildSessionId({ input, metadata, }) {
84
+ const sessionId = readTaskSessionId(metadata);
85
+ if (sessionId)
86
+ return sessionId;
87
+ const taskId = input?.task_id;
88
+ if (typeof taskId === 'string' && taskId.startsWith('ses'))
89
+ return taskId;
90
+ return undefined;
91
+ }
92
+ /** Compact tool input for session markdown. Keep this greppable and short. */
93
+ export function formatCompactToolSummary({ tool, input, maxChars = DEFAULT_TOOL_INPUT_MAX_CHARS, metadata, }) {
94
+ const record = input ?? {};
95
+ if (tool === 'read') {
96
+ const path = record.filePath ?? record.path;
97
+ return typeof path === 'string' && path.length > 0 ? fileBaseName(path) : '';
98
+ }
99
+ if (tool === 'task') {
100
+ const description = typeof record.description === 'string' ? record.description : '';
101
+ const sessionId = taskChildSessionId({ input: record, metadata });
102
+ if (!sessionId)
103
+ return truncateChars(description, maxChars);
104
+ const separator = description.trim() ? 1 : 0;
105
+ const descBudget = Math.max(0, maxChars - sessionId.length - separator);
106
+ const desc = truncateChars(description, descBudget);
107
+ return [desc, sessionId].filter(Boolean).join(' ');
108
+ }
109
+ if (tool === 'bash') {
110
+ const command = typeof record.command === 'string' ? record.command : '';
111
+ const collapsedCommand = command.replace(/\s+/g, ' ').trim();
112
+ if (collapsedCommand.length > 0 && collapsedCommand.length <= maxChars) {
113
+ return collapsedCommand;
114
+ }
115
+ const description = typeof record.description === 'string' ? record.description : '';
116
+ return truncateChars(description || collapsedCommand, maxChars);
117
+ }
118
+ const parts = [];
119
+ for (const [key, value] of Object.entries(record)) {
120
+ parts.push(`${key}=${stringifyToolValue(value)}`);
121
+ }
122
+ return truncateChars(parts.join(' '), maxChars);
123
+ }
18
124
  export class ShareMarkdown {
19
125
  client;
20
126
  constructor(client) {
@@ -26,7 +132,7 @@ export class ShareMarkdown {
26
132
  * @returns Error or markdown string
27
133
  */
28
134
  async generate(options) {
29
- const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options;
135
+ const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true, includeThinking = false, toolInputMaxChars = DEFAULT_TOOL_INPUT_MAX_CHARS, } = options;
30
136
  // Get session info
31
137
  const sessionResponse = await this.client.session.get({
32
138
  sessionID,
@@ -45,12 +151,7 @@ export class ShareMarkdown {
45
151
  const messages = messagesResponse.data;
46
152
  // If lastAssistantOnly, filter to only the last assistant message
47
153
  const messagesToRender = lastAssistantOnly
48
- ? (() => {
49
- const assistantMessages = messages.filter((m) => m.info.role === 'assistant');
50
- return assistantMessages.length > 0
51
- ? [assistantMessages[assistantMessages.length - 1]]
52
- : [];
53
- })()
154
+ ? messages.filter((m) => m.info.role === 'assistant').slice(-1)
54
155
  : messages;
55
156
  // Build markdown
56
157
  const lines = [];
@@ -74,17 +175,37 @@ export class ShareMarkdown {
74
175
  lines.push('## Conversation');
75
176
  lines.push('');
76
177
  }
77
- for (const message of messagesToRender) {
78
- const messageLines = this.renderMessage(message.info, message.parts, { compactTools });
178
+ for (const [index, message] of messagesToRender.entries()) {
179
+ const messageLines = this.renderMessage(message.info, message.parts, {
180
+ compactTools,
181
+ includeThinking,
182
+ toolInputMaxChars,
183
+ });
79
184
  lines.push(...messageLines);
80
185
  lines.push('');
186
+ const nextRole = messagesToRender[index + 1]?.info.role;
187
+ if (message.info.role !== 'assistant' || nextRole === 'assistant')
188
+ continue;
189
+ for (let userIndex = index - 1; userIndex >= 0; userIndex--) {
190
+ if (messagesToRender[userIndex]?.info.role !== 'user')
191
+ continue;
192
+ const durationMs = userPromptDurationMs({
193
+ messages: messagesToRender,
194
+ userIndex,
195
+ });
196
+ if (durationMs !== undefined) {
197
+ lines.push(`duration: ${formatDuration(durationMs)}`);
198
+ lines.push('');
199
+ }
200
+ break;
201
+ }
81
202
  }
82
203
  return lines.join('\n');
83
204
  }
84
205
  renderMessage(message, parts, opts) {
85
206
  const lines = [];
86
207
  if (message.role === 'user') {
87
- lines.push('### 👤 User');
208
+ lines.push('### user');
88
209
  lines.push('');
89
210
  for (const part of parts) {
90
211
  if (part.type === 'text' && part.text) {
@@ -95,7 +216,7 @@ export class ShareMarkdown {
95
216
  }
96
217
  }
97
218
  else if (part.type === 'file') {
98
- lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`);
219
+ lines.push(`file: ${part.filename || 'unnamed file'}`);
99
220
  if (part.url) {
100
221
  lines.push(` - URL: ${part.url}`);
101
222
  }
@@ -104,47 +225,38 @@ export class ShareMarkdown {
104
225
  }
105
226
  }
106
227
  else if (message.role === 'assistant') {
107
- const modelId = [message.providerID, message.modelID].filter(Boolean).join('/') ||
108
- 'unknown model';
109
- lines.push(`### 🤖 Assistant (${modelId})`);
110
- lines.push('');
111
- // Filter and process parts
112
228
  const filteredParts = parts.filter((part) => {
113
- if (part.type === 'step-start' && parts.indexOf(part) > 0)
114
- return false;
115
- if (part.type === 'snapshot')
116
- return false;
117
- if (part.type === 'patch')
229
+ if (part.type === 'step-start' ||
230
+ part.type === 'step-finish' ||
231
+ part.type === 'snapshot' ||
232
+ part.type === 'patch')
118
233
  return false;
119
- if (part.type === 'step-finish')
120
- return false;
121
- if (part.type === 'text' && part.synthetic === true)
234
+ if (part.type === 'text' && (part.synthetic === true || !part.text))
122
235
  return false;
123
236
  if (part.type === 'tool' && part.tool === 'todoread')
124
237
  return false;
125
- if (part.type === 'text' && !part.text)
126
- return false;
127
238
  if (part.type === 'tool' &&
128
239
  (part.state.status === 'pending' || part.state.status === 'running'))
129
240
  return false;
130
241
  return true;
131
242
  });
243
+ const body = [];
132
244
  for (const part of filteredParts) {
133
- const partLines = opts.compactTools
134
- ? this.renderPartCompact(part, message)
135
- : this.renderPart(part, message);
136
- lines.push(...partLines);
137
- }
138
- // Add completion time if available
139
- if (message.time?.completed) {
140
- const duration = message.time.completed - message.time.created;
141
- lines.push('');
142
- lines.push(`*Completed in ${this.formatDuration(duration)}*`);
245
+ body.push(...(opts.compactTools
246
+ ? this.renderPartCompact(part, opts)
247
+ : this.renderPart(part, opts)));
143
248
  }
249
+ if (body.length === 0)
250
+ return lines;
251
+ const modelId = [message.providerID, message.modelID].filter(Boolean).join('/') ||
252
+ 'unknown model';
253
+ lines.push(`### assistant (${modelId})`);
254
+ lines.push('');
255
+ lines.push(...body);
144
256
  }
145
257
  return lines;
146
258
  }
147
- renderPart(part, message) {
259
+ renderPart(part, opts) {
148
260
  const lines = [];
149
261
  switch (part.type) {
150
262
  case 'text':
@@ -154,14 +266,11 @@ export class ShareMarkdown {
154
266
  }
155
267
  break;
156
268
  case 'reasoning':
157
- if (part.text) {
158
- lines.push('<details>');
159
- lines.push('<summary>💭 Thinking</summary>');
269
+ if (opts.includeThinking && part.text) {
270
+ lines.push('thinking:');
160
271
  lines.push('');
161
272
  lines.push(part.text);
162
273
  lines.push('');
163
- lines.push('</details>');
164
- lines.push('');
165
274
  }
166
275
  break;
167
276
  case 'tool':
@@ -169,10 +278,10 @@ export class ShareMarkdown {
169
278
  const output = part.state.output || '';
170
279
  const isOversized = output.length > TOOL_OUTPUT_MAX_CHARS;
171
280
  if (isOversized) {
172
- lines.push(`> ⚠️ **Large tool output** (${output.length.toLocaleString()} chars, truncated to ${TOOL_OUTPUT_MAX_CHARS.toLocaleString()})`);
281
+ lines.push(`> Large tool output (${output.length.toLocaleString()} chars, truncated to ${TOOL_OUTPUT_MAX_CHARS.toLocaleString()})`);
173
282
  lines.push('');
174
283
  }
175
- lines.push(`#### 🛠️ Tool: ${part.tool}`);
284
+ lines.push(`#### tool: ${part.tool}`);
176
285
  lines.push('');
177
286
  // Render input parameters in YAML
178
287
  if (part.state.input && Object.keys(part.state.input).length > 0) {
@@ -193,17 +302,9 @@ export class ShareMarkdown {
193
302
  lines.push('```');
194
303
  lines.push('');
195
304
  }
196
- // Add timing info if significant
197
- if (part.state.time?.start && part.state.time?.end) {
198
- const duration = part.state.time.end - part.state.time.start;
199
- if (duration > 2000) {
200
- lines.push(`*Duration: ${this.formatDuration(duration)}*`);
201
- lines.push('');
202
- }
203
- }
204
305
  }
205
306
  else if (part.state.status === 'error') {
206
- lines.push(`#### ❌ Tool Error: ${part.tool}`);
307
+ lines.push(`#### tool-error: ${part.tool}`);
207
308
  lines.push('');
208
309
  lines.push('```');
209
310
  lines.push(part.state.error || 'Unknown error');
@@ -211,75 +312,36 @@ export class ShareMarkdown {
211
312
  lines.push('');
212
313
  }
213
314
  break;
214
- case 'step-start':
215
- lines.push(`**Started using ${message.providerID}/${message.modelID}**`);
216
- lines.push('');
217
- break;
218
315
  }
219
316
  return lines;
220
317
  }
221
318
  /** Compact rendering: tool calls become a single line with line count instead of full output. */
222
- renderPartCompact(part, message) {
223
- // Non-tool parts render the same as verbose
319
+ renderPartCompact(part, opts) {
224
320
  if (part.type !== 'tool') {
225
- return this.renderPart(part, message);
321
+ return this.renderPart(part, opts);
226
322
  }
227
323
  const lines = [];
228
324
  if (part.state.status === 'completed') {
229
325
  const output = part.state.output || '';
230
326
  const lineCount = output ? output.split('\n').length : 0;
231
- const inputSummary = this.compactInputSummary(part.state.input);
327
+ const inputSummary = formatCompactToolSummary({
328
+ tool: part.tool,
329
+ input: part.state.input,
330
+ maxChars: opts.toolInputMaxChars,
331
+ metadata: part.state.metadata,
332
+ });
232
333
  const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : '';
233
334
  const parts = [inputSummary, outputLabel].filter(Boolean).join(' ');
234
- lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`);
335
+ lines.push(`tool: ${part.tool}${parts ? ` ${parts}` : ''}`);
235
336
  lines.push('');
236
337
  }
237
338
  else if (part.state.status === 'error') {
238
339
  const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120);
239
- lines.push(`> ❌ **${part.tool}** — ${errorText}`);
340
+ lines.push(`tool-error: ${part.tool} ${errorText}`);
240
341
  lines.push('');
241
342
  }
242
343
  return lines;
243
344
  }
244
- /** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
245
- compactInputSummary(input) {
246
- if (!input)
247
- return '';
248
- const entries = Object.entries(input);
249
- if (entries.length === 0)
250
- return '';
251
- const parts = [];
252
- const maxParams = 2;
253
- for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
254
- const [key, value] = entries[i];
255
- let val;
256
- try {
257
- val = typeof value === 'string'
258
- ? value
259
- : (JSON.stringify(value) ?? String(value));
260
- }
261
- catch {
262
- val = String(value);
263
- }
264
- // Collapse whitespace for readability
265
- const collapsed = val.replace(/\s+/g, ' ').trim();
266
- const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed;
267
- parts.push(`${key}=${normalized}`);
268
- }
269
- if (entries.length > maxParams) {
270
- parts.push('...');
271
- }
272
- return parts.join(', ');
273
- }
274
- formatDuration(ms) {
275
- if (ms < 1000)
276
- return `${ms}ms`;
277
- if (ms < 60000)
278
- return `${(ms / 1000).toFixed(1)}s`;
279
- const minutes = Math.floor(ms / 60000);
280
- const seconds = Math.floor((ms % 60000) / 1000);
281
- return `${minutes}m ${seconds}s`;
282
- }
283
345
  }
284
346
  /**
285
347
  * Generate compact session context for voice transcription.