kimaki 0.4.58 → 0.4.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { intro, outro, text, password, note, cancel, isCancel, confirm, log, mul
7
7
  import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
8
  import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
9
  import { getBotToken, setBotToken, setChannelDirectory, findChannelsByDirectory, findChannelByAppId, getThreadSession, getThreadIdBySessionId, getPrisma, } from './database.js';
10
+ import { ShareMarkdown } from './markdown.js';
10
11
  import { formatWorktreeName } from './commands/worktree.js';
11
12
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
12
13
  import yaml from 'js-yaml';
@@ -235,11 +236,11 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
235
236
  .toJSON(),
236
237
  new SlashCommandBuilder()
237
238
  .setName('add-project')
238
- .setDescription('Create Discord channels for a project. Use `npx kimaki add-project` for unlisted projects')
239
+ .setDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects')
239
240
  .addStringOption((option) => {
240
241
  option
241
242
  .setName('project')
242
- .setDescription('Select a project. Use `npx kimaki add-project` if not listed')
243
+ .setDescription('Recent OpenCode projects. Use `npx kimaki project add` if not listed')
243
244
  .setRequired(true)
244
245
  .setAutocomplete(true);
245
246
  return option;
@@ -1289,7 +1290,7 @@ cli
1289
1290
  }
1290
1291
  });
1291
1292
  cli
1292
- .command('project add [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
1293
+ .command('project add [directory]', 'Create Discord channels for a project directory (replaces legacy add-project)')
1293
1294
  .alias('add-project')
1294
1295
  .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1295
1296
  .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
@@ -1616,5 +1617,87 @@ cli
1616
1617
  process.exit(EXIT_NO_RESTART);
1617
1618
  }
1618
1619
  });
1620
+ cli
1621
+ .command('session list', 'List all OpenCode sessions, marking which were started via Kimaki')
1622
+ .option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
1623
+ .option('--json', 'Output as JSON')
1624
+ .action(async (options) => {
1625
+ try {
1626
+ const projectDirectory = path.resolve(options.project || '.');
1627
+ await initDatabase();
1628
+ cliLogger.log('Connecting to OpenCode server...');
1629
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1630
+ if (getClient instanceof Error) {
1631
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
1632
+ process.exit(EXIT_NO_RESTART);
1633
+ }
1634
+ const sessionsResponse = await getClient().session.list();
1635
+ const sessions = sessionsResponse.data || [];
1636
+ if (sessions.length === 0) {
1637
+ cliLogger.log('No sessions found');
1638
+ process.exit(0);
1639
+ }
1640
+ // Look up which sessions were started via kimaki (have a thread mapping)
1641
+ const prisma = await getPrisma();
1642
+ const threadSessions = await prisma.thread_sessions.findMany({
1643
+ select: { thread_id: true, session_id: true },
1644
+ });
1645
+ const sessionToThread = new Map(threadSessions
1646
+ .filter((row) => row.session_id !== '')
1647
+ .map((row) => [row.session_id, row.thread_id]));
1648
+ if (options.json) {
1649
+ const output = sessions.map((session) => ({
1650
+ id: session.id,
1651
+ title: session.title || 'Untitled Session',
1652
+ directory: session.directory,
1653
+ updated: new Date(session.time.updated).toISOString(),
1654
+ source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
1655
+ threadId: sessionToThread.get(session.id) || null,
1656
+ }));
1657
+ console.log(JSON.stringify(output, null, 2));
1658
+ process.exit(0);
1659
+ }
1660
+ for (const session of sessions) {
1661
+ const threadId = sessionToThread.get(session.id);
1662
+ const source = threadId ? '(kimaki)' : '(opencode)';
1663
+ const updatedAt = new Date(session.time.updated).toISOString();
1664
+ const threadInfo = threadId ? ` | thread: ${threadId}` : '';
1665
+ console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}`);
1666
+ }
1667
+ process.exit(0);
1668
+ }
1669
+ catch (error) {
1670
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1671
+ process.exit(EXIT_NO_RESTART);
1672
+ }
1673
+ });
1674
+ cli
1675
+ .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
1676
+ .option('--project <path>', 'Project directory (defaults to cwd)')
1677
+ .action(async (sessionId, options) => {
1678
+ try {
1679
+ const projectDirectory = path.resolve(options.project || '.');
1680
+ await initDatabase();
1681
+ cliLogger.log('Connecting to OpenCode server...');
1682
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1683
+ if (getClient instanceof Error) {
1684
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
1685
+ process.exit(EXIT_NO_RESTART);
1686
+ }
1687
+ const markdown = new ShareMarkdown(getClient());
1688
+ const result = await markdown.generate({ sessionID: sessionId });
1689
+ if (result instanceof Error) {
1690
+ cliLogger.error(result.message);
1691
+ process.exit(EXIT_NO_RESTART);
1692
+ }
1693
+ // Print to stdout so it can be piped: kimaki session read <id> > ./tmp/session.md
1694
+ process.stdout.write(result);
1695
+ process.exit(0);
1696
+ }
1697
+ catch (error) {
1698
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1699
+ process.exit(EXIT_NO_RESTART);
1700
+ }
1701
+ });
1619
1702
  cli.help();
1620
1703
  cli.parse();
@@ -1,10 +1,10 @@
1
1
  // Discord-specific utility functions.
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
- import { ChannelType } from 'discord.js';
4
+ import { ChannelType, MessageFlags } from 'discord.js';
5
5
  import { REST, Routes } from 'discord.js';
6
6
  import { Lexer } from 'marked';
7
- import { formatMarkdownTables } from './format-tables.js';
7
+ import { splitTablesFromMarkdown } from './format-tables.js';
8
8
  import { getChannelDirectory } from './database.js';
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js';
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
@@ -264,27 +264,49 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
264
264
  }
265
265
  export async function sendThreadMessage(thread, content, options) {
266
266
  const MAX_LENGTH = 2000;
267
- content = formatMarkdownTables(content);
268
- content = unnestCodeBlocksFromLists(content);
269
- content = limitHeadingDepth(content);
270
- content = escapeBackticksInCodeBlocks(content);
271
- // If custom flags provided, send as single message (no chunking)
272
- if (options?.flags !== undefined) {
273
- return thread.send({ content, flags: options.flags });
274
- }
275
- const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
276
- if (chunks.length > 1) {
277
- discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
278
- }
267
+ // Split content into text and CV2 component segments (tables → Container components)
268
+ const segments = splitTablesFromMarkdown(content);
269
+ const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS;
279
270
  let firstMessage;
280
- for (let i = 0; i < chunks.length; i++) {
281
- const chunk = chunks[i];
282
- if (!chunk) {
271
+ for (const segment of segments) {
272
+ if (segment.type === 'components') {
273
+ const message = await thread.send({
274
+ components: segment.components,
275
+ flags: MessageFlags.IsComponentsV2 | baseFlags,
276
+ });
277
+ if (!firstMessage) {
278
+ firstMessage = message;
279
+ }
283
280
  continue;
284
281
  }
285
- const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
286
- if (i === 0) {
287
- firstMessage = message;
282
+ // Apply text transformations to text segments
283
+ let text = segment.text;
284
+ text = unnestCodeBlocksFromLists(text);
285
+ text = limitHeadingDepth(text);
286
+ text = escapeBackticksInCodeBlocks(text);
287
+ if (!text.trim()) {
288
+ continue;
289
+ }
290
+ // If custom flags provided, send as single message (no chunking)
291
+ if (options?.flags !== undefined) {
292
+ const message = await thread.send({ content: text, flags: options.flags });
293
+ if (!firstMessage) {
294
+ firstMessage = message;
295
+ }
296
+ continue;
297
+ }
298
+ const chunks = splitMarkdownForDiscord({ content: text, maxLength: MAX_LENGTH });
299
+ if (chunks.length > 1) {
300
+ discordLogger.log(`MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`);
301
+ }
302
+ for (const chunk of chunks) {
303
+ if (!chunk) {
304
+ continue;
305
+ }
306
+ const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
307
+ if (!firstMessage) {
308
+ firstMessage = message;
309
+ }
288
310
  }
289
311
  }
290
312
  return firstMessage;
@@ -1,22 +1,50 @@
1
- // Markdown table to code block converter.
2
- // Discord doesn't render GFM tables, so this converts them to
3
- // space-aligned code blocks for proper monospace display.
1
+ // Markdown table formatter for Discord.
2
+ // Converts GFM tables to Discord Components V2 (ContainerBuilder with TextDisplay
3
+ // key-value pairs and Separators between row groups). Large tables are split
4
+ // across multiple Container components to stay within the 40-component limit.
4
5
  import { Lexer } from 'marked';
5
- export function formatMarkdownTables(markdown) {
6
+ import { SeparatorSpacingSize, } from 'discord.js';
7
+ // Max 40 components per message (nested components count toward the limit).
8
+ // Each container uses: 1 (container) + M (TextDisplays) + M-1 (separators) = 2M children.
9
+ // So max rows per container = floor((40 - 1) / 2) = 19.
10
+ const MAX_COMPONENTS = 40;
11
+ const MAX_ROWS_PER_CONTAINER = Math.floor((MAX_COMPONENTS - 1) / 2);
12
+ /**
13
+ * Split markdown into text and table component segments.
14
+ * Tables are rendered as CV2 Container components with bold key-value TextDisplay
15
+ * pairs. Large tables are split across multiple component segments.
16
+ */
17
+ export function splitTablesFromMarkdown(markdown) {
6
18
  const lexer = new Lexer();
7
19
  const tokens = lexer.lex(markdown);
8
- let result = '';
20
+ const segments = [];
21
+ let textBuffer = '';
9
22
  for (const token of tokens) {
10
23
  if (token.type === 'table') {
11
- result += formatTableToken(token);
24
+ if (textBuffer.trim()) {
25
+ segments.push({ type: 'text', text: textBuffer });
26
+ textBuffer = '';
27
+ }
28
+ const componentSegments = buildTableComponents(token);
29
+ segments.push(...componentSegments);
12
30
  }
13
31
  else {
14
- result += token.raw;
32
+ textBuffer += token.raw;
15
33
  }
16
34
  }
17
- return result;
35
+ if (textBuffer.trim()) {
36
+ segments.push({ type: 'text', text: textBuffer });
37
+ }
38
+ return segments;
18
39
  }
19
- function formatTableToken(table) {
40
+ /**
41
+ * Build CV2 components for a table. Each data row becomes a single TextDisplay
42
+ * with all key-value pairs joined by newlines (header bold as key). Separator
43
+ * dividers are placed between row groups.
44
+ * Large tables are split into multiple component segments, each containing a
45
+ * Container with up to MAX_ROWS_PER_CONTAINER rows.
46
+ */
47
+ export function buildTableComponents(table) {
20
48
  const headers = table.header.map((cell) => {
21
49
  return extractCellText(cell.tokens);
22
50
  });
@@ -25,14 +53,30 @@ function formatTableToken(table) {
25
53
  return extractCellText(cell.tokens);
26
54
  });
27
55
  });
28
- const columnWidths = calculateColumnWidths(headers, rows);
29
- const lines = [];
30
- lines.push(formatRow(headers, columnWidths));
31
- lines.push(formatSeparator(columnWidths));
32
- for (const row of rows) {
33
- lines.push(formatRow(row, columnWidths));
56
+ // Split rows into chunks that fit within the component limit
57
+ const chunks = [];
58
+ for (let i = 0; i < rows.length; i += MAX_ROWS_PER_CONTAINER) {
59
+ chunks.push(rows.slice(i, i + MAX_ROWS_PER_CONTAINER));
34
60
  }
35
- return '```\n' + lines.join('\n') + '\n```\n';
61
+ return chunks.map((chunkRows) => {
62
+ const children = [];
63
+ for (let i = 0; i < chunkRows.length; i++) {
64
+ if (i > 0) {
65
+ children.push({ type: 14, divider: true, spacing: SeparatorSpacingSize.Small });
66
+ }
67
+ const row = chunkRows[i];
68
+ const lines = headers.map((key, j) => {
69
+ const value = row[j] || '';
70
+ return `**${key}** ${value}`;
71
+ });
72
+ children.push({ type: 10, content: lines.join('\n') });
73
+ }
74
+ const container = {
75
+ type: 17,
76
+ components: children,
77
+ };
78
+ return { type: 'components', components: [container] };
79
+ });
36
80
  }
37
81
  function extractCellText(tokens) {
38
82
  const parts = [];
@@ -69,28 +113,3 @@ function extractTokenText(token) {
69
113
  }
70
114
  }
71
115
  }
72
- function calculateColumnWidths(headers, rows) {
73
- const widths = headers.map((h) => {
74
- return h.length;
75
- });
76
- for (const row of rows) {
77
- for (let i = 0; i < row.length; i++) {
78
- const cell = row[i] ?? '';
79
- widths[i] = Math.max(widths[i] ?? 0, cell.length);
80
- }
81
- }
82
- return widths;
83
- }
84
- function formatRow(cells, widths) {
85
- const paddedCells = cells.map((cell, i) => {
86
- return cell.padEnd(widths[i] ?? 0);
87
- });
88
- return paddedCells.join(' ');
89
- }
90
- function formatSeparator(widths) {
91
- return widths
92
- .map((w) => {
93
- return '-'.repeat(w);
94
- })
95
- .join(' ');
96
- }