kimaki 0.18.0 → 0.20.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.
@@ -56,74 +56,7 @@ cli
56
56
  void client.login(botToken);
57
57
  });
58
58
  cliLogger.log('Finding guild...');
59
- // Find guild
60
- let guild;
61
- if (options.guild) {
62
- const guildId = String(options.guild);
63
- const foundGuild = client.guilds.cache.get(guildId);
64
- if (!foundGuild) {
65
- cliLogger.log('Guild not found');
66
- cliLogger.error(`Guild not found: ${guildId}`);
67
- void client.destroy();
68
- process.exit(EXIT_NO_RESTART);
69
- }
70
- guild = foundGuild;
71
- }
72
- else {
73
- const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
74
- where: { channel_type: 'text' },
75
- orderBy: { created_at: 'desc' },
76
- columns: { channel_id: true },
77
- }).then((row) => row?.channel_id);
78
- if (existingChannelId) {
79
- try {
80
- const ch = await client.channels.fetch(existingChannelId);
81
- if (ch && !ch.isDMBased()) {
82
- guild = ch.guild;
83
- }
84
- else {
85
- throw new Error('Channel has no guild');
86
- }
87
- }
88
- catch (error) {
89
- cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
90
- let firstGuild = client.guilds.cache.first();
91
- if (!firstGuild) {
92
- // Cache might be empty, try fetching guilds from API
93
- const fetched = await client.guilds.fetch();
94
- const firstOAuth2Guild = fetched.first();
95
- if (firstOAuth2Guild) {
96
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
97
- }
98
- }
99
- if (!firstGuild) {
100
- cliLogger.log('No guild found');
101
- cliLogger.error('No guild found. Add the bot to a server first.');
102
- void client.destroy();
103
- process.exit(EXIT_NO_RESTART);
104
- }
105
- guild = firstGuild;
106
- }
107
- }
108
- else {
109
- let firstGuild = client.guilds.cache.first();
110
- if (!firstGuild) {
111
- // Cache might be empty, try fetching guilds from API
112
- const fetched = await client.guilds.fetch();
113
- const firstOAuth2Guild = fetched.first();
114
- if (firstOAuth2Guild) {
115
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
116
- }
117
- }
118
- if (!firstGuild) {
119
- cliLogger.log('No guild found');
120
- cliLogger.error('No guild found. Add the bot to a server first.');
121
- void client.destroy();
122
- process.exit(EXIT_NO_RESTART);
123
- }
124
- guild = firstGuild;
125
- }
126
- }
59
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
127
60
  // Check if channel already exists in this guild
128
61
  cliLogger.log('Checking for existing channel...');
129
62
  try {
@@ -353,25 +286,7 @@ cli
353
286
  client.once(Events.Error, reject);
354
287
  client.login(botToken).catch(reject);
355
288
  });
356
- let guild;
357
- if (options.guild) {
358
- const found = client.guilds.cache.get(options.guild);
359
- if (!found) {
360
- cliLogger.error(`Guild not found: ${options.guild}`);
361
- void client.destroy();
362
- process.exit(EXIT_NO_RESTART);
363
- }
364
- guild = found;
365
- }
366
- else {
367
- const first = client.guilds.cache.first();
368
- if (!first) {
369
- cliLogger.error('No guild found. Add the bot to a server first.');
370
- void client.destroy();
371
- process.exit(EXIT_NO_RESTART);
372
- }
373
- guild = first;
374
- }
289
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
375
290
  const { textChannelId, channelName } = await createProjectChannels({
376
291
  guild,
377
292
  projectDirectory,
@@ -383,4 +298,87 @@ cli
383
298
  cliLogger.log(channelUrl);
384
299
  process.exit(0);
385
300
  });
301
+ // Resolve the guild for project add/create commands. In gateway mode the
302
+ // guild cache only contains authorized guilds, so picking from cache is safe.
303
+ // The old approach fetched an existing channel to infer the guild, but that
304
+ // breaks when the channel belongs to a different guild (e.g. old self-hosted
305
+ // bot channels) and the gateway proxy rejects the REST call. This led to a
306
+ // non-deterministic fallback that picked the wrong guild.
307
+ async function resolveGuildForProjectCommand({ client, guildIdOverride }) {
308
+ if (guildIdOverride) {
309
+ const found = client.guilds.cache.get(guildIdOverride);
310
+ if (!found) {
311
+ cliLogger.error(`Guild not found: ${guildIdOverride}`);
312
+ void client.destroy();
313
+ process.exit(EXIT_NO_RESTART);
314
+ }
315
+ return found;
316
+ }
317
+ // Try existing channel lookup to find the guild the user already has channels in.
318
+ // This handles multi-guild setups where we want to add to the same guild.
319
+ const db = await getDb();
320
+ const existingChannels = await db.query.channel_directories.findMany({
321
+ where: { channel_type: 'text' },
322
+ orderBy: { created_at: 'desc' },
323
+ columns: { channel_id: true },
324
+ limit: 20,
325
+ });
326
+ // Log available guilds for debugging guild selection issues
327
+ const cachedGuilds = Array.from(client.guilds.cache.values());
328
+ cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`);
329
+ // When multiple guilds are available, find which guild has the most
330
+ // existing channels. The user's main guild will have far more channels
331
+ // than a test/demo guild.
332
+ const guildHits = new Map();
333
+ for (const row of existingChannels) {
334
+ try {
335
+ const ch = await client.channels.fetch(row.channel_id);
336
+ if (ch && !ch.isDMBased()) {
337
+ const entry = guildHits.get(ch.guild.id);
338
+ if (entry) {
339
+ entry.count++;
340
+ }
341
+ else {
342
+ guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 });
343
+ }
344
+ }
345
+ }
346
+ catch {
347
+ // Channel might be in a different guild (gateway proxy rejects) or deleted, skip
348
+ }
349
+ }
350
+ if (guildHits.size > 0) {
351
+ // Pick the guild with the most channels
352
+ const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0];
353
+ cliLogger.debug(`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`);
354
+ cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`);
355
+ return best.guild;
356
+ }
357
+ cliLogger.debug('Could not resolve guild from existing channels, falling back to cache');
358
+ // If only one guild in cache, use it directly (common case).
359
+ // If multiple guilds, error out and ask the user to specify --guild
360
+ // since we can't determine which one to use.
361
+ if (cachedGuilds.length === 1) {
362
+ return cachedGuilds[0];
363
+ }
364
+ if (cachedGuilds.length > 1) {
365
+ cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
366
+ void client.destroy();
367
+ process.exit(EXIT_NO_RESTART);
368
+ }
369
+ // Cache empty, try fetching
370
+ const fetched = await client.guilds.fetch();
371
+ if (fetched.size === 1) {
372
+ const firstOAuth2Guild = fetched.first();
373
+ return await client.guilds.fetch(firstOAuth2Guild.id);
374
+ }
375
+ if (fetched.size > 1) {
376
+ cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
377
+ void client.destroy();
378
+ process.exit(EXIT_NO_RESTART);
379
+ }
380
+ cliLogger.error('No guild found. Add the bot to a server first.');
381
+ void client.destroy();
382
+ process.exit(EXIT_NO_RESTART);
383
+ }
386
384
  export default cli;
@@ -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);
@@ -4,12 +4,16 @@
4
4
  // the prompt is sent with that agent and the session keeps that agent afterwards.
5
5
  import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
6
  import crypto from 'node:crypto';
7
- import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
7
+ import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
8
8
  import { initializeOpencodeForDirectory } from '../opencode.js';
9
9
  import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
10
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
11
11
  import { createLogger, LogPrefix } from '../logger.js';
12
12
  import { getCurrentModelInfo } from './model.js';
13
+ import { isGitRepositoryRoot } from '../worktrees.js';
14
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
15
+ import { WORKTREE_PREFIX } from './merge-worktree.js';
16
+ import { store } from '../store.js';
13
17
  const agentLogger = createLogger(LogPrefix.AGENT);
14
18
  const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
15
19
  const pendingAgentContexts = new Map();
@@ -429,22 +433,65 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
429
433
  return;
430
434
  }
431
435
  await command.deferReply();
436
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
437
+ // mirroring the logic in discord-bot.ts message handler.
438
+ const wantsWorktrees = store.getState().useWorktrees ||
439
+ (await getChannelWorktreesEnabled(channel.id));
440
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
441
+ if (wantsWorktrees && !shouldUseWorktrees) {
442
+ agentLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
443
+ }
444
+ const baseThreadName = prompt.slice(0, 80);
445
+ const threadName = shouldUseWorktrees
446
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
447
+ : baseThreadName;
432
448
  const starterMessage = await channel.send({
433
449
  content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
434
450
  flags: SILENT_MESSAGE_FLAGS,
435
451
  });
436
452
  const thread = await starterMessage.startThread({
437
- name: prompt.slice(0, 80),
453
+ name: threadName.slice(0, 80),
438
454
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
439
455
  reason: `${resolvedAgentName} agent prompt`,
440
456
  });
441
457
  await thread.members.add(command.user.id);
442
- await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
458
+ // Create worktree in background if enabled, same as discord-bot.ts
459
+ let worktreePromise;
460
+ if (shouldUseWorktrees) {
461
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
462
+ agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
463
+ const worktreeStatusMessage = await thread
464
+ .send({
465
+ content: worktreeCreatingMessage(worktreeName),
466
+ flags: SILENT_MESSAGE_FLAGS,
467
+ })
468
+ .catch(() => undefined);
469
+ worktreePromise = createWorktreeInBackground({
470
+ thread,
471
+ starterMessage: worktreeStatusMessage,
472
+ worktreeName,
473
+ projectDirectory,
474
+ rest: command.client.rest,
475
+ });
476
+ }
477
+ const sessionDirectory = await (async () => {
478
+ if (!worktreePromise)
479
+ return projectDirectory;
480
+ const result = await worktreePromise;
481
+ if (result instanceof Error)
482
+ return projectDirectory;
483
+ return result;
484
+ })();
485
+ await command
486
+ .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
487
+ .catch(() => {
488
+ agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session');
489
+ });
443
490
  const runtime = getOrCreateRuntime({
444
491
  threadId: thread.id,
445
492
  thread,
446
493
  projectDirectory,
447
- sdkDirectory: projectDirectory,
494
+ sdkDirectory: sessionDirectory,
448
495
  channelId: channel.id,
449
496
  appId,
450
497
  });
@@ -173,6 +173,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
173
173
  if (!discordClient) {
174
174
  discordClient = await createDiscordClient();
175
175
  }
176
+ store.setState({ useWorktrees: Boolean(useWorktrees) });
176
177
  let currentAppId = appId;
177
178
  const setupHandlers = async (c) => {
178
179
  discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
@@ -332,15 +333,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
332
333
  return;
333
334
  }
334
335
  }
335
- // Ignore messages that start with a mention of another user (not the bot).
336
- // These are likely users talking to each other, not the bot.
336
+ // Detect messages that start with a mention of another user (not the bot).
337
+ // In channels these are fully ignored. In threads they are added to the
338
+ // session context without triggering the AI (noReply), so the agent sees
339
+ // user-to-user conversation on the next real turn.
337
340
  const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
338
- if (leadingMentionMatch) {
339
- const mentionedUserId = leadingMentionMatch[1];
340
- if (mentionedUserId !== discordClient.user?.id) {
341
- return;
342
- }
343
- }
341
+ const isLeadingMentionToOtherUser = leadingMentionMatch &&
342
+ leadingMentionMatch[1] !== discordClient.user?.id;
344
343
  if (message.partial) {
345
344
  discordLogger.log(`Fetching partial message ${message.id}`);
346
345
  const fetched = await message.fetch()
@@ -354,6 +353,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
354
353
  // When mention mode is enabled, users without Kimaki role can message
355
354
  // without getting a permission error - we just silently ignore.
356
355
  const channel = message.channel;
356
+ // In text channels, messages starting with a mention to another user
357
+ // are fully ignored before any permission or mention-mode checks.
358
+ // This prevents permission-error replies for user-to-user conversation.
359
+ if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
360
+ return;
361
+ }
357
362
  if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
358
363
  const mentionModeEnabled = await getChannelMentionMode(channel.id);
359
364
  if (mentionModeEnabled) {
@@ -435,6 +440,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
435
440
  discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
436
441
  return;
437
442
  }
443
+ // Context-only messages (user-to-user replies) can't be stored without
444
+ // an existing session. Skip early to avoid creating a runtime or running
445
+ // preprocessing for nothing.
446
+ if (isLeadingMentionToOtherUser && !hasExistingSession) {
447
+ return;
448
+ }
438
449
  const parent = thread.parent;
439
450
  let projectDirectory;
440
451
  if (parent) {
@@ -509,7 +520,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
509
520
  const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
510
521
  ? extractBtwSuffix(message.content || '')
511
522
  : null;
512
- if (btwResult?.forceBtw && projectDirectory) {
523
+ if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
513
524
  const result = await forkSessionToBtwThread({
514
525
  sourceThread: thread,
515
526
  projectDirectory,
@@ -559,7 +570,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
559
570
  appId: currentAppId,
560
571
  });
561
572
  // Cancel interactive UI when a real user sends a message.
562
- if (!message.author.bot && !isCliInjectedPrompt) {
573
+ // Context-only messages (user-to-user replies) should not interrupt
574
+ // the active run or dismiss pending UI.
575
+ if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
563
576
  cancelPendingActionButtons(thread.id);
564
577
  cancelHtmlActionsForThread(thread.id);
565
578
  const dismissedPermission = await cancelPendingPermission(thread.id);
@@ -594,6 +607,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
594
607
  model: cliInjectedModel,
595
608
  permissions: cliInjectedPermissions,
596
609
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
610
+ noReply: isLeadingMentionToOtherUser || undefined,
597
611
  sessionStartSource: sessionStartSource
598
612
  ? {
599
613
  scheduleKind: sessionStartSource.scheduleKind,
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`;