kimaki 0.28.0 → 0.29.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.
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +84 -33
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli.js +2 -0
- package/dist/commands/queue.js +79 -31
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +76 -0
- package/dist/db.js +34 -0
- package/dist/db.test.js +96 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +9 -3
- package/dist/discord-command-registration.js +1 -1
- package/dist/guild-categories.test.js +225 -0
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/message-formatting.js +9 -16
- package/dist/message-formatting.test.js +91 -2
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +53 -5
- package/dist/session-handler/event-stream-state.test.js +223 -0
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +314 -73
- package/dist/system-message.js +55 -13
- package/dist/system-message.test.js +50 -13
- package/dist/task-id-plugin.js +16 -0
- package/package.json +5 -5
- package/skills/strada/SKILL.md +61 -232
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +123 -34
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli.ts +2 -0
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +106 -40
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +114 -0
- package/src/db.test.ts +112 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +11 -2
- package/src/discord-command-registration.ts +3 -1
- package/src/guild-categories.test.ts +260 -0
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/message-formatting.test.ts +105 -1
- package/src/message-formatting.ts +7 -12
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +226 -0
- package/src/session-handler/event-stream-state.ts +57 -5
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +380 -81
- package/src/system-message.test.ts +50 -13
- package/src/system-message.ts +58 -12
- package/src/task-id-plugin.ts +17 -0
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
// Assign jsonSchema only. Leave parameters as the Effect Schema used to decode
|
|
4
4
|
// args before execute. Extra fields stay in the model-facing schema and in
|
|
5
5
|
// Discord input; they never reach the shell.
|
|
6
|
+
//
|
|
7
|
+
// Also injects KIMAKI_SESSION_ID into bash env. /btw and /fork clone parent
|
|
8
|
+
// history, so the copied system prompt still says --session <parent>. Upload
|
|
9
|
+
// then prefers this live env over that stale flag.
|
|
6
10
|
import { z } from 'zod';
|
|
11
|
+
export const KIMAKI_SESSION_ID_ENV = 'KIMAKI_SESSION_ID';
|
|
12
|
+
export function injectKimakiSessionEnv({ sessionID, env, }) {
|
|
13
|
+
if (!sessionID)
|
|
14
|
+
return;
|
|
15
|
+
env[KIMAKI_SESSION_ID_ENV] = sessionID;
|
|
16
|
+
}
|
|
17
|
+
export function resolveUploadToDiscordSessionId({ flagSessionId, envSessionId = process.env[KIMAKI_SESSION_ID_ENV], } = {}) {
|
|
18
|
+
if (envSessionId)
|
|
19
|
+
return envSessionId;
|
|
20
|
+
return flagSessionId;
|
|
21
|
+
}
|
|
7
22
|
export const bashParameters = z.object({
|
|
8
23
|
command: z.string().describe('The command to execute'),
|
|
9
24
|
description: z
|
|
@@ -28,5 +43,8 @@ export const bashToolSchemaPlugin = async () => {
|
|
|
28
43
|
return;
|
|
29
44
|
extendBashToolDefinition(output);
|
|
30
45
|
},
|
|
46
|
+
'shell.env': async (input, output) => {
|
|
47
|
+
injectKimakiSessionEnv({ sessionID: input.sessionID, env: output.env });
|
|
48
|
+
},
|
|
31
49
|
};
|
|
32
50
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { extendBashToolDefinition, } from './bash-tool-schema-plugin.js';
|
|
2
|
+
import { extendBashToolDefinition, injectKimakiSessionEnv, KIMAKI_SESSION_ID_ENV, resolveUploadToDiscordSessionId, } from './bash-tool-schema-plugin.js';
|
|
3
3
|
describe('extendBashToolDefinition', () => {
|
|
4
4
|
test('keeps parameters and assigns jsonSchema from bashParameters', () => {
|
|
5
5
|
const parameters = { keep: true };
|
|
@@ -47,3 +47,22 @@ describe('extendBashToolDefinition', () => {
|
|
|
47
47
|
`);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
describe('upload-to-discord session targeting', () => {
|
|
51
|
+
test('bash env uses the live OpenCode session, not a copied --session flag', () => {
|
|
52
|
+
const env = {};
|
|
53
|
+
injectKimakiSessionEnv({ sessionID: 'ses_child', env });
|
|
54
|
+
expect(env).toEqual({ [KIMAKI_SESSION_ID_ENV]: 'ses_child' });
|
|
55
|
+
expect(resolveUploadToDiscordSessionId({
|
|
56
|
+
flagSessionId: 'ses_parent',
|
|
57
|
+
envSessionId: env[KIMAKI_SESSION_ID_ENV],
|
|
58
|
+
})).toBe('ses_child');
|
|
59
|
+
});
|
|
60
|
+
test('falls back to --session when bash is not inside an OpenCode session', () => {
|
|
61
|
+
expect(resolveUploadToDiscordSessionId({
|
|
62
|
+
flagSessionId: 'ses_parent',
|
|
63
|
+
})).toBe('ses_parent');
|
|
64
|
+
});
|
|
65
|
+
test('returns undefined when neither live session nor flag is set', () => {
|
|
66
|
+
expect(resolveUploadToDiscordSessionId({})).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ChannelType, } from 'discord.js';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, } from './database.js';
|
|
7
|
+
import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, getGuildCategories, setGuildCategoryId, } from './database.js';
|
|
8
8
|
import { getProjectsDir } from './config.js';
|
|
9
9
|
import { execAsync } from './worktrees.js';
|
|
10
10
|
import { createLogger, LogPrefix } from './logger.js';
|
|
@@ -39,41 +39,117 @@ async function trackProjectRegistered({ projectKind, source, }) {
|
|
|
39
39
|
trackEvent('project_registered', props);
|
|
40
40
|
}
|
|
41
41
|
const logger = createLogger(LogPrefix.CHANNEL);
|
|
42
|
-
|
|
43
|
-
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
42
|
+
function defaultCategoryName(kind, botName) {
|
|
44
43
|
const isKimakiBot = botName?.toLowerCase() === 'kimaki';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
if (kind === 'audio') {
|
|
45
|
+
return botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
|
|
46
|
+
}
|
|
47
|
+
return botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
|
|
48
|
+
}
|
|
49
|
+
function defaultKimakiChannelName({ botName, isGatewayMode, }) {
|
|
50
|
+
if (isGatewayMode || !botName)
|
|
51
|
+
return 'kimaki';
|
|
52
|
+
const sanitized = botName
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
55
|
+
.replace(/-+/g, '-')
|
|
56
|
+
.replace(/^-|-$/g, '');
|
|
57
|
+
if (!sanitized || sanitized === 'kimaki')
|
|
58
|
+
return 'kimaki';
|
|
59
|
+
return `kimaki-${sanitized}`.slice(0, 100);
|
|
60
|
+
}
|
|
61
|
+
const categoryEnsures = new Map();
|
|
62
|
+
function ensureCategorySerialized({ key, run, }) {
|
|
63
|
+
const existing = categoryEnsures.get(key);
|
|
64
|
+
if (existing)
|
|
65
|
+
return existing;
|
|
66
|
+
const promise = run().finally(() => {
|
|
67
|
+
if (categoryEnsures.get(key) === promise) {
|
|
68
|
+
categoryEnsures.delete(key);
|
|
49
69
|
}
|
|
50
|
-
return channel.name.toLowerCase() === categoryName.toLowerCase();
|
|
51
70
|
});
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
categoryEnsures.set(key, promise);
|
|
72
|
+
return promise;
|
|
73
|
+
}
|
|
74
|
+
function isUnknownDiscordChannel(error) {
|
|
75
|
+
const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
|
|
76
|
+
const status = error instanceof Error ? Reflect.get(error, 'status') : undefined;
|
|
77
|
+
return code === 10003 || status === 404;
|
|
78
|
+
}
|
|
79
|
+
function isCategoryChannel(channel) {
|
|
80
|
+
return channel?.type === ChannelType.GuildCategory;
|
|
81
|
+
}
|
|
82
|
+
async function fetchCategoryById(guild, categoryId) {
|
|
83
|
+
const cached = guild.channels.cache.get(categoryId);
|
|
84
|
+
if (isCategoryChannel(cached))
|
|
85
|
+
return cached;
|
|
86
|
+
try {
|
|
87
|
+
const fetched = await guild.channels.fetch(categoryId);
|
|
88
|
+
return isCategoryChannel(fetched) ? fetched : null;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (isUnknownDiscordChannel(error))
|
|
92
|
+
return null;
|
|
93
|
+
throw error;
|
|
54
94
|
}
|
|
55
|
-
return guild.channels.create({
|
|
56
|
-
name: categoryName,
|
|
57
|
-
type: ChannelType.GuildCategory,
|
|
58
|
-
});
|
|
59
95
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
async function adoptParentFromTrackedChannels({ guild, channelType, }) {
|
|
97
|
+
const mappings = await findChannelsByDirectory({ channelType });
|
|
98
|
+
const channels = await guild.channels.fetch();
|
|
99
|
+
for (const row of mappings) {
|
|
100
|
+
if (row.guild_id && row.guild_id !== guild.id)
|
|
101
|
+
continue;
|
|
102
|
+
const channel = channels.get(row.channel_id);
|
|
103
|
+
if (!channel?.parentId)
|
|
104
|
+
continue;
|
|
105
|
+
const parent = await fetchCategoryById(guild, channel.parentId);
|
|
106
|
+
if (parent)
|
|
107
|
+
return parent;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
async function resolveKimakiCategory({ guild, kind, botName, }) {
|
|
112
|
+
const stored = await getGuildCategories(guild.id);
|
|
113
|
+
const storedId = kind === 'audio' ? stored?.audio_category_id : stored?.category_id;
|
|
114
|
+
if (storedId) {
|
|
115
|
+
const existing = await fetchCategoryById(guild, storedId);
|
|
116
|
+
if (existing)
|
|
117
|
+
return existing;
|
|
118
|
+
}
|
|
119
|
+
const adopted = await adoptParentFromTrackedChannels({
|
|
120
|
+
guild,
|
|
121
|
+
channelType: kind === 'audio' ? 'voice' : 'text',
|
|
69
122
|
});
|
|
70
|
-
if (
|
|
71
|
-
|
|
123
|
+
if (adopted) {
|
|
124
|
+
await setGuildCategoryId({
|
|
125
|
+
guildId: guild.id,
|
|
126
|
+
kind,
|
|
127
|
+
categoryId: adopted.id,
|
|
128
|
+
});
|
|
129
|
+
return adopted;
|
|
72
130
|
}
|
|
73
|
-
|
|
74
|
-
name:
|
|
131
|
+
const created = await guild.channels.create({
|
|
132
|
+
name: defaultCategoryName(kind, botName),
|
|
75
133
|
type: ChannelType.GuildCategory,
|
|
76
134
|
});
|
|
135
|
+
await setGuildCategoryId({
|
|
136
|
+
guildId: guild.id,
|
|
137
|
+
kind,
|
|
138
|
+
categoryId: created.id,
|
|
139
|
+
});
|
|
140
|
+
return created;
|
|
141
|
+
}
|
|
142
|
+
export function ensureKimakiCategory(guild, botName) {
|
|
143
|
+
return ensureCategorySerialized({
|
|
144
|
+
key: `${guild.id}:text`,
|
|
145
|
+
run: () => resolveKimakiCategory({ guild, kind: 'text', botName }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export function ensureKimakiAudioCategory(guild, botName) {
|
|
149
|
+
return ensureCategorySerialized({
|
|
150
|
+
key: `${guild.id}:audio`,
|
|
151
|
+
run: () => resolveKimakiCategory({ guild, kind: 'audio', botName }),
|
|
152
|
+
});
|
|
77
153
|
}
|
|
78
154
|
export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, analyticsSource = 'cli', }) {
|
|
79
155
|
const baseName = path.basename(projectDirectory);
|
|
@@ -92,6 +168,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
|
|
|
92
168
|
channelId: textChannel.id,
|
|
93
169
|
directory: projectDirectory,
|
|
94
170
|
channelType: 'text',
|
|
171
|
+
guildId: guild.id,
|
|
95
172
|
});
|
|
96
173
|
await trackProjectRegistered({
|
|
97
174
|
projectKind: 'user',
|
|
@@ -109,6 +186,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
|
|
|
109
186
|
channelId: voiceChannel.id,
|
|
110
187
|
directory: projectDirectory,
|
|
111
188
|
channelType: 'voice',
|
|
189
|
+
guildId: guild.id,
|
|
112
190
|
});
|
|
113
191
|
voiceChannelId = voiceChannel.id;
|
|
114
192
|
}
|
|
@@ -158,8 +236,9 @@ const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not c
|
|
|
158
236
|
* Directory is ~/.kimaki/projects/kimaki, git-initialized with a .gitignore.
|
|
159
237
|
*
|
|
160
238
|
* Idempotency: checks the database for an existing channel mapped to the
|
|
161
|
-
* kimaki projects directory. Also scans
|
|
162
|
-
* as a fallback for channels created before
|
|
239
|
+
* kimaki projects directory. Also scans this machine's category for the
|
|
240
|
+
* exact default channel name as a fallback for channels created before
|
|
241
|
+
* DB mapping existed.
|
|
163
242
|
*/
|
|
164
243
|
export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
|
|
165
244
|
const projectDirectory = getDefaultKimakiDirectory();
|
|
@@ -209,11 +288,9 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
209
288
|
logger.log(`Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`);
|
|
210
289
|
return null;
|
|
211
290
|
}
|
|
212
|
-
// 2. Fallback: detect existing channel
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
// Do NOT adopt it — just skip channel creation entirely to avoid both
|
|
216
|
-
// instances fighting over the same channel.
|
|
291
|
+
// 2. Fallback: detect an existing default channel in THIS machine's group.
|
|
292
|
+
// A #kimaki channel in another machine's group is ignored.
|
|
293
|
+
const channelName = defaultKimakiChannelName({ botName, isGatewayMode });
|
|
217
294
|
const kimakiCategory = await ensureKimakiCategory(guild, botName);
|
|
218
295
|
const existingByName = guild.channels.cache.find((ch) => {
|
|
219
296
|
if (ch.type !== ChannelType.GuildText) {
|
|
@@ -222,10 +299,10 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
222
299
|
if (ch.parentId !== kimakiCategory.id) {
|
|
223
300
|
return false;
|
|
224
301
|
}
|
|
225
|
-
return ch.name ===
|
|
302
|
+
return ch.name === channelName;
|
|
226
303
|
});
|
|
227
304
|
if (existingByName) {
|
|
228
|
-
logger.log(`Found existing default kimaki channel by name: ${existingByName.id}
|
|
305
|
+
logger.log(`Found existing default kimaki channel by name: ${existingByName.id}. Skipping recreation.`);
|
|
229
306
|
return null;
|
|
230
307
|
}
|
|
231
308
|
// Git init — gracefully skip if git is not installed
|
|
@@ -244,21 +321,6 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
244
321
|
if (!fs.existsSync(gitignorePath)) {
|
|
245
322
|
fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
|
|
246
323
|
}
|
|
247
|
-
// Channel name: "kimaki-{botName}" for self-hosted, "kimaki" for gateway
|
|
248
|
-
const channelName = (() => {
|
|
249
|
-
if (isGatewayMode || !botName) {
|
|
250
|
-
return 'kimaki';
|
|
251
|
-
}
|
|
252
|
-
const sanitized = botName
|
|
253
|
-
.toLowerCase()
|
|
254
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
255
|
-
.replace(/-+/g, '-')
|
|
256
|
-
.replace(/^-|-$/g, '');
|
|
257
|
-
if (!sanitized || sanitized === 'kimaki') {
|
|
258
|
-
return 'kimaki';
|
|
259
|
-
}
|
|
260
|
-
return `kimaki-${sanitized}`.slice(0, 100);
|
|
261
|
-
})();
|
|
262
324
|
const textChannel = await guild.channels.create({
|
|
263
325
|
name: channelName,
|
|
264
326
|
type: ChannelType.GuildText,
|
|
@@ -24,6 +24,7 @@ import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
|
|
|
24
24
|
import { upgrade, getCurrentVersion } from '../upgrade.js';
|
|
25
25
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
|
|
26
26
|
import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
|
|
27
|
+
import { resolveUploadToDiscordSessionId } from '../bash-tool-schema-plugin.js';
|
|
27
28
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
28
29
|
const cli = goke();
|
|
29
30
|
cli
|
|
@@ -31,7 +32,9 @@ cli
|
|
|
31
32
|
.option('-s, --session <sessionId>', 'OpenCode session ID')
|
|
32
33
|
.action(async (files, options) => {
|
|
33
34
|
try {
|
|
34
|
-
const
|
|
35
|
+
const sessionId = resolveUploadToDiscordSessionId({
|
|
36
|
+
flagSessionId: options.session,
|
|
37
|
+
});
|
|
35
38
|
if (!sessionId) {
|
|
36
39
|
cliLogger.error('Session ID is required. Use --session <sessionId>');
|
|
37
40
|
process.exit(EXIT_NO_RESTART);
|
|
@@ -12,7 +12,7 @@ import { spawn, execSync } from 'node:child_process';
|
|
|
12
12
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js';
|
|
13
13
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
|
|
14
14
|
import { getDefaultKimakiDirectory } from '../channel-management.js';
|
|
15
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError } from '../database.js';
|
|
15
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError, listGuildCategoryIds } from '../database.js';
|
|
16
16
|
import { ShareMarkdown } from '../markdown.js';
|
|
17
17
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
|
|
18
18
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
|
|
@@ -88,7 +88,7 @@ cli
|
|
|
88
88
|
cli
|
|
89
89
|
.command('project list', 'List all registered projects with their Discord channels')
|
|
90
90
|
.option('--json', 'Output as JSON')
|
|
91
|
-
.option('--all', 'Include remote projects from other machines (scans Kimaki
|
|
91
|
+
.option('--all', 'Include remote projects from other machines (scans Kimaki groups in Discord)')
|
|
92
92
|
.option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
|
|
93
93
|
.option('--prune', 'Remove stale entries whose Discord channel no longer exists')
|
|
94
94
|
.action(async (options) => {
|
|
@@ -158,12 +158,15 @@ cli
|
|
|
158
158
|
process.exit(EXIT_NO_RESTART);
|
|
159
159
|
}
|
|
160
160
|
let guildScanFailures = 0;
|
|
161
|
+
const storedCategoryIds = new Set(await listGuildCategoryIds());
|
|
161
162
|
for (const guildId of uniqueGuildIds) {
|
|
162
163
|
try {
|
|
163
164
|
const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
|
|
164
|
-
//
|
|
165
|
+
// Kimaki groups: name still starts with Kimaki, or this machine stored the id
|
|
166
|
+
// after a rename. Other machines' renamed groups cannot be found by name.
|
|
165
167
|
const kimakiCategoryIds = new Set(guildChannels
|
|
166
|
-
.filter((ch) => ch.type === 4 &&
|
|
168
|
+
.filter((ch) => ch.type === 4 &&
|
|
169
|
+
(/^kimaki(\s|$)/i.test(ch.name) || storedCategoryIds.has(ch.id)))
|
|
167
170
|
.map((ch) => ch.id));
|
|
168
171
|
// Find text channels (type 0) in Kimaki categories that are not in our local DB
|
|
169
172
|
for (const ch of guildChannels) {
|
|
@@ -54,45 +54,82 @@ async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
|
|
|
54
54
|
}
|
|
55
55
|
return new Error(`Session is not linked to a Kimaki thread in the local database: ${sessionId}`);
|
|
56
56
|
}
|
|
57
|
+
// Total token footprint of a session (input + output + reasoning + cache).
|
|
58
|
+
function getSessionTokenTotal(tokens) {
|
|
59
|
+
return (tokens.input +
|
|
60
|
+
tokens.output +
|
|
61
|
+
tokens.reasoning +
|
|
62
|
+
tokens.cache.read +
|
|
63
|
+
tokens.cache.write);
|
|
64
|
+
}
|
|
65
|
+
// Compact token count: 1234 -> "1k", 200000 -> "200k", 1_500_000 -> "1.5M".
|
|
66
|
+
function formatTokenCount(count) {
|
|
67
|
+
if (count >= 1_000_000) {
|
|
68
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
if (count >= 1_000) {
|
|
71
|
+
return `${Math.round(count / 1_000)}k`;
|
|
72
|
+
}
|
|
73
|
+
return String(count);
|
|
74
|
+
}
|
|
57
75
|
cli
|
|
58
76
|
.command('session list', 'List all OpenCode sessions, marking which were started via Kimaki')
|
|
59
77
|
.option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
|
|
78
|
+
.option('--all', 'List sessions across every locally registered project')
|
|
60
79
|
.option('--active', 'Only list active sessions; exits 1 when none remain')
|
|
61
80
|
.option('--exclude <sessionId>', 'Exclude one session ID from the results')
|
|
62
81
|
.option('--json', 'Output as JSON')
|
|
63
82
|
.action(async (options) => {
|
|
64
83
|
try {
|
|
65
|
-
const projectDirectory = path.resolve(options.project || '.');
|
|
66
84
|
await initDatabase();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (getClient instanceof Error) {
|
|
70
|
-
cliLogger.error('Failed to connect to OpenCode:', getClient.message);
|
|
85
|
+
if (options.all && options.project) {
|
|
86
|
+
cliLogger.error('Use either --all or --project, not both');
|
|
71
87
|
process.exit(EXIT_NO_RESTART);
|
|
72
88
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
const projectDirectories = options.all
|
|
90
|
+
? Array.from(new Set((await getAllTextChannelDirectories()).map((directory) => path.resolve(directory))))
|
|
91
|
+
: [path.resolve(options.project || '.')];
|
|
92
|
+
if (projectDirectories.length === 0) {
|
|
93
|
+
cliLogger.error('No registered project directories found. Add a project with `kimaki project add`.');
|
|
94
|
+
process.exit(EXIT_NO_RESTART);
|
|
95
|
+
}
|
|
96
|
+
const gathered = [];
|
|
97
|
+
for (const projectDirectory of projectDirectories) {
|
|
98
|
+
cliLogger.log(`Connecting to OpenCode server for ${projectDirectory}...`);
|
|
99
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
100
|
+
if (getClient instanceof Error) {
|
|
101
|
+
if (options.all) {
|
|
102
|
+
cliLogger.warn(`Skipping ${projectDirectory}: failed to connect to OpenCode: ${getClient.message}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
cliLogger.error('Failed to connect to OpenCode:', getClient.message);
|
|
83
106
|
process.exit(EXIT_NO_RESTART);
|
|
84
107
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
const client = getClient();
|
|
109
|
+
const [sessionsResponse, statusResponse] = await Promise.all([
|
|
110
|
+
client.session.list(),
|
|
111
|
+
client.session.status({ directory: projectDirectory }).catch(() => null),
|
|
112
|
+
]);
|
|
113
|
+
const statuses = statusResponse?.data || {};
|
|
114
|
+
for (const session of sessionsResponse.data || []) {
|
|
115
|
+
const status = statuses[session.id];
|
|
116
|
+
gathered.push({
|
|
117
|
+
session,
|
|
118
|
+
projectDirectory,
|
|
119
|
+
status: status && status.type !== 'idle' ? 'busy' : 'idle',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const selected = gathered
|
|
124
|
+
.filter((entry) => {
|
|
125
|
+
if (entry.session.id === options.exclude)
|
|
89
126
|
return false;
|
|
90
|
-
if (
|
|
91
|
-
return
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
127
|
+
if (options.active && entry.status !== 'busy')
|
|
128
|
+
return false;
|
|
129
|
+
return true;
|
|
130
|
+
})
|
|
131
|
+
.sort((a, b) => b.session.time.updated - a.session.time.updated);
|
|
132
|
+
if (selected.length === 0) {
|
|
96
133
|
if (options.json)
|
|
97
134
|
console.log('[]');
|
|
98
135
|
else
|
|
@@ -107,15 +144,26 @@ cli
|
|
|
107
144
|
const sessionToThread = new Map(threadSessions
|
|
108
145
|
.filter((row) => row.session_id !== '')
|
|
109
146
|
.map((row) => [row.session_id, row.thread_id]));
|
|
110
|
-
const sessionStartSources = await getSessionStartSourcesBySessionIds(
|
|
147
|
+
const sessionStartSources = await getSessionStartSourcesBySessionIds(selected.map((entry) => entry.session.id));
|
|
111
148
|
const scheduleModeLabel = ({ scheduleKind, }) => {
|
|
112
149
|
if (scheduleKind === 'at') {
|
|
113
150
|
return 'delay';
|
|
114
151
|
}
|
|
115
152
|
return 'cron';
|
|
116
153
|
};
|
|
154
|
+
// Token footprint straight from the session object (no message fetch).
|
|
155
|
+
// Session.tokens is a per-session aggregate, so it is reported as a token
|
|
156
|
+
// count rather than a context-window percentage.
|
|
157
|
+
const contextInfo = (entry) => {
|
|
158
|
+
const tokens = entry.session.tokens;
|
|
159
|
+
if (!tokens)
|
|
160
|
+
return null;
|
|
161
|
+
const total = getSessionTokenTotal(tokens);
|
|
162
|
+
return total > 0 ? total : null;
|
|
163
|
+
};
|
|
117
164
|
if (options.json) {
|
|
118
|
-
const output =
|
|
165
|
+
const output = selected.map((entry) => {
|
|
166
|
+
const session = entry.session;
|
|
119
167
|
const startSource = sessionStartSources.get(session.id);
|
|
120
168
|
const startedBy = startSource
|
|
121
169
|
? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
|
|
@@ -127,15 +175,18 @@ cli
|
|
|
127
175
|
updated: new Date(session.time.updated).toISOString(),
|
|
128
176
|
source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
|
|
129
177
|
threadId: sessionToThread.get(session.id) || null,
|
|
178
|
+
status: entry.status,
|
|
179
|
+
model: session.model?.id || null,
|
|
180
|
+
tokens: contextInfo(entry),
|
|
130
181
|
startedBy,
|
|
131
182
|
scheduledTaskId: startSource?.scheduled_task_id || null,
|
|
132
|
-
status: options.active ? statuses?.[session.id]?.type || 'busy' : undefined,
|
|
133
183
|
};
|
|
134
184
|
});
|
|
135
185
|
console.log(JSON.stringify(output, null, 2));
|
|
136
186
|
process.exit(0);
|
|
137
187
|
}
|
|
138
|
-
for (const
|
|
188
|
+
for (const entry of selected) {
|
|
189
|
+
const session = entry.session;
|
|
139
190
|
const threadId = sessionToThread.get(session.id);
|
|
140
191
|
const startSource = sessionStartSources.get(session.id);
|
|
141
192
|
const source = threadId ? '(kimaki)' : '(opencode)';
|
|
@@ -144,10 +195,10 @@ cli
|
|
|
144
195
|
: '';
|
|
145
196
|
const updatedAt = new Date(session.time.updated).toISOString();
|
|
146
197
|
const threadInfo = threadId ? ` | thread: ${threadId}` : '';
|
|
147
|
-
const statusInfo =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${
|
|
198
|
+
const statusInfo = ` | status: ${entry.status === 'busy' ? 'working' : 'idle'}`;
|
|
199
|
+
const tokens = contextInfo(entry);
|
|
200
|
+
const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
|
|
201
|
+
console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
|
|
151
202
|
}
|
|
152
203
|
process.exit(0);
|
|
153
204
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { goke } from 'goke';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
4
|
+
import { initDatabase } from '../discord-bot.js';
|
|
5
|
+
import { createDiscordRest } from '../discord-urls.js';
|
|
6
|
+
import { EXIT_NO_RESTART, resolveBotCredentials } from '../cli-runner.js';
|
|
7
|
+
import { listDiscordChannelThreads } from '../list-channel-threads.js';
|
|
8
|
+
const cliLogger = createLogger(LogPrefix.CLI);
|
|
9
|
+
const cli = goke();
|
|
10
|
+
cli
|
|
11
|
+
.command('thread list', 'List Discord threads in a channel. Use this to find a thread on another computer, then send with --thread.')
|
|
12
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
13
|
+
.option('--json', 'Output as JSON')
|
|
14
|
+
.option('--limit <n>', z.number().default(50).describe('Maximum threads to show'))
|
|
15
|
+
.example('kimaki thread list --channel 123456789012345678')
|
|
16
|
+
.example('kimaki thread list --channel 123456789012345678 --json')
|
|
17
|
+
.action(async (options, { console, process }) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!options.channel) {
|
|
20
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>. Find remote channels with `kimaki project list --all --json`.');
|
|
21
|
+
process.exit(EXIT_NO_RESTART);
|
|
22
|
+
}
|
|
23
|
+
await initDatabase();
|
|
24
|
+
const { token: botToken } = await resolveBotCredentials({});
|
|
25
|
+
const rest = createDiscordRest(botToken);
|
|
26
|
+
const listed = await listDiscordChannelThreads({
|
|
27
|
+
rest,
|
|
28
|
+
channelId: options.channel,
|
|
29
|
+
});
|
|
30
|
+
if (!listed.ok) {
|
|
31
|
+
cliLogger.error(listed.error.message);
|
|
32
|
+
process.exit(EXIT_NO_RESTART);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const limited = listed.threads.slice(0, options.limit);
|
|
36
|
+
if (options.json) {
|
|
37
|
+
console.log(JSON.stringify(limited.map((thread) => ({
|
|
38
|
+
thread_id: thread.id,
|
|
39
|
+
name: thread.name,
|
|
40
|
+
channel_id: thread.parentId,
|
|
41
|
+
guild_id: thread.guildId,
|
|
42
|
+
archived: thread.archived,
|
|
43
|
+
archive_state: thread.archiveState,
|
|
44
|
+
last_message_id: thread.lastMessageId,
|
|
45
|
+
})), null, 2));
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (limited.length === 0) {
|
|
49
|
+
console.log(`No threads found in channel ${options.channel}`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
for (const thread of limited) {
|
|
53
|
+
const archivedTag = thread.archived ? ' [archived]' : '';
|
|
54
|
+
console.log(`${thread.id} | ${thread.name}${archivedTag}`);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
|
|
60
|
+
process.exit(EXIT_NO_RESTART);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
export default cli;
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import projectCommands from './cli-commands/project.js';
|
|
|
20
20
|
import sendCommands from './cli-commands/send.js';
|
|
21
21
|
import sessionCommands from './cli-commands/session.js';
|
|
22
22
|
import taskCommands from './cli-commands/task.js';
|
|
23
|
+
import threadCommands from './cli-commands/thread.js';
|
|
23
24
|
import userCommands from './cli-commands/user.js';
|
|
24
25
|
import { EXIT_NO_RESTART, printDiscordInstallUrlAndExit, run, } from './cli-runner.js';
|
|
25
26
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
@@ -258,6 +259,7 @@ cli.use(miscCommands);
|
|
|
258
259
|
cli.use(sendCommands);
|
|
259
260
|
cli.use(taskCommands);
|
|
260
261
|
cli.use(projectCommands);
|
|
262
|
+
cli.use(threadCommands);
|
|
261
263
|
cli.use(userCommands);
|
|
262
264
|
cli.use(sessionCommands);
|
|
263
265
|
cli.use(maintenanceCommands);
|