kimaki 0.18.0 → 0.19.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.
@@ -1,4 +1,8 @@
1
1
  // Terminal send command for creating Discord threads and scheduling prompts.
2
+ // Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
3
+ // The local SQLite database (channel_directories) is NOT required for the basic
4
+ // flow: post message → create thread → remote bot picks it up. The local project
5
+ // directory mapping is only needed for --send-at, --wait, and --cwd.
2
6
  import { goke } from 'goke';
3
7
  import { z } from 'zod';
4
8
  import { note } from '@clack/prompts';
@@ -271,9 +275,18 @@ cli
271
275
  if (!threadData.parent_id) {
272
276
  throw new Error(`Thread has no parent channel: ${targetThreadId}`);
273
277
  }
278
+ // channelConfig is optional: in CI/headless environments the local DB
279
+ // has no channel_directories rows because the bot hasn't synced yet.
280
+ // The running bot on the other end resolves the directory from its own DB.
281
+ // We only require it for features that genuinely need a local directory
282
+ // (scheduled tasks and --wait).
274
283
  const channelConfig = await getChannelDirectory(threadData.parent_id);
275
- if (!channelConfig) {
276
- throw new Error('Thread parent channel is not configured with a project directory');
284
+ // Guard early: fail before sending the message if a feature that
285
+ // needs local project directory mapping is requested.
286
+ if (!channelConfig && (parsedSchedule || options.wait)) {
287
+ const flag = parsedSchedule ? '--send-at' : '--wait';
288
+ throw new Error('Thread parent channel is not configured with a project directory. ' +
289
+ `${flag} requires a local project mapping. Run the bot first to sync channel data.`);
277
290
  }
278
291
  if (parsedSchedule) {
279
292
  const payload = {
@@ -298,6 +311,7 @@ cli
298
311
  channelId: threadData.parent_id,
299
312
  threadId: targetThreadId,
300
313
  sessionId: sessionId || undefined,
314
+ // channelConfig is guaranteed: early guard threw if missing with --send-at
301
315
  projectDirectory: channelConfig.directory,
302
316
  });
303
317
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
@@ -337,6 +351,8 @@ cli
337
351
  process.stdout.write(`Session: ${existingSessionId}\n`);
338
352
  process.stdout.write(`${threadUrl}\n`);
339
353
  if (options.wait) {
354
+ // channelConfig is guaranteed here: early guard above already
355
+ // threw if channelConfig is missing when --wait is used.
340
356
  const { waitAndOutputSession } = await import('../wait-session.js');
341
357
  await waitAndOutputSession({
342
358
  threadId: targetThreadId,
@@ -352,17 +368,25 @@ cli
352
368
  }
353
369
  // Get channel info to extract directory from topic
354
370
  const channelData = (await rest.get(Routes.channel(channelId)));
371
+ // channelConfig is optional: in CI/headless environments the local DB
372
+ // has no channel_directories rows because the bot hasn't synced yet.
373
+ // The running bot on the other end resolves the directory from its own DB.
374
+ // We only require it for features that genuinely need a local directory
375
+ // (--send-at, --wait, --cwd).
355
376
  const channelConfig = await getChannelDirectory(channelData.id);
356
- if (!channelConfig && !notifyOnly) {
357
- cliLogger.log('Channel not configured');
358
- throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
359
- }
360
377
  const projectDirectory = channelConfig?.directory;
378
+ // Features that require a local project directory mapping
379
+ const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
380
+ if (!channelConfig && needsProjectDirectory) {
381
+ throw new Error(`Channel #${channelData.name} is not configured with a project directory. ` +
382
+ `${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
383
+ 'Run the bot first to sync channel data.');
384
+ }
361
385
  // Validate --cwd is inside the project or an existing git worktree.
362
386
  let resolvedCwd;
363
387
  if (options.cwd) {
364
- // projectDirectory is guaranteed here: --cwd is incompatible with --notify-only,
365
- // and non-notify sends already require channelConfig above.
388
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
389
+ // already threw if channelConfig is missing when --cwd is used.
366
390
  const cwdResult = await resolveSessionWorkingDirectory({
367
391
  projectDirectory: projectDirectory,
368
392
  candidatePath: options.cwd,
@@ -510,8 +534,8 @@ cli
510
534
  process.stdout.write(`Session: ${newSessionId}\n`);
511
535
  process.stdout.write(`${threadUrl}\n`);
512
536
  if (options.wait) {
513
- // projectDirectory is guaranteed here: --wait is incompatible with --notify-only,
514
- // and non-notify sends already require channelConfig above.
537
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
538
+ // already threw if channelConfig is missing when --wait is used.
515
539
  const { waitAndOutputSession } = await import('../wait-session.js');
516
540
  await waitAndOutputSession({
517
541
  threadId: threadData.id,
@@ -126,6 +126,7 @@ cli
126
126
  cli
127
127
  .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
128
128
  .option('--project <path>', 'Project directory (defaults to cwd)')
129
+ .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
129
130
  .action(async (sessionId, options) => {
130
131
  try {
131
132
  const projectDirectory = path.resolve(options.project || '.');
@@ -137,8 +138,9 @@ cli
137
138
  process.exit(EXIT_NO_RESTART);
138
139
  }
139
140
  // Try current project first (fast path)
141
+ const compactTools = !options.verbose;
140
142
  const markdown = new ShareMarkdown(getClient());
141
- const result = await markdown.generate({ sessionID: sessionId });
143
+ const result = await markdown.generate({ sessionID: sessionId, compactTools });
142
144
  if (!(result instanceof Error)) {
143
145
  process.stdout.write(result);
144
146
  process.exit(0);
@@ -171,6 +173,7 @@ cli
171
173
  const otherMarkdown = new ShareMarkdown(otherClient());
172
174
  const otherResult = await otherMarkdown.generate({
173
175
  sessionID: sessionId,
176
+ compactTools,
174
177
  });
175
178
  if (!(otherResult instanceof Error)) {
176
179
  process.stdout.write(otherResult);
package/dist/markdown.js CHANGED
@@ -26,7 +26,7 @@ export class ShareMarkdown {
26
26
  * @returns Error or markdown string
27
27
  */
28
28
  async generate(options) {
29
- const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
29
+ const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options;
30
30
  // Get session info
31
31
  const sessionResponse = await this.client.session.get({
32
32
  sessionID,
@@ -75,13 +75,13 @@ export class ShareMarkdown {
75
75
  lines.push('');
76
76
  }
77
77
  for (const message of messagesToRender) {
78
- const messageLines = this.renderMessage(message.info, message.parts);
78
+ const messageLines = this.renderMessage(message.info, message.parts, { compactTools });
79
79
  lines.push(...messageLines);
80
80
  lines.push('');
81
81
  }
82
82
  return lines.join('\n');
83
83
  }
84
- renderMessage(message, parts) {
84
+ renderMessage(message, parts, opts) {
85
85
  const lines = [];
86
86
  if (message.role === 'user') {
87
87
  lines.push('### 👤 User');
@@ -128,7 +128,9 @@ export class ShareMarkdown {
128
128
  return true;
129
129
  });
130
130
  for (const part of filteredParts) {
131
- const partLines = this.renderPart(part, message);
131
+ const partLines = opts.compactTools
132
+ ? this.renderPartCompact(part, message)
133
+ : this.renderPart(part, message);
132
134
  lines.push(...partLines);
133
135
  }
134
136
  // Add completion time if available
@@ -214,6 +216,59 @@ export class ShareMarkdown {
214
216
  }
215
217
  return lines;
216
218
  }
219
+ /** Compact rendering: tool calls become a single line with line count instead of full output. */
220
+ renderPartCompact(part, message) {
221
+ // Non-tool parts render the same as verbose
222
+ if (part.type !== 'tool') {
223
+ return this.renderPart(part, message);
224
+ }
225
+ const lines = [];
226
+ if (part.state.status === 'completed') {
227
+ const output = part.state.output || '';
228
+ const lineCount = output ? output.split('\n').length : 0;
229
+ const inputSummary = this.compactInputSummary(part.state.input);
230
+ const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : '';
231
+ const parts = [inputSummary, outputLabel].filter(Boolean).join(' ');
232
+ lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`);
233
+ lines.push('');
234
+ }
235
+ else if (part.state.status === 'error') {
236
+ const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120);
237
+ lines.push(`> ❌ **${part.tool}** — ${errorText}`);
238
+ lines.push('');
239
+ }
240
+ return lines;
241
+ }
242
+ /** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
243
+ compactInputSummary(input) {
244
+ if (!input)
245
+ return '';
246
+ const entries = Object.entries(input);
247
+ if (entries.length === 0)
248
+ return '';
249
+ const parts = [];
250
+ const maxParams = 2;
251
+ for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
252
+ const [key, value] = entries[i];
253
+ let val;
254
+ try {
255
+ val = typeof value === 'string'
256
+ ? value
257
+ : (JSON.stringify(value) ?? String(value));
258
+ }
259
+ catch {
260
+ val = String(value);
261
+ }
262
+ // Collapse whitespace for readability
263
+ const collapsed = val.replace(/\s+/g, ' ').trim();
264
+ const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed;
265
+ parts.push(`${key}=${normalized}`);
266
+ }
267
+ if (entries.length > maxParams) {
268
+ parts.push('...');
269
+ }
270
+ return parts.join(', ');
271
+ }
217
272
  formatDuration(ms) {
218
273
  if (ms < 1000)
219
274
  return `${ms}ms`;
@@ -36,6 +36,18 @@ function createMatchers() {
36
36
  ],
37
37
  },
38
38
  };
39
+ const toolCallMatcher = {
40
+ id: 'tool-call-reply',
41
+ priority: 90,
42
+ when: { latestUserTextIncludes: 'use a tool please' },
43
+ then: {
44
+ parts: [
45
+ { type: 'stream-start', warnings: [] },
46
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
47
+ { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
48
+ ],
49
+ },
50
+ };
39
51
  const defaultMatcher = {
40
52
  id: 'default-reply',
41
53
  priority: 1,
@@ -49,12 +61,13 @@ function createMatchers() {
49
61
  ],
50
62
  },
51
63
  };
52
- return [helloMatcher, defaultMatcher];
64
+ return [helloMatcher, toolCallMatcher, defaultMatcher];
53
65
  }
54
66
  let client;
55
67
  let directories;
56
68
  let testStartTime;
57
69
  let sessionID;
70
+ let toolSessionID;
58
71
  beforeAll(async () => {
59
72
  testStartTime = Date.now();
60
73
  directories = createRunDirectories();
@@ -117,7 +130,34 @@ beforeAll(async () => {
117
130
  setTimeout(resolve, 200);
118
131
  });
119
132
  }
120
- }, 20_000);
133
+ // Create a second session that triggers a tool call (bash echo)
134
+ const toolCreateResult = await client.session.create({
135
+ directory: directories.projectDirectory,
136
+ title: 'Tool Call Session',
137
+ });
138
+ toolSessionID = toolCreateResult.data.id;
139
+ await client.session.promptAsync({
140
+ sessionID: toolSessionID,
141
+ directory: directories.projectDirectory,
142
+ parts: [{ type: 'text', text: 'use a tool please' }],
143
+ });
144
+ // Wait for tool execution to complete
145
+ const toolMaxWait = 15_000;
146
+ const toolPollStart = Date.now();
147
+ while (Date.now() - toolPollStart < toolMaxWait) {
148
+ const msgs = await client.session.messages({
149
+ sessionID: toolSessionID,
150
+ directory: directories.projectDirectory,
151
+ });
152
+ const messages = msgs.data || [];
153
+ const hasToolPart = messages.some((m) => m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'));
154
+ if (hasToolPart) {
155
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
156
+ break;
157
+ }
158
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
159
+ }
160
+ }, 30_000);
121
161
  afterAll(async () => {
122
162
  if (directories) {
123
163
  await cleanupTestSessions({
@@ -262,3 +302,34 @@ test('generate markdown with lastAssistantOnly', async () => {
262
302
  // Should contain the assistant response
263
303
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
264
304
  });
305
+ test('compact tools: tool calls show one-liner with line count', async () => {
306
+ const exporter = new ShareMarkdown(client);
307
+ const result = await exporter.generate({
308
+ sessionID: toolSessionID,
309
+ compactTools: true,
310
+ });
311
+ expect(errore.isOk(result)).toBe(true);
312
+ const md = errore.unwrap(result);
313
+ // Compact mode: exact one-liner format with params and line count
314
+ expect(md).toContain('> 🛠️ **bash** command=echo hello world, description=Print greeting');
315
+ expect(md).toMatch(/\(\d+ lines?\)/);
316
+ // Should NOT contain full output code blocks or input YAML
317
+ expect(md).not.toContain('**Output:**');
318
+ expect(md).not.toContain('**Input:**');
319
+ expect(md).not.toContain('```yaml');
320
+ });
321
+ test('verbose tools: tool calls show full input and output', async () => {
322
+ const exporter = new ShareMarkdown(client);
323
+ const result = await exporter.generate({
324
+ sessionID: toolSessionID,
325
+ compactTools: false,
326
+ });
327
+ expect(errore.isOk(result)).toBe(true);
328
+ const md = errore.unwrap(result);
329
+ // Verbose mode: full tool rendering with input YAML and output code block
330
+ expect(md).toContain('#### 🛠️ Tool: bash');
331
+ expect(md).toContain('**Input:**');
332
+ expect(md).toContain('```yaml');
333
+ expect(md).toContain('**Output:**');
334
+ expect(md).toContain('hello world');
335
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.18.0",
5
+ "version": "0.19.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,9 +25,9 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "db": "^0.0.0",
29
28
  "opencode-cached-provider": "^0.0.1",
30
- "opencode-deterministic-provider": "^0.0.1"
29
+ "opencode-deterministic-provider": "^0.0.1",
30
+ "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
33
33
  "@ai-sdk/google": "^3.0.53",
@@ -199,6 +199,40 @@ After setup, `sigillo run` in any subdirectory uses that project + environment a
199
199
 
200
200
  Avoid `sigillo secrets download` unless a specific tool requires a file. Prefer injecting directly via `sigillo run --` so values never touch the filesystem.
201
201
 
202
+ ## Placeholder secrets (user fills in later)
203
+
204
+ When the user asks to add a secret but will provide the actual value later via the dashboard, set it with an empty value:
205
+
206
+ ```bash
207
+ sigillo secrets set DATABASE_URL ""
208
+ ```
209
+
210
+ This creates the secret as a placeholder. The CLI shows `empty: true` when listing secrets so empty placeholders are visible. After setting the placeholder, always print the dashboard URL so the user can fill it in:
211
+
212
+ ```
213
+ https://sigillo.dev/dash/projects/<PROJECT_ID>/envs/<ENV_SLUG>
214
+ ```
215
+
216
+ Replace `<PROJECT_ID>` and `<ENV_SLUG>` with the actual values from the current setup. To find them:
217
+
218
+ ```bash
219
+ sigillo secrets
220
+ ```
221
+
222
+ The `environment_id` in the output is the env ID. The project ID is from `sigillo setup` or `sigillo projects`.
223
+
224
+ To set placeholders across all environments at once:
225
+
226
+ ```bash
227
+ sigillo secrets set DATABASE_URL "" -c dev -c preview -c prod
228
+ ```
229
+
230
+ For secrets that need real random values immediately (auth secrets, encryption keys), generate them instead of leaving empty:
231
+
232
+ ```bash
233
+ sigillo secrets set AUTH_SECRET "$(openssl rand -base64 32)" -c dev -c preview -c prod
234
+ ```
235
+
202
236
  ## Bootstrapping a project for a new codebase
203
237
 
204
238
  When a codebase needs Sigillo for the first time, the agent creates the org, project, and placeholder secrets. The user fills in real values later via the web UI.
@@ -1,4 +1,8 @@
1
1
  // Terminal send command for creating Discord threads and scheduling prompts.
2
+ // Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
3
+ // The local SQLite database (channel_directories) is NOT required for the basic
4
+ // flow: post message → create thread → remote bot picks it up. The local project
5
+ // directory mapping is only needed for --send-at, --wait, and --cwd.
2
6
  import { goke } from 'goke'
3
7
  import { z } from 'zod'
4
8
  import { note } from '@clack/prompts'
@@ -395,10 +399,20 @@ cli
395
399
  throw new Error(`Thread has no parent channel: ${targetThreadId}`)
396
400
  }
397
401
 
402
+ // channelConfig is optional: in CI/headless environments the local DB
403
+ // has no channel_directories rows because the bot hasn't synced yet.
404
+ // The running bot on the other end resolves the directory from its own DB.
405
+ // We only require it for features that genuinely need a local directory
406
+ // (scheduled tasks and --wait).
398
407
  const channelConfig = await getChannelDirectory(threadData.parent_id)
399
- if (!channelConfig) {
408
+
409
+ // Guard early: fail before sending the message if a feature that
410
+ // needs local project directory mapping is requested.
411
+ if (!channelConfig && (parsedSchedule || options.wait)) {
412
+ const flag = parsedSchedule ? '--send-at' : '--wait'
400
413
  throw new Error(
401
- 'Thread parent channel is not configured with a project directory',
414
+ 'Thread parent channel is not configured with a project directory. ' +
415
+ `${flag} requires a local project mapping. Run the bot first to sync channel data.`,
402
416
  )
403
417
  }
404
418
 
@@ -425,7 +439,8 @@ cli
425
439
  channelId: threadData.parent_id,
426
440
  threadId: targetThreadId,
427
441
  sessionId: sessionId || undefined,
428
- projectDirectory: channelConfig.directory,
442
+ // channelConfig is guaranteed: early guard threw if missing with --send-at
443
+ projectDirectory: channelConfig!.directory,
429
444
  })
430
445
 
431
446
  const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
@@ -475,10 +490,12 @@ cli
475
490
  process.stdout.write(`${threadUrl}\n`)
476
491
 
477
492
  if (options.wait) {
493
+ // channelConfig is guaranteed here: early guard above already
494
+ // threw if channelConfig is missing when --wait is used.
478
495
  const { waitAndOutputSession } = await import('../wait-session.js')
479
496
  await waitAndOutputSession({
480
497
  threadId: targetThreadId,
481
- projectDirectory: channelConfig.directory,
498
+ projectDirectory: channelConfig!.directory,
482
499
  waitStartedAtMs,
483
500
  })
484
501
  }
@@ -500,22 +517,29 @@ cli
500
517
  guild_id: string
501
518
  }
502
519
 
520
+ // channelConfig is optional: in CI/headless environments the local DB
521
+ // has no channel_directories rows because the bot hasn't synced yet.
522
+ // The running bot on the other end resolves the directory from its own DB.
523
+ // We only require it for features that genuinely need a local directory
524
+ // (--send-at, --wait, --cwd).
503
525
  const channelConfig = await getChannelDirectory(channelData.id)
526
+ const projectDirectory = channelConfig?.directory
504
527
 
505
- if (!channelConfig && !notifyOnly) {
506
- cliLogger.log('Channel not configured')
528
+ // Features that require a local project directory mapping
529
+ const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd)
530
+ if (!channelConfig && needsProjectDirectory) {
507
531
  throw new Error(
508
- `Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`,
532
+ `Channel #${channelData.name} is not configured with a project directory. ` +
533
+ `${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
534
+ 'Run the bot first to sync channel data.',
509
535
  )
510
536
  }
511
537
 
512
- const projectDirectory = channelConfig?.directory
513
-
514
538
  // Validate --cwd is inside the project or an existing git worktree.
515
539
  let resolvedCwd: string | undefined
516
540
  if (options.cwd) {
517
- // projectDirectory is guaranteed here: --cwd is incompatible with --notify-only,
518
- // and non-notify sends already require channelConfig above.
541
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
542
+ // already threw if channelConfig is missing when --cwd is used.
519
543
  const cwdResult = await resolveSessionWorkingDirectory({
520
544
  projectDirectory: projectDirectory!,
521
545
  candidatePath: options.cwd,
@@ -690,8 +714,8 @@ cli
690
714
  process.stdout.write(`${threadUrl}\n`)
691
715
 
692
716
  if (options.wait) {
693
- // projectDirectory is guaranteed here: --wait is incompatible with --notify-only,
694
- // and non-notify sends already require channelConfig above.
717
+ // projectDirectory is guaranteed here: needsProjectDirectory check above
718
+ // already threw if channelConfig is missing when --wait is used.
695
719
  const { waitAndOutputSession } = await import('../wait-session.js')
696
720
  await waitAndOutputSession({
697
721
  threadId: threadData.id,
@@ -188,7 +188,8 @@ cli
188
188
  'Read a session conversation as markdown (pipe to file to grep)',
189
189
  )
190
190
  .option('--project <path>', 'Project directory (defaults to cwd)')
191
- .action(async (sessionId: string, options: { project?: string }) => {
191
+ .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
192
+ .action(async (sessionId: string, options: { project?: string; verbose?: boolean }) => {
192
193
  try {
193
194
  const projectDirectory = path.resolve(options.project || '.')
194
195
 
@@ -202,8 +203,9 @@ cli
202
203
  }
203
204
 
204
205
  // Try current project first (fast path)
206
+ const compactTools = !options.verbose
205
207
  const markdown = new ShareMarkdown(getClient())
206
- const result = await markdown.generate({ sessionID: sessionId })
208
+ const result = await markdown.generate({ sessionID: sessionId, compactTools })
207
209
  if (!(result instanceof Error)) {
208
210
  process.stdout.write(result)
209
211
  process.exit(0)
@@ -236,6 +238,7 @@ cli
236
238
  const otherMarkdown = new ShareMarkdown(otherClient())
237
239
  const otherResult = await otherMarkdown.generate({
238
240
  sessionID: sessionId,
241
+ compactTools,
239
242
  })
240
243
  if (!(otherResult instanceof Error)) {
241
244
  process.stdout.write(otherResult)
@@ -45,6 +45,19 @@ function createMatchers(): DeterministicMatcher[] {
45
45
  },
46
46
  }
47
47
 
48
+ const toolCallMatcher: DeterministicMatcher = {
49
+ id: 'tool-call-reply',
50
+ priority: 90,
51
+ when: { latestUserTextIncludes: 'use a tool please' },
52
+ then: {
53
+ parts: [
54
+ { type: 'stream-start', warnings: [] },
55
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
56
+ { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
57
+ ],
58
+ },
59
+ }
60
+
48
61
  const defaultMatcher: DeterministicMatcher = {
49
62
  id: 'default-reply',
50
63
  priority: 1,
@@ -59,13 +72,14 @@ function createMatchers(): DeterministicMatcher[] {
59
72
  },
60
73
  }
61
74
 
62
- return [helloMatcher, defaultMatcher]
75
+ return [helloMatcher, toolCallMatcher, defaultMatcher]
63
76
  }
64
77
 
65
78
  let client: OpencodeClient
66
79
  let directories: ReturnType<typeof createRunDirectories>
67
80
  let testStartTime: number
68
81
  let sessionID: string
82
+ let toolSessionID: string
69
83
 
70
84
  beforeAll(async () => {
71
85
  testStartTime = Date.now()
@@ -148,7 +162,39 @@ beforeAll(async () => {
148
162
  setTimeout(resolve, 200)
149
163
  })
150
164
  }
151
- }, 20_000)
165
+
166
+ // Create a second session that triggers a tool call (bash echo)
167
+ const toolCreateResult = await client.session.create({
168
+ directory: directories.projectDirectory,
169
+ title: 'Tool Call Session',
170
+ })
171
+ toolSessionID = toolCreateResult.data!.id
172
+
173
+ await client.session.promptAsync({
174
+ sessionID: toolSessionID,
175
+ directory: directories.projectDirectory,
176
+ parts: [{ type: 'text', text: 'use a tool please' }],
177
+ })
178
+
179
+ // Wait for tool execution to complete
180
+ const toolMaxWait = 15_000
181
+ const toolPollStart = Date.now()
182
+ while (Date.now() - toolPollStart < toolMaxWait) {
183
+ const msgs = await client.session.messages({
184
+ sessionID: toolSessionID,
185
+ directory: directories.projectDirectory,
186
+ })
187
+ const messages = msgs.data || []
188
+ const hasToolPart = messages.some((m) =>
189
+ m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'),
190
+ )
191
+ if (hasToolPart) {
192
+ await new Promise((resolve) => { setTimeout(resolve, 500) })
193
+ break
194
+ }
195
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
196
+ }
197
+ }, 30_000)
152
198
 
153
199
  afterAll(async () => {
154
200
  if (directories) {
@@ -313,3 +359,44 @@ test('generate markdown with lastAssistantOnly', async () => {
313
359
  // Should contain the assistant response
314
360
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.')
315
361
  })
362
+
363
+ test('compact tools: tool calls show one-liner with line count', async () => {
364
+ const exporter = new ShareMarkdown(client)
365
+
366
+ const result = await exporter.generate({
367
+ sessionID: toolSessionID,
368
+ compactTools: true,
369
+ })
370
+
371
+ expect(errore.isOk(result)).toBe(true)
372
+ const md = errore.unwrap(result)
373
+
374
+ // Compact mode: exact one-liner format with params and line count
375
+ expect(md).toContain(
376
+ '> 🛠️ **bash** command=echo hello world, description=Print greeting',
377
+ )
378
+ expect(md).toMatch(/\(\d+ lines?\)/)
379
+ // Should NOT contain full output code blocks or input YAML
380
+ expect(md).not.toContain('**Output:**')
381
+ expect(md).not.toContain('**Input:**')
382
+ expect(md).not.toContain('```yaml')
383
+ })
384
+
385
+ test('verbose tools: tool calls show full input and output', async () => {
386
+ const exporter = new ShareMarkdown(client)
387
+
388
+ const result = await exporter.generate({
389
+ sessionID: toolSessionID,
390
+ compactTools: false,
391
+ })
392
+
393
+ expect(errore.isOk(result)).toBe(true)
394
+ const md = errore.unwrap(result)
395
+
396
+ // Verbose mode: full tool rendering with input YAML and output code block
397
+ expect(md).toContain('#### 🛠️ Tool: bash')
398
+ expect(md).toContain('**Input:**')
399
+ expect(md).toContain('```yaml')
400
+ expect(md).toContain('**Output:**')
401
+ expect(md).toContain('hello world')
402
+ })
package/src/markdown.ts CHANGED
@@ -32,8 +32,10 @@ export class ShareMarkdown {
32
32
  sessionID: string
33
33
  includeSystemInfo?: boolean
34
34
  lastAssistantOnly?: boolean
35
+ /** When true (default), tool calls show a compact one-liner with line count instead of full output. */
36
+ compactTools?: boolean
35
37
  }): Promise<SessionNotFoundError | MessagesNotFoundError | string> {
36
- const { sessionID, includeSystemInfo, lastAssistantOnly } = options
38
+ const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options
37
39
 
38
40
  // Get session info
39
41
  const sessionResponse = await this.client.session.get({
@@ -96,7 +98,7 @@ export class ShareMarkdown {
96
98
  }
97
99
 
98
100
  for (const message of messagesToRender) {
99
- const messageLines = this.renderMessage(message!.info, message!.parts)
101
+ const messageLines = this.renderMessage(message!.info, message!.parts, { compactTools })
100
102
  lines.push(...messageLines)
101
103
  lines.push('')
102
104
  }
@@ -104,7 +106,7 @@ export class ShareMarkdown {
104
106
  return lines.join('\n')
105
107
  }
106
108
 
107
- private renderMessage(message: any, parts: any[]): string[] {
109
+ private renderMessage(message: any, parts: any[], opts: { compactTools: boolean }): string[] {
108
110
  const lines: string[] = []
109
111
 
110
112
  if (message.role === 'user') {
@@ -148,7 +150,9 @@ export class ShareMarkdown {
148
150
  })
149
151
 
150
152
  for (const part of filteredParts) {
151
- const partLines = this.renderPart(part, message)
153
+ const partLines = opts.compactTools
154
+ ? this.renderPartCompact(part, message)
155
+ : this.renderPart(part, message)
152
156
  lines.push(...partLines)
153
157
  }
154
158
 
@@ -251,6 +255,61 @@ export class ShareMarkdown {
251
255
  return lines
252
256
  }
253
257
 
258
+ /** Compact rendering: tool calls become a single line with line count instead of full output. */
259
+ private renderPartCompact(part: any, message: any): string[] {
260
+ // Non-tool parts render the same as verbose
261
+ if (part.type !== 'tool') {
262
+ return this.renderPart(part, message)
263
+ }
264
+
265
+ const lines: string[] = []
266
+
267
+ if (part.state.status === 'completed') {
268
+ const output: string = part.state.output || ''
269
+ const lineCount = output ? output.split('\n').length : 0
270
+ const inputSummary = this.compactInputSummary(part.state.input)
271
+ const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : ''
272
+ const parts = [inputSummary, outputLabel].filter(Boolean).join(' ')
273
+ lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`)
274
+ lines.push('')
275
+ } else if (part.state.status === 'error') {
276
+ const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120)
277
+ lines.push(`> ❌ **${part.tool}** — ${errorText}`)
278
+ lines.push('')
279
+ }
280
+
281
+ return lines
282
+ }
283
+
284
+ /** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
285
+ private compactInputSummary(input: Record<string, unknown> | undefined): string {
286
+ if (!input) return ''
287
+ const entries = Object.entries(input)
288
+ if (entries.length === 0) return ''
289
+
290
+ const parts: string[] = []
291
+ const maxParams = 2
292
+ for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
293
+ const [key, value] = entries[i]!
294
+ let val: string
295
+ try {
296
+ val = typeof value === 'string'
297
+ ? value
298
+ : (JSON.stringify(value) ?? String(value))
299
+ } catch {
300
+ val = String(value)
301
+ }
302
+ // Collapse whitespace for readability
303
+ const collapsed = val.replace(/\s+/g, ' ').trim()
304
+ const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed
305
+ parts.push(`${key}=${normalized}`)
306
+ }
307
+ if (entries.length > maxParams) {
308
+ parts.push('...')
309
+ }
310
+ return parts.join(', ')
311
+ }
312
+
254
313
  private formatDuration(ms: number): string {
255
314
  if (ms < 1000) return `${ms}ms`
256
315
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`