kimaki 0.28.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 (179) 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/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
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.
@@ -8,10 +8,68 @@ import url from 'node:url';
8
8
  import { test, expect, beforeAll, afterAll } from 'vitest';
9
9
  import * as errore from 'errore';
10
10
  import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
11
- import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
11
+ import { ShareMarkdown, fileBaseName, formatCompactToolSummary, formatDuration, getCompactSessionContext, truncateChars, userPromptDurationMs, } from './markdown.js';
12
12
  import { setDataDir } from './config.js';
13
13
  import { initializeOpencodeForDirectory, getOpencodeClient, stopOpencodeServer } from './opencode.js';
14
14
  import { cleanupTestSessions, initTestGitRepo } from './test-utils.js';
15
+ test('truncateChars keeps short text and ellipsizes long text', () => {
16
+ expect(truncateChars('hello world', 80)).toBe('hello world');
17
+ expect(truncateChars(' a b ', 80)).toBe('a b');
18
+ expect(truncateChars('abcdefghij', 5)).toBe('abcd…');
19
+ expect(truncateChars('ab', 1)).toBe('…');
20
+ });
21
+ test('formatDuration uses ms, seconds, then minutes', () => {
22
+ expect(formatDuration(250)).toBe('250ms');
23
+ expect(formatDuration(1500)).toBe('1.5s');
24
+ expect(formatDuration(65_000)).toBe('1m 5s');
25
+ });
26
+ test('userPromptDurationMs spans user send to last following assistant', () => {
27
+ const messages = [
28
+ { info: { role: 'user', time: { created: 1000 } } },
29
+ { info: { role: 'assistant', time: { created: 1100, completed: 4000 } } },
30
+ { info: { role: 'assistant', time: { created: 4100, completed: 9000 } } },
31
+ { info: { role: 'user', time: { created: 20_000 } } },
32
+ { info: { role: 'assistant', time: { created: 20_100, completed: 21_000 } } },
33
+ ];
34
+ expect(userPromptDurationMs({ messages, userIndex: 0 })).toBe(8000);
35
+ expect(userPromptDurationMs({ messages, userIndex: 3 })).toBe(1000);
36
+ });
37
+ test('fileBaseName strips directories on posix and windows paths', () => {
38
+ expect(fileBaseName('/Users/morse/README.md')).toBe('README.md');
39
+ expect(fileBaseName('src\\cli.ts')).toBe('cli.ts');
40
+ });
41
+ test('formatCompactToolSummary indexes read, task, and bash inputs', () => {
42
+ expect(formatCompactToolSummary({
43
+ tool: 'read',
44
+ input: { filePath: '/Users/morse/Documents/GitHub/kimakivoice/README.md', offset: 1 },
45
+ })).toBe('README.md');
46
+ expect(formatCompactToolSummary({
47
+ tool: 'task',
48
+ input: { description: 'analyze gpuix-solid', task_id: 'ses_child123' },
49
+ })).toBe('analyze gpuix-solid ses_child123');
50
+ expect(formatCompactToolSummary({
51
+ tool: 'task',
52
+ input: { description: 'analyze gpuix-solid' },
53
+ metadata: { sessionId: 'ses_from_meta' },
54
+ })).toBe('analyze gpuix-solid ses_from_meta');
55
+ expect(formatCompactToolSummary({
56
+ tool: 'bash',
57
+ input: { command: 'echo hello world', description: 'Print greeting' },
58
+ })).toBe('echo hello world');
59
+ expect(formatCompactToolSummary({
60
+ tool: 'bash',
61
+ input: {
62
+ command: 'a'.repeat(120),
63
+ description: 'Rebuild native addon',
64
+ },
65
+ maxChars: 80,
66
+ })).toBe('Rebuild native addon');
67
+ expect(formatCompactToolSummary({
68
+ tool: 'grep',
69
+ input: { pattern: 'ShareMarkdown', path: 'cli/src/markdown.ts' },
70
+ maxChars: 40,
71
+ })).toBe('pattern=ShareMarkdown path=cli/src/mark…');
72
+ });
15
73
  const ROOT = path.resolve(process.cwd(), 'tmp', 'markdown-test');
16
74
  function createRunDirectories() {
17
75
  fs.mkdirSync(ROOT, { recursive: true });
@@ -181,10 +239,7 @@ afterAll(async () => {
181
239
  // Strip dynamic parts (timestamps, durations, branch names) for stable assertions
182
240
  function normalizeMarkdown(md) {
183
241
  return md
184
- // Normalize "Completed in Xs" to a fixed string
185
- .replace(/\*Completed in [\d.]+[ms]+\*/g, '*Completed in Xs*')
186
- // Normalize "Duration: Xs" tool timing
187
- .replace(/\*Duration: [\d.]+[ms]+\*/g, '*Duration: Xs*')
242
+ .replace(/^duration: .+$/gm, 'duration: <duration>')
188
243
  // Normalize ISO dates in session info
189
244
  .replace(/\*\*Created\*\*: .+/g, '**Created**: <date>')
190
245
  .replace(/\*\*Updated\*\*: .+/g, '**Updated**: <date>')
@@ -206,11 +261,13 @@ test('generate markdown with system info', async () => {
206
261
  expect(markdown).toContain('# Markdown Test Session');
207
262
  expect(markdown).toContain('## Session Information');
208
263
  expect(markdown).toContain('## Conversation');
209
- expect(markdown).toContain('### 👤 User');
264
+ expect(markdown).toContain('### user');
210
265
  expect(markdown).toContain('hello markdown test');
211
- expect(markdown).toContain('### 🤖 Assistant (deterministic-provider/deterministic-v2)');
266
+ expect(markdown).toContain('### assistant (deterministic-provider/deterministic-v2)');
212
267
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
213
- expect(markdown).toContain('**Started using deterministic-provider/deterministic-v2**');
268
+ expect(markdown).not.toContain('Started using');
269
+ expect(markdown).not.toContain('Completed in');
270
+ expect(markdown).toMatch(/^duration: /m);
214
271
  const normalized = normalizeMarkdown(markdown);
215
272
  expect(normalized).toMatchInlineSnapshot(`
216
273
  "# Markdown Test Session
@@ -223,19 +280,17 @@ test('generate markdown with system info', async () => {
223
280
 
224
281
  ## Conversation
225
282
 
226
- ### 👤 User
283
+ ### user
227
284
 
228
285
  hello markdown test
229
286
 
230
287
 
231
- ### 🤖 Assistant (deterministic-provider/deterministic-v2)
232
-
233
- **Started using deterministic-provider/deterministic-v2**
288
+ ### assistant (deterministic-provider/deterministic-v2)
234
289
 
235
290
  Hello! This is a deterministic markdown test response.
236
291
 
237
292
 
238
- *Completed in Xs*
293
+ duration: <duration>
239
294
  "
240
295
  `);
241
296
  });
@@ -256,19 +311,17 @@ test('generate markdown without system info', async () => {
256
311
 
257
312
  ## Conversation
258
313
 
259
- ### 👤 User
314
+ ### user
260
315
 
261
316
  hello markdown test
262
317
 
263
318
 
264
- ### 🤖 Assistant (deterministic-provider/deterministic-v2)
265
-
266
- **Started using deterministic-provider/deterministic-v2**
319
+ ### assistant (deterministic-provider/deterministic-v2)
267
320
 
268
321
  Hello! This is a deterministic markdown test response.
269
322
 
270
323
 
271
- *Completed in Xs*
324
+ duration: <duration>
272
325
  "
273
326
  `);
274
327
  });
@@ -310,6 +363,21 @@ test('generate markdown with lastAssistantOnly', async () => {
310
363
  // Should contain the assistant response
311
364
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
312
365
  });
366
+ test('thinking is omitted unless includeThinking is set', async () => {
367
+ const exporter = new ShareMarkdown(client);
368
+ const hidden = await exporter.generate({
369
+ sessionID,
370
+ includeThinking: false,
371
+ });
372
+ expect(errore.isOk(hidden)).toBe(true);
373
+ expect(errore.unwrap(hidden)).not.toContain('thinking:');
374
+ const shown = await exporter.generate({
375
+ sessionID,
376
+ includeThinking: true,
377
+ });
378
+ expect(errore.isOk(shown)).toBe(true);
379
+ expect(errore.unwrap(shown)).not.toContain('💭');
380
+ });
313
381
  test('compact tools: tool calls show one-liner with line count', async () => {
314
382
  const exporter = new ShareMarkdown(client);
315
383
  const result = await exporter.generate({
@@ -319,7 +387,7 @@ test('compact tools: tool calls show one-liner with line count', async () => {
319
387
  expect(errore.isOk(result)).toBe(true);
320
388
  const md = errore.unwrap(result);
321
389
  // Compact mode: exact one-liner format with params and line count
322
- expect(md).toContain('> 🛠️ **bash** command=echo hello world, description=Print greeting');
390
+ expect(md).toContain('tool: bash echo hello world');
323
391
  expect(md).toMatch(/\(\d+ lines?\)/);
324
392
  // Should NOT contain full output code blocks or input YAML
325
393
  expect(md).not.toContain('**Output:**');
@@ -335,7 +403,7 @@ test('verbose tools: tool calls show full input and output', async () => {
335
403
  expect(errore.isOk(result)).toBe(true);
336
404
  const md = errore.unwrap(result);
337
405
  // Verbose mode: full tool rendering with input YAML and output code block
338
- expect(md).toContain('#### 🛠️ Tool: bash');
406
+ expect(md).toContain('#### tool: bash');
339
407
  expect(md).toContain('**Input:**');
340
408
  expect(md).toContain('```yaml');
341
409
  expect(md).toContain('**Output:**');
@@ -12,9 +12,20 @@ const logger = createLogger(LogPrefix.FORMATTING);
12
12
  export const TOOL_PREFIX = '┣ ';
13
13
  export const FILE_EDIT_PREFIX = '◼︎ ';
14
14
  export const THINKING_PREFIX = '┣ ';
15
- export const STATUS_PREFIX = '⬦ ';
15
+ // Discord subtext. Not `#-`.
16
+ export const SYSTEM_LINE_PREFIX = '-# ';
17
+ export const STATUS_PREFIX = SYSTEM_LINE_PREFIX;
16
18
  export const QUEUE_PREFIX = '» ';
17
- export const WORKTREE_PREFIX = STATUS_PREFIX;
19
+ export const WORKTREE_PREFIX = '⬦ ';
20
+ export function asSystemLine(text) {
21
+ const lead = text.startsWith('\n') ? '\n' : '';
22
+ const body = lead ? text.slice(1) : text;
23
+ return lead + body.split('\n').map((line) => {
24
+ if (line.startsWith(SYSTEM_LINE_PREFIX) || line.startsWith('-#'))
25
+ return line;
26
+ return `${SYSTEM_LINE_PREFIX}${line}`;
27
+ }).join('\n');
28
+ }
18
29
  /**
19
30
  * Serialize Discord embeds into plain text so the AI model can read them.
20
31
  * Each embed becomes an <embed> XML block with title, author, description,
@@ -165,6 +176,11 @@ export function asDiscordQuote(text) {
165
176
  return `> ${line}`;
166
177
  }).join('\n');
167
178
  }
179
+ // Leave one char for the kind-change blank line in sessionPartContent.
180
+ const DISCORD_MESSAGE_MAX_LENGTH = 2000;
181
+ export function quotedTextFitsOneDiscordMessage(text) {
182
+ return asDiscordQuote(text).length < DISCORD_MESSAGE_MAX_LENGTH;
183
+ }
168
184
  function isNonEmptyTextPart(part) {
169
185
  return part.type === 'text' && Boolean(part.text?.trim());
170
186
  }
@@ -187,6 +203,8 @@ export function shouldQuoteIntermediateTextPart({ part, isLastInTurn, nextToolNa
187
203
  return false;
188
204
  if (text.trim().split('\n').length > 2)
189
205
  return false;
206
+ if (!quotedTextFitsOneDiscordMessage(text))
207
+ return false;
190
208
  if (nextToolName === 'question'
191
209
  || nextToolName?.endsWith('kimaki_sleep')
192
210
  || nextToolName?.endsWith('kimaki_action_buttons')) {
@@ -195,31 +213,22 @@ export function shouldQuoteIntermediateTextPart({ part, isLastInTurn, nextToolNa
195
213
  return true;
196
214
  }
197
215
  export function planAssistantTurnFlush({ parts, mode, throughPartId, }) {
198
- const lastText = parts.filter(isNonEmptyTextPart).at(-1);
199
- const lastTextIndex = lastText
200
- ? parts.findLastIndex((part) => part.id === lastText.id)
201
- : -1;
202
216
  const throughIndex = throughPartId
203
217
  ? parts.findIndex((part) => part.id === throughPartId)
204
218
  : -1;
205
219
  const sendUntil = (() => {
206
- if (mode === 'final')
207
- return parts.length;
208
- if (mode === 'interactive') {
209
- if (throughIndex >= 0)
210
- return throughIndex + 1;
211
- return parts.length;
212
- }
213
- if (!lastText)
214
- return parts.length;
215
- // Hold unfinished last text and anything after it so tool order stays intact.
216
- if (!lastText.time?.end)
217
- return lastTextIndex;
220
+ if (mode === 'interactive' && throughIndex >= 0)
221
+ return throughIndex + 1;
218
222
  return parts.length;
219
223
  })();
220
224
  const sendParts = [];
221
225
  const holdParts = [];
222
226
  for (const [index, part] of parts.entries()) {
227
+ // Live tools must not wait for the current text part to end.
228
+ if (mode === 'progress' && part.type === 'text' && !part.time?.end) {
229
+ holdParts.push(part);
230
+ continue;
231
+ }
223
232
  if (index < sendUntil) {
224
233
  sendParts.push({
225
234
  part,
@@ -632,6 +641,8 @@ export function formatTaskToolTitle(part) {
632
641
  export function formatPart(part, prefix) {
633
642
  const pfx = prefix ? `${prefix} ⋅ ` : '';
634
643
  if (part.type === 'text') {
644
+ if (part.synthetic === true)
645
+ return '';
635
646
  const text = part.text?.trim();
636
647
  if (!text)
637
648
  return '';