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 +86 -3
- package/dist/discord-utils.js +42 -20
- package/dist/format-tables.js +60 -41
- package/dist/format-tables.test.js +173 -392
- package/dist/opencode-plugin.js +9 -83
- package/dist/system-message.js +19 -0
- package/package.json +2 -2
- package/src/cli.ts +103 -3
- package/src/discord-utils.ts +47 -21
- package/src/format-tables.test.ts +186 -412
- package/src/format-tables.ts +78 -45
- package/src/opencode-plugin.ts +9 -95
- package/src/system-message.ts +19 -0
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
|
|
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('
|
|
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 (
|
|
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();
|
package/dist/discord-utils.js
CHANGED
|
@@ -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 {
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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;
|
package/dist/format-tables.js
CHANGED
|
@@ -1,22 +1,50 @@
|
|
|
1
|
-
// Markdown table
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
const segments = [];
|
|
21
|
+
let textBuffer = '';
|
|
9
22
|
for (const token of tokens) {
|
|
10
23
|
if (token.type === 'table') {
|
|
11
|
-
|
|
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
|
-
|
|
32
|
+
textBuffer += token.raw;
|
|
15
33
|
}
|
|
16
34
|
}
|
|
17
|
-
|
|
35
|
+
if (textBuffer.trim()) {
|
|
36
|
+
segments.push({ type: 'text', text: textBuffer });
|
|
37
|
+
}
|
|
38
|
+
return segments;
|
|
18
39
|
}
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
}
|