kimaki 0.5.0 → 0.7.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/anthropic-auth-plugin.js +16 -12
- package/dist/bundled-skills.js +37 -0
- package/dist/cli.js +3 -3
- package/dist/commands/add-dir.js +1 -1
- package/dist/commands/add-dir.test.js +39 -0
- package/dist/commands/btw.js +2 -2
- package/dist/commands/fork-subagent.js +177 -0
- package/dist/commands/fork.js +71 -29
- package/dist/discord-command-registration.js +7 -2
- package/dist/format-tables.js +197 -8
- package/dist/format-tables.test.js +153 -2
- package/dist/hrana-server.js +12 -24
- package/dist/interaction-handler.js +9 -1
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +12 -5
- package/dist/kimaki-opencode-plugin.js +2 -1
- package/dist/message-preprocessing.js +5 -4
- package/dist/message-preprocessing.test.js +35 -0
- package/dist/onboarding-tutorial.js +6 -15
- package/dist/opencode-interrupt-plugin.js +29 -2
- package/dist/opencode.js +31 -25
- package/dist/orphan-opencode-sweep.test.js +80 -0
- package/dist/plugin-logger.js +9 -0
- package/dist/session-handler/event-stream-state.js +29 -1
- package/dist/session-handler/event-stream-state.test.js +70 -1
- package/dist/session-handler/thread-session-runtime.js +4 -0
- package/dist/store.js +1 -1
- package/dist/subagent-rate-limit-plugin.js +175 -0
- package/dist/subagent-rate-limit-plugin.test.js +120 -0
- package/dist/system-message.js +77 -30
- package/dist/system-message.test.js +88 -32
- package/dist/system-prompt-drift-plugin.js +1 -5
- package/dist/thread-message-queue.e2e.test.js +2 -2
- package/dist/voice.js +10 -1
- package/package.json +8 -7
- package/skills/batch/SKILL.md +1 -1
- package/skills/goke/SKILL.md +1 -1
- package/skills/new-skill/SKILL.md +4 -2
- package/skills/npm-package/SKILL.md +62 -23
- package/skills/opensrc/SKILL.md +78 -0
- package/skills/profano/SKILL.md +5 -13
- package/skills/sigillo/SKILL.md +101 -0
- package/skills/spiceflow/SKILL.md +16 -2
- package/skills/tuistory/SKILL.md +60 -212
- package/skills/zele/SKILL.md +32 -124
- package/src/anthropic-auth-plugin.ts +21 -18
- package/src/cli.ts +3 -3
- package/src/commands/add-dir.test.ts +45 -0
- package/src/commands/add-dir.ts +1 -1
- package/src/commands/btw.ts +2 -2
- package/src/commands/fork-subagent.ts +263 -0
- package/src/commands/fork.ts +105 -40
- package/src/discord-command-registration.ts +7 -2
- package/src/format-tables.test.ts +168 -8
- package/src/format-tables.ts +282 -9
- package/src/hrana-server.ts +12 -27
- package/src/interaction-handler.ts +17 -1
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +13 -5
- package/src/kimaki-opencode-plugin.ts +2 -1
- package/src/message-preprocessing.ts +5 -4
- package/src/onboarding-tutorial.ts +6 -15
- package/src/opencode-interrupt-plugin.ts +32 -2
- package/src/opencode.ts +43 -35
- package/src/plugin-logger.ts +16 -0
- package/src/session-handler/event-stream-state.test.ts +74 -0
- package/src/session-handler/event-stream-state.ts +54 -2
- package/src/session-handler/thread-session-runtime.ts +4 -0
- package/src/store.ts +1 -1
- package/src/subagent-rate-limit-plugin.ts +218 -0
- package/src/system-message.test.ts +103 -44
- package/src/system-message.ts +77 -30
- package/src/thread-message-queue.e2e.test.ts +2 -2
- package/src/voice.ts +11 -0
- package/skills/gitchamber/SKILL.md +0 -93
- package/skills/jitter/dist/jitter-utils.js +0 -620
- package/src/system-prompt-drift-plugin.ts +0 -365
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
|
+
import { appendToastSessionMarker } from "./plugin-logger.js";
|
|
25
26
|
import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
|
|
26
27
|
import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
|
|
27
28
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
@@ -484,14 +485,20 @@ function sanitizeAnthropicSystemText(text, onError) {
|
|
|
484
485
|
onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
|
|
485
486
|
return text;
|
|
486
487
|
}
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
//
|
|
488
|
+
// Extract the cwd from the block we're about to strip. OpenCode's system
|
|
489
|
+
// prompt embeds <environment><cwd>/path</cwd></environment> in the identity
|
|
490
|
+
// block. We preserve the per-session cwd instead of falling back to
|
|
491
|
+
// process.cwd() which is the opencode server's cwd and wrong for
|
|
492
|
+
// multi-session/worktree setups where each session has a different directory.
|
|
493
|
+
const strippedBlock = text.slice(startIdx, endIdx);
|
|
494
|
+
const cwdMatch = strippedBlock.match(/<cwd>([^<]+)<\/cwd>/);
|
|
495
|
+
const cwd = cwdMatch?.[1] || process.cwd();
|
|
496
|
+
const envContext = `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
|
|
497
|
+
`Read, write, and edit files under <cwd>.\n\n`;
|
|
490
498
|
const result = text.slice(0, startIdx) +
|
|
491
499
|
envContext +
|
|
492
500
|
text.slice(endIdx);
|
|
493
|
-
|
|
494
|
-
return result.replace(/\bopencode\b/gi, "openc0de");
|
|
501
|
+
return result;
|
|
495
502
|
}
|
|
496
503
|
function mapSystemTextPart(part, onError) {
|
|
497
504
|
if (typeof part === "string") {
|
|
@@ -511,7 +518,10 @@ function mapSystemTextPart(part, onError) {
|
|
|
511
518
|
return part;
|
|
512
519
|
}
|
|
513
520
|
function prependClaudeCodeIdentity(system, onError) {
|
|
514
|
-
const identityBlock = {
|
|
521
|
+
const identityBlock = {
|
|
522
|
+
type: "text",
|
|
523
|
+
text: CLAUDE_CODE_IDENTITY,
|
|
524
|
+
};
|
|
515
525
|
if (typeof system === "undefined")
|
|
516
526
|
return [identityBlock];
|
|
517
527
|
if (typeof system === "string") {
|
|
@@ -649,12 +659,6 @@ function wrapResponseStream(response, reverseToolNameMap) {
|
|
|
649
659
|
headers: response.headers,
|
|
650
660
|
});
|
|
651
661
|
}
|
|
652
|
-
function appendToastSessionMarker({ message, sessionId, }) {
|
|
653
|
-
if (!sessionId) {
|
|
654
|
-
return message;
|
|
655
|
-
}
|
|
656
|
-
return `${message} ${sessionId}`;
|
|
657
|
-
}
|
|
658
662
|
// --- Beta headers ---
|
|
659
663
|
function getRequiredBetas(modelId) {
|
|
660
664
|
const betas = [
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Bundled Kimaki skills path helpers.
|
|
2
|
+
// The canonical tracked skills live at the repository root in /skills.
|
|
3
|
+
// Build and publish scripts copy them into cli/skills so the npm package ships
|
|
4
|
+
// the same files. Prefer the repo-root directory during local development and
|
|
5
|
+
// fall back to the packaged cli/skills directory when running from npm.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
function getCliDir() {
|
|
10
|
+
const currentFilePath = fileURLToPath(import.meta.url);
|
|
11
|
+
return path.resolve(path.dirname(currentFilePath), '..');
|
|
12
|
+
}
|
|
13
|
+
export function resolvePackagedBundledSkillsDir() {
|
|
14
|
+
return path.join(getCliDir(), 'skills');
|
|
15
|
+
}
|
|
16
|
+
export function resolveBundledSkillsDir() {
|
|
17
|
+
const repoSkillsDir = path.resolve(getCliDir(), '..', 'skills');
|
|
18
|
+
if (fs.existsSync(repoSkillsDir)) {
|
|
19
|
+
return repoSkillsDir;
|
|
20
|
+
}
|
|
21
|
+
return resolvePackagedBundledSkillsDir();
|
|
22
|
+
}
|
|
23
|
+
export function listBundledSkillNames() {
|
|
24
|
+
try {
|
|
25
|
+
return fs
|
|
26
|
+
.readdirSync(resolveBundledSkillsDir(), { withFileTypes: true })
|
|
27
|
+
.filter((entry) => {
|
|
28
|
+
return entry.isDirectory();
|
|
29
|
+
})
|
|
30
|
+
.map((entry) => {
|
|
31
|
+
return entry.name;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1291,11 +1291,11 @@ cli
|
|
|
1291
1291
|
.option('--enable-skill <name>', z
|
|
1292
1292
|
.array(z.string())
|
|
1293
1293
|
.optional()
|
|
1294
|
-
.describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/
|
|
1294
|
+
.describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/skills for available skills.'))
|
|
1295
1295
|
.option('--disable-skill <name>', z
|
|
1296
1296
|
.array(z.string())
|
|
1297
1297
|
.optional()
|
|
1298
|
-
.describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/remorses/kimaki/tree/main/
|
|
1298
|
+
.describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/remorses/kimaki/tree/main/skills for available skills.'))
|
|
1299
1299
|
.action(async (options) => {
|
|
1300
1300
|
// Guard: only one kimaki bot process can run at a time (they share a lock
|
|
1301
1301
|
// port). Running `kimaki` here would kill the already-running bot process
|
|
@@ -2788,7 +2788,7 @@ cli
|
|
|
2788
2788
|
});
|
|
2789
2789
|
});
|
|
2790
2790
|
cli
|
|
2791
|
-
.command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C.
|
|
2791
|
+
.command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. For background usage, start with bunx tuistory --help, then run it in a tuistory session.')
|
|
2792
2792
|
.action(async () => {
|
|
2793
2793
|
const { startScreenshare } = await import('./commands/screenshare.js');
|
|
2794
2794
|
try {
|
package/dist/commands/add-dir.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function handleAddDirCommand({ command, }) {
|
|
|
71
71
|
});
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
-
const requestedDirectory = command.options.getString('directory'
|
|
74
|
+
const requestedDirectory = command.options.getString('directory') ?? ALL_DIRECTORIES_PATTERN;
|
|
75
75
|
const resolvedPattern = resolveDirectoryPermissionPattern({
|
|
76
76
|
input: requestedDirectory,
|
|
77
77
|
workingDirectory: resolvedDirectories.workingDirectory,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Tests for /add-dir permission helpers.
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { buildAddDirPermissionRules, resolveDirectoryPermissionPattern, } from './add-dir.js';
|
|
6
7
|
import { buildExternalDirectoryPermissionRules, buildSessionPermissions, } from '../opencode.js';
|
|
@@ -16,6 +17,10 @@ describe('resolveDirectoryPermissionPattern', () => {
|
|
|
16
17
|
expect(result).toBe(nested.replaceAll('\\', '/'));
|
|
17
18
|
});
|
|
18
19
|
test('supports allowing every directory with *', () => {
|
|
20
|
+
expect(resolveDirectoryPermissionPattern({
|
|
21
|
+
input: ' * ',
|
|
22
|
+
workingDirectory: '/repo',
|
|
23
|
+
})).toBe('*');
|
|
19
24
|
expect(buildAddDirPermissionRules({
|
|
20
25
|
resolvedPattern: '*',
|
|
21
26
|
})).toMatchInlineSnapshot(`
|
|
@@ -84,4 +89,38 @@ describe('resolveDirectoryPermissionPattern', () => {
|
|
|
84
89
|
]
|
|
85
90
|
`);
|
|
86
91
|
});
|
|
92
|
+
test('pre-allows common toolchain caches under home with ~ patterns', () => {
|
|
93
|
+
const home = os.homedir().replaceAll('\\', '/');
|
|
94
|
+
expect(buildSessionPermissions({
|
|
95
|
+
directory: '/Users/me/project',
|
|
96
|
+
}).filter((rule) => {
|
|
97
|
+
return [
|
|
98
|
+
`${home}/.cache/zig`,
|
|
99
|
+
`${home}/.cargo`,
|
|
100
|
+
`${home}/.cache/go-build`,
|
|
101
|
+
`${home}/go/pkg`,
|
|
102
|
+
].includes(rule.pattern);
|
|
103
|
+
})).toEqual([
|
|
104
|
+
{
|
|
105
|
+
permission: 'external_directory',
|
|
106
|
+
pattern: `${home}/.cache/zig`,
|
|
107
|
+
action: 'allow',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
permission: 'external_directory',
|
|
111
|
+
pattern: `${home}/.cargo`,
|
|
112
|
+
action: 'allow',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
permission: 'external_directory',
|
|
116
|
+
pattern: `${home}/.cache/go-build`,
|
|
117
|
+
action: 'allow',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
permission: 'external_directory',
|
|
121
|
+
pattern: `${home}/go/pkg`,
|
|
122
|
+
action: 'allow',
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
87
126
|
});
|
package/dist/commands/btw.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// dispatches the user's prompt so the forked session starts working right away.
|
|
5
5
|
import { ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
6
6
|
import { getThreadSession, setThreadSession } from '../database.js';
|
|
7
|
-
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
8
7
|
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
9
8
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
10
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
11
11
|
const logger = createLogger(LogPrefix.FORK);
|
|
12
12
|
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
|
|
13
13
|
const sessionId = await getThreadSession(sourceThread.id);
|
|
@@ -52,7 +52,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
52
52
|
thread,
|
|
53
53
|
projectDirectory,
|
|
54
54
|
sdkDirectory: projectDirectory,
|
|
55
|
-
channelId:
|
|
55
|
+
channelId: sourceThread.parentId || sourceThread.id,
|
|
56
56
|
appId,
|
|
57
57
|
});
|
|
58
58
|
await runtime.enqueueIncoming({
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// /fork-subagent command - Fork a subagent task session into a new thread.
|
|
2
|
+
import { ActionRowBuilder, MessageFlags, StringSelectMenuBuilder, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
3
|
+
import { getSessionEventSnapshot, getThreadSession, setThreadSession, } from '../database.js';
|
|
4
|
+
import { resolveTextChannel, resolveWorkingDirectory, sendThreadMessage, } from '../discord-utils.js';
|
|
5
|
+
import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
|
|
6
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
|
+
import { getDerivedSubagentSessions, } from '../session-handler/event-stream-state.js';
|
|
8
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
|
+
import { getThreadChannel, parsePersistedEventRows, } from './fork.js';
|
|
10
|
+
const forkLogger = createLogger(LogPrefix.FORK);
|
|
11
|
+
function truncateLabelPart(text, maxLength) {
|
|
12
|
+
if (text.length <= maxLength) {
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
if (maxLength <= 1) {
|
|
16
|
+
return text.slice(0, maxLength);
|
|
17
|
+
}
|
|
18
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
19
|
+
}
|
|
20
|
+
function getSubagentOptionLabel({ subagentType, description, }) {
|
|
21
|
+
const agent = truncateLabelPart(subagentType || 'task', 24);
|
|
22
|
+
const cleanedDescription = description?.trim() || 'No description';
|
|
23
|
+
const descriptionBudget = Math.max(1, 100 - agent.length - 3);
|
|
24
|
+
const truncatedDescription = truncateLabelPart(cleanedDescription, descriptionBudget);
|
|
25
|
+
return `${agent} · ${truncatedDescription}`;
|
|
26
|
+
}
|
|
27
|
+
export async function handleForkSubagentCommand(interaction) {
|
|
28
|
+
const threadChannel = getThreadChannel(interaction.channel);
|
|
29
|
+
if (threadChannel instanceof Error) {
|
|
30
|
+
await interaction.reply({
|
|
31
|
+
content: threadChannel.message,
|
|
32
|
+
flags: MessageFlags.Ephemeral,
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const resolved = await resolveWorkingDirectory({
|
|
37
|
+
channel: threadChannel,
|
|
38
|
+
});
|
|
39
|
+
if (!resolved) {
|
|
40
|
+
await interaction.reply({
|
|
41
|
+
content: 'Could not determine project directory for this channel',
|
|
42
|
+
flags: MessageFlags.Ephemeral,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sessionId = await getThreadSession(threadChannel.id);
|
|
47
|
+
if (!sessionId) {
|
|
48
|
+
await interaction.reply({
|
|
49
|
+
content: 'No active session in this thread',
|
|
50
|
+
flags: MessageFlags.Ephemeral,
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
55
|
+
const rows = await getSessionEventSnapshot({ sessionId });
|
|
56
|
+
const events = parsePersistedEventRows({ rows });
|
|
57
|
+
const subagentSessions = getDerivedSubagentSessions({
|
|
58
|
+
events,
|
|
59
|
+
mainSessionId: sessionId,
|
|
60
|
+
}).slice(0, 25);
|
|
61
|
+
if (subagentSessions.length === 0) {
|
|
62
|
+
await interaction.editReply({
|
|
63
|
+
content: 'No subagent task sessions found in this thread',
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const options = subagentSessions.map((subagentSession) => ({
|
|
68
|
+
label: getSubagentOptionLabel({
|
|
69
|
+
subagentType: subagentSession.subagentType,
|
|
70
|
+
description: subagentSession.description,
|
|
71
|
+
}),
|
|
72
|
+
value: subagentSession.childSessionId,
|
|
73
|
+
description: new Date(subagentSession.timestamp).toLocaleString().slice(0, 100),
|
|
74
|
+
}));
|
|
75
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
76
|
+
.setCustomId(`fork_subagent_select:${sessionId}`)
|
|
77
|
+
.setPlaceholder('Select a subagent session to fork')
|
|
78
|
+
.addOptions(options);
|
|
79
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
80
|
+
await interaction.editReply({
|
|
81
|
+
content: '**Fork Subagent Session**\nSelect a subagent task session to fork into a new thread:',
|
|
82
|
+
components: [actionRow],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export async function handleForkSubagentSelectMenu(interaction) {
|
|
86
|
+
const customId = interaction.customId;
|
|
87
|
+
if (!customId.startsWith('fork_subagent_select:')) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const [, parentSessionId] = customId.split(':');
|
|
91
|
+
if (!parentSessionId) {
|
|
92
|
+
await interaction.reply({
|
|
93
|
+
content: 'Invalid selection data',
|
|
94
|
+
flags: MessageFlags.Ephemeral,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const selectedSessionId = interaction.values[0];
|
|
99
|
+
if (!selectedSessionId) {
|
|
100
|
+
await interaction.reply({
|
|
101
|
+
content: 'No subagent session selected',
|
|
102
|
+
flags: MessageFlags.Ephemeral,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await interaction.deferReply();
|
|
107
|
+
const threadChannel = getThreadChannel(interaction.channel);
|
|
108
|
+
if (threadChannel instanceof Error) {
|
|
109
|
+
await interaction.editReply(threadChannel.message);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const resolved = await resolveWorkingDirectory({
|
|
113
|
+
channel: threadChannel,
|
|
114
|
+
});
|
|
115
|
+
if (!resolved) {
|
|
116
|
+
await interaction.editReply('Could not determine project directory for this channel');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const rows = await getSessionEventSnapshot({ sessionId: parentSessionId });
|
|
120
|
+
const events = parsePersistedEventRows({ rows });
|
|
121
|
+
const selectedSubagent = getDerivedSubagentSessions({
|
|
122
|
+
events,
|
|
123
|
+
mainSessionId: parentSessionId,
|
|
124
|
+
}).find((candidate) => {
|
|
125
|
+
return candidate.childSessionId === selectedSessionId;
|
|
126
|
+
});
|
|
127
|
+
const getClient = await initializeOpencodeForDirectory(resolved.projectDirectory);
|
|
128
|
+
if (getClient instanceof Error) {
|
|
129
|
+
await interaction.editReply(`Failed to fork session: ${getClient.message}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const forkResponse = await getClient().session.fork({
|
|
133
|
+
sessionID: selectedSessionId,
|
|
134
|
+
});
|
|
135
|
+
if (!forkResponse.data) {
|
|
136
|
+
await interaction.editReply('Failed to fork session');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const textChannel = await resolveTextChannel(threadChannel);
|
|
140
|
+
if (!textChannel) {
|
|
141
|
+
await interaction.editReply('Could not resolve parent text channel');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const forkedSession = forkResponse.data;
|
|
145
|
+
const forkedThread = await textChannel.threads.create({
|
|
146
|
+
name: `Fork: ${selectedSubagent?.description || selectedSubagent?.subagentType || 'subagent session'}`.slice(0, 100),
|
|
147
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
148
|
+
reason: `Forked subagent session ${selectedSessionId}`,
|
|
149
|
+
});
|
|
150
|
+
await setThreadSession(forkedThread.id, forkedSession.id);
|
|
151
|
+
await forkedThread.members.add(interaction.user.id);
|
|
152
|
+
forkLogger.log(`Created forked subagent session ${forkedSession.id} in thread ${forkedThread.id} from ${selectedSessionId}`);
|
|
153
|
+
const agentLabel = selectedSubagent?.subagentType || 'task';
|
|
154
|
+
const descriptionLabel = selectedSubagent?.description || 'No description';
|
|
155
|
+
await sendThreadMessage(forkedThread, `**Forked subagent session created!**\nAgent: \`${agentLabel}\`\nTask: ${descriptionLabel}\nFrom: \`${selectedSessionId}\`\nNew session: \`${forkedSession.id}\``);
|
|
156
|
+
try {
|
|
157
|
+
const messagesResponse = await getClient().session.messages({
|
|
158
|
+
sessionID: forkedSession.id,
|
|
159
|
+
});
|
|
160
|
+
if (messagesResponse.data) {
|
|
161
|
+
const { chunks } = collectSessionChunks({
|
|
162
|
+
messages: messagesResponse.data,
|
|
163
|
+
limit: 30,
|
|
164
|
+
});
|
|
165
|
+
const batched = batchChunksForDiscord(chunks);
|
|
166
|
+
for (const batch of batched) {
|
|
167
|
+
await sendThreadMessage(forkedThread, batch.content);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
forkLogger.error('Error replaying forked subagent history:', error);
|
|
173
|
+
await sendThreadMessage(forkedThread, 'Failed to load session messages, but the session is connected and ready to continue.');
|
|
174
|
+
}
|
|
175
|
+
await sendThreadMessage(forkedThread, 'You can now continue the conversation from this point.');
|
|
176
|
+
await interaction.editReply(`Subagent session forked! Continue in ${forkedThread.toString()}`);
|
|
177
|
+
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -3,34 +3,78 @@ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectM
|
|
|
3
3
|
import { getThreadSession, setThreadSession, setPartMessagesBatch, } from '../database.js';
|
|
4
4
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
5
|
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
6
|
-
import { collectSessionChunks, batchChunksForDiscord } from '../message-formatting.js';
|
|
6
|
+
import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
8
|
import * as errore from 'errore';
|
|
9
9
|
const sessionLogger = createLogger(LogPrefix.SESSION);
|
|
10
10
|
const forkLogger = createLogger(LogPrefix.FORK);
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
function isTruthy(value) {
|
|
12
|
+
return Boolean(value);
|
|
13
|
+
}
|
|
14
|
+
function getThreadChannelFromCommand(interaction) {
|
|
15
|
+
return getThreadChannel(interaction.channel);
|
|
16
|
+
}
|
|
17
|
+
function getThreadChannel(channel) {
|
|
13
18
|
if (!channel) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
return new Error('This command can only be used in a channel');
|
|
20
|
+
}
|
|
21
|
+
if (channel.type !== ChannelType.PublicThread
|
|
22
|
+
&& channel.type !== ChannelType.PrivateThread
|
|
23
|
+
&& channel.type !== ChannelType.AnnouncementThread) {
|
|
24
|
+
return new Error('This command can only be used in a thread with an active session');
|
|
25
|
+
}
|
|
26
|
+
return channel;
|
|
27
|
+
}
|
|
28
|
+
function parsePersistedEventRows({ rows, }) {
|
|
29
|
+
return rows.flatMap((row) => {
|
|
30
|
+
const parsed = errore.try({
|
|
31
|
+
try: () => {
|
|
32
|
+
return JSON.parse(row.event_json);
|
|
33
|
+
},
|
|
34
|
+
catch: (error) => {
|
|
35
|
+
return new Error('Failed to parse persisted event JSON', {
|
|
36
|
+
cause: error,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
17
39
|
});
|
|
18
|
-
|
|
40
|
+
if (parsed instanceof Error) {
|
|
41
|
+
forkLogger.warn(`[fork] Skipping invalid persisted event row ${row.id}: ${parsed.message}`);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return [{
|
|
45
|
+
event: parsed,
|
|
46
|
+
timestamp: Number(row.timestamp),
|
|
47
|
+
eventIndex: Number(row.event_index),
|
|
48
|
+
}];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function truncateLabelPart(text, maxLength) {
|
|
52
|
+
if (text.length <= maxLength) {
|
|
53
|
+
return text;
|
|
19
54
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
55
|
+
if (maxLength <= 1) {
|
|
56
|
+
return text.slice(0, maxLength);
|
|
57
|
+
}
|
|
58
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
59
|
+
}
|
|
60
|
+
function getSubagentOptionLabel({ subagentType, description, }) {
|
|
61
|
+
const agent = truncateLabelPart(subagentType || 'task', 24);
|
|
62
|
+
const cleanedDescription = description?.trim() || 'No description';
|
|
63
|
+
const descriptionBudget = Math.max(1, 100 - agent.length - 3);
|
|
64
|
+
const truncatedDescription = truncateLabelPart(cleanedDescription, descriptionBudget);
|
|
65
|
+
return `${agent} · ${truncatedDescription}`;
|
|
66
|
+
}
|
|
67
|
+
export async function handleForkCommand(interaction) {
|
|
68
|
+
const threadChannel = getThreadChannelFromCommand(interaction);
|
|
69
|
+
if (threadChannel instanceof Error) {
|
|
26
70
|
await interaction.reply({
|
|
27
|
-
content:
|
|
71
|
+
content: threadChannel.message,
|
|
28
72
|
flags: MessageFlags.Ephemeral,
|
|
29
73
|
});
|
|
30
74
|
return;
|
|
31
75
|
}
|
|
32
76
|
const resolved = await resolveWorkingDirectory({
|
|
33
|
-
channel:
|
|
77
|
+
channel: threadChannel,
|
|
34
78
|
});
|
|
35
79
|
if (!resolved) {
|
|
36
80
|
await interaction.reply({
|
|
@@ -40,7 +84,7 @@ export async function handleForkCommand(interaction) {
|
|
|
40
84
|
return;
|
|
41
85
|
}
|
|
42
86
|
const { projectDirectory } = resolved;
|
|
43
|
-
const sessionId = await getThreadSession(
|
|
87
|
+
const sessionId = await getThreadSession(threadChannel.id);
|
|
44
88
|
if (!sessionId) {
|
|
45
89
|
await interaction.reply({
|
|
46
90
|
content: 'No active session in this thread',
|
|
@@ -79,7 +123,9 @@ export async function handleForkCommand(interaction) {
|
|
|
79
123
|
// injected by the opencode plugin — they clutter the dropdown preview.
|
|
80
124
|
const options = recentMessages
|
|
81
125
|
.map((m, index) => {
|
|
82
|
-
const textPart = m.parts.find((p) =>
|
|
126
|
+
const textPart = m.parts.find((p) => {
|
|
127
|
+
return p.type === 'text' && !p.synthetic && typeof p.text === 'string';
|
|
128
|
+
});
|
|
83
129
|
if (!textPart?.text) {
|
|
84
130
|
return null;
|
|
85
131
|
}
|
|
@@ -93,7 +139,7 @@ export async function handleForkCommand(interaction) {
|
|
|
93
139
|
.slice(0, 50),
|
|
94
140
|
};
|
|
95
141
|
})
|
|
96
|
-
.filter(
|
|
142
|
+
.filter(isTruthy);
|
|
97
143
|
const selectMenu = new StringSelectMenuBuilder()
|
|
98
144
|
// Discord component custom_id max length is 100 chars.
|
|
99
145
|
// Avoid embedding long directory paths (or base64 of them) in the custom ID.
|
|
@@ -136,9 +182,9 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
136
182
|
return;
|
|
137
183
|
}
|
|
138
184
|
await interaction.deferReply();
|
|
139
|
-
const threadChannel = interaction.channel;
|
|
140
|
-
if (
|
|
141
|
-
await interaction.editReply(
|
|
185
|
+
const threadChannel = getThreadChannel(interaction.channel);
|
|
186
|
+
if (threadChannel instanceof Error) {
|
|
187
|
+
await interaction.editReply(threadChannel.message);
|
|
142
188
|
return;
|
|
143
189
|
}
|
|
144
190
|
const resolved = await resolveWorkingDirectory({
|
|
@@ -164,14 +210,9 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
164
210
|
return;
|
|
165
211
|
}
|
|
166
212
|
const forkedSession = forkResponse.data;
|
|
167
|
-
const parentChannel = interaction.channel;
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
ChannelType.PublicThread,
|
|
171
|
-
ChannelType.PrivateThread,
|
|
172
|
-
ChannelType.AnnouncementThread,
|
|
173
|
-
].includes(parentChannel.type)) {
|
|
174
|
-
await interaction.editReply('Could not access parent channel');
|
|
213
|
+
const parentChannel = getThreadChannel(interaction.channel);
|
|
214
|
+
if (parentChannel instanceof Error) {
|
|
215
|
+
await interaction.editReply(parentChannel.message);
|
|
175
216
|
return;
|
|
176
217
|
}
|
|
177
218
|
const textChannel = await resolveTextChannel(parentChannel);
|
|
@@ -218,3 +259,4 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
218
259
|
await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
219
260
|
}
|
|
220
261
|
}
|
|
262
|
+
export { getThreadChannel, parsePersistedEventRows };
|
|
@@ -206,7 +206,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
206
206
|
option
|
|
207
207
|
.setName('directory')
|
|
208
208
|
.setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
|
|
209
|
-
.setRequired(
|
|
209
|
+
.setRequired(false);
|
|
210
210
|
return option;
|
|
211
211
|
})
|
|
212
212
|
.setDMPermission(false)
|
|
@@ -236,6 +236,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
236
236
|
.setDescription(truncateCommandDescription('Fork the session from a past user message'))
|
|
237
237
|
.setDMPermission(false)
|
|
238
238
|
.toJSON(),
|
|
239
|
+
new SlashCommandBuilder()
|
|
240
|
+
.setName('fork-subagent')
|
|
241
|
+
.setDescription(truncateCommandDescription('Fork a subagent task session into a new thread'))
|
|
242
|
+
.setDMPermission(false)
|
|
243
|
+
.toJSON(),
|
|
239
244
|
new SlashCommandBuilder()
|
|
240
245
|
.setName('btw')
|
|
241
246
|
.setDescription(truncateCommandDescription('Ask something without polluting or blocking the current session'))
|
|
@@ -255,7 +260,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
255
260
|
.toJSON(),
|
|
256
261
|
new SlashCommandBuilder()
|
|
257
262
|
.setName('model-variant')
|
|
258
|
-
.setDescription(truncateCommandDescription('
|
|
263
|
+
.setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
|
|
259
264
|
.setDMPermission(false)
|
|
260
265
|
.toJSON(),
|
|
261
266
|
new SlashCommandBuilder()
|