kimaki 0.28.0 → 0.30.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/agent-model.e2e.test.js +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- 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 +118 -36
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +6 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +84 -36
- 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 +149 -6
- package/dist/db.js +34 -0
- package/dist/db.test.js +199 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +220 -146
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/guild-categories.test.js +225 -0
- package/dist/hrana-server.js +68 -14
- 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/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +29 -18
- package/dist/message-formatting.test.js +111 -3
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +240 -6
- package/dist/session-handler/event-stream-state.test.js +1116 -130
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +499 -89
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +64 -23
- package/dist/system-message.test.js +69 -23
- package/dist/task-id-plugin.js +16 -0
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +7 -7
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/skills/strada/SKILL.md +61 -232
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -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 +167 -38
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +8 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +111 -45
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +219 -6
- package/src/db.test.ts +236 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +272 -185
- package/src/discord-command-registration.ts +3 -1
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/guild-categories.test.ts +260 -0
- package/src/hrana-server.ts +96 -16
- 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/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +129 -2
- package/src/message-formatting.ts +28 -14
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +1184 -133
- package/src/session-handler/event-stream-state.ts +322 -8
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +597 -95
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +70 -23
- package/src/system-message.ts +67 -22
- package/src/task-id-plugin.ts +17 -0
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// When the system prompt changes between user messages, it logs a warning
|
|
3
3
|
// with addition/deletion line counts so you know context cache was discarded.
|
|
4
4
|
// This helps spot plugins that mutate the system prompt unexpectedly.
|
|
5
|
-
import { diffLines } from 'diff';
|
|
6
5
|
import * as errore from 'errore';
|
|
6
|
+
import { countSystemPromptDiffLines, writeSystemPromptPatch, } from './cache-rewrite.js';
|
|
7
7
|
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
8
8
|
import { initSentry, notifyError } from './sentry.js';
|
|
9
9
|
const logger = createPluginLogger('OPENCODE');
|
|
@@ -27,18 +27,6 @@ function shouldSuppressDriftWarning({ previousContext, currentContext, }) {
|
|
|
27
27
|
|| previousContext.model !== currentContext.model
|
|
28
28
|
|| previousContext.directory !== currentContext.directory);
|
|
29
29
|
}
|
|
30
|
-
function countDiffLines({ beforeText, afterText, }) {
|
|
31
|
-
const changes = diffLines(beforeText, afterText);
|
|
32
|
-
let additions = 0;
|
|
33
|
-
let deletions = 0;
|
|
34
|
-
for (const change of changes) {
|
|
35
|
-
if (change.added)
|
|
36
|
-
additions += change.count ?? 0;
|
|
37
|
-
if (change.removed)
|
|
38
|
-
deletions += change.count ?? 0;
|
|
39
|
-
}
|
|
40
|
-
return { additions, deletions };
|
|
41
|
-
}
|
|
42
30
|
function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
43
31
|
const existing = sessions.get(sessionId);
|
|
44
32
|
if (existing)
|
|
@@ -56,7 +44,7 @@ function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
|
56
44
|
sessions.set(sessionId, state);
|
|
57
45
|
return state;
|
|
58
46
|
}
|
|
59
|
-
function handleSystemTransform({ input, output, sessions, }) {
|
|
47
|
+
function handleSystemTransform({ input, output, sessions, dataDir, }) {
|
|
60
48
|
const sessionId = input.sessionID;
|
|
61
49
|
if (!sessionId)
|
|
62
50
|
return;
|
|
@@ -79,12 +67,31 @@ function handleSystemTransform({ input, output, sessions, }) {
|
|
|
79
67
|
})) {
|
|
80
68
|
return;
|
|
81
69
|
}
|
|
82
|
-
const { additions, deletions } =
|
|
70
|
+
const { additions, deletions } = countSystemPromptDiffLines({
|
|
71
|
+
beforeText: previousPrompt,
|
|
72
|
+
afterText: currentPrompt,
|
|
73
|
+
});
|
|
74
|
+
const model = state.currentTurnContext?.model;
|
|
75
|
+
const agent = state.currentTurnContext?.agent;
|
|
76
|
+
if (!dataDir) {
|
|
77
|
+
logger.warn(`[cache-drift] system prompt changed for session ${sessionId} `
|
|
78
|
+
+ `(+${additions} / -${deletions}), no data dir for patch`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void writeSystemPromptPatch({
|
|
82
|
+
dataDir,
|
|
83
|
+
sessionId,
|
|
83
84
|
beforeText: previousPrompt,
|
|
84
85
|
afterText: currentPrompt,
|
|
86
|
+
model,
|
|
87
|
+
agent,
|
|
88
|
+
}).then((filePath) => {
|
|
89
|
+
logger.warn(`[cache-drift] system prompt changed for session ${sessionId} `
|
|
90
|
+
+ `(+${additions} / -${deletions}) patch=${filePath}`);
|
|
91
|
+
}).catch((error) => {
|
|
92
|
+
logger.warn(`[cache-drift] ${formatPluginErrorWithStack(error)}`);
|
|
93
|
+
void notifyError(error, 'cache drift patch write failed');
|
|
85
94
|
});
|
|
86
|
-
logger.warn(`[cache-drift] context cache discarded for session ${sessionId}: `
|
|
87
|
-
+ `system prompt changed since previous message (+${additions} / -${deletions} lines)`);
|
|
88
95
|
}
|
|
89
96
|
function getDeletedSessionId({ event }) {
|
|
90
97
|
if (event.type !== 'session.deleted')
|
|
@@ -131,7 +138,7 @@ const cacheDriftPlugin = async ({ directory }) => {
|
|
|
131
138
|
state.pendingCompareTimeout = setTimeout(() => {
|
|
132
139
|
state.pendingCompareTimeout = undefined;
|
|
133
140
|
try {
|
|
134
|
-
handleSystemTransform({ input, output, sessions });
|
|
141
|
+
handleSystemTransform({ input, output, sessions, dataDir });
|
|
135
142
|
}
|
|
136
143
|
catch (err) {
|
|
137
144
|
logger.warn(`[cache-drift] ${formatPluginErrorWithStack(err)}`);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createTwoFilesPatch, diffLines } from 'diff';
|
|
4
|
+
export const CACHE_REWRITES_DIR = 'cache-rewrites';
|
|
5
|
+
export function countSystemPromptDiffLines({ beforeText, afterText, }) {
|
|
6
|
+
const changes = diffLines(beforeText, afterText);
|
|
7
|
+
let additions = 0;
|
|
8
|
+
let deletions = 0;
|
|
9
|
+
for (const change of changes) {
|
|
10
|
+
if (change.added)
|
|
11
|
+
additions += change.count ?? 0;
|
|
12
|
+
if (change.removed)
|
|
13
|
+
deletions += change.count ?? 0;
|
|
14
|
+
}
|
|
15
|
+
return { additions, deletions };
|
|
16
|
+
}
|
|
17
|
+
export function formatSystemPromptPatch({ beforeText, afterText, sessionId, model, agent, }) {
|
|
18
|
+
const { additions, deletions } = countSystemPromptDiffLines({ beforeText, afterText });
|
|
19
|
+
const patch = createTwoFilesPatch('system-before.txt', 'system-after.txt', beforeText, afterText, undefined, undefined, { context: 3 });
|
|
20
|
+
return [
|
|
21
|
+
`session: ${sessionId}`,
|
|
22
|
+
`model: ${model ?? 'unknown'}`,
|
|
23
|
+
`agent: ${agent ?? 'unknown'}`,
|
|
24
|
+
`system +${additions} -${deletions}`,
|
|
25
|
+
'',
|
|
26
|
+
patch,
|
|
27
|
+
].join('\n');
|
|
28
|
+
}
|
|
29
|
+
export function cacheRewritePatchPath({ dataDir, sessionId, now = Date.now(), }) {
|
|
30
|
+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
31
|
+
return path.join(dataDir, CACHE_REWRITES_DIR, `${now}-${safeSessionId}.patch`);
|
|
32
|
+
}
|
|
33
|
+
export async function writeSystemPromptPatch({ dataDir, sessionId, beforeText, afterText, model, agent, now, }) {
|
|
34
|
+
const filePath = cacheRewritePatchPath({ dataDir, sessionId, now });
|
|
35
|
+
const dirPath = path.dirname(filePath);
|
|
36
|
+
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
37
|
+
await fs.promises.writeFile(filePath, formatSystemPromptPatch({ beforeText, afterText, sessionId, model, agent }), { encoding: 'utf8', mode: 0o600 });
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, expect, test } from 'vitest';
|
|
5
|
+
import { countSystemPromptDiffLines, formatSystemPromptPatch, writeSystemPromptPatch, } from './cache-rewrite.js';
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
for (const dir of tempDirs.splice(0)) {
|
|
9
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
test('counts added and removed system prompt lines', () => {
|
|
13
|
+
expect(countSystemPromptDiffLines({
|
|
14
|
+
beforeText: 'keep\nold line\n',
|
|
15
|
+
afterText: 'keep\nnew line\nextra\n',
|
|
16
|
+
})).toEqual({ additions: 2, deletions: 1 });
|
|
17
|
+
});
|
|
18
|
+
test('writes a unified patch only for the caller', async () => {
|
|
19
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-cache-rewrite-'));
|
|
20
|
+
tempDirs.push(dataDir);
|
|
21
|
+
const filePath = await writeSystemPromptPatch({
|
|
22
|
+
dataDir,
|
|
23
|
+
sessionId: 'ses_test',
|
|
24
|
+
beforeText: 'alpha\n',
|
|
25
|
+
afterText: 'beta\n',
|
|
26
|
+
model: 'openai/gpt-5',
|
|
27
|
+
agent: 'build',
|
|
28
|
+
now: 1_700_000_000_000,
|
|
29
|
+
});
|
|
30
|
+
expect(path.basename(filePath)).toBe('1700000000000-ses_test.patch');
|
|
31
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
expect(text).toContain('session: ses_test');
|
|
33
|
+
expect(text).toContain('system +1 -1');
|
|
34
|
+
expect(text).toContain('-alpha');
|
|
35
|
+
expect(text).toContain('+beta');
|
|
36
|
+
expect(formatSystemPromptPatch({
|
|
37
|
+
beforeText: 'alpha\n',
|
|
38
|
+
afterText: 'beta\n',
|
|
39
|
+
sessionId: 'ses_test',
|
|
40
|
+
})).toContain('--- system-before.txt');
|
|
41
|
+
});
|
|
@@ -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) {
|