@otto-assistant/bridge 0.4.102 → 0.4.103
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 +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +9 -0
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +9 -0
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
|
@@ -49,6 +49,48 @@ type PendingQuestionContext = {
|
|
|
49
49
|
const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000
|
|
50
50
|
export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
|
|
51
51
|
|
|
52
|
+
export function findPendingQuestionContextForRequest({
|
|
53
|
+
threadId,
|
|
54
|
+
requestId,
|
|
55
|
+
}: {
|
|
56
|
+
threadId: string
|
|
57
|
+
requestId: string
|
|
58
|
+
}): { contextHash: string; context: PendingQuestionContext } | null {
|
|
59
|
+
for (const [contextHash, context] of pendingQuestionContexts) {
|
|
60
|
+
if (context.thread.id !== threadId) {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (context.requestId !== requestId) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
return { contextHash, context }
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deletePendingQuestionContextsForRequest({
|
|
72
|
+
threadId,
|
|
73
|
+
requestId,
|
|
74
|
+
}: {
|
|
75
|
+
threadId: string
|
|
76
|
+
requestId: string
|
|
77
|
+
}): number {
|
|
78
|
+
const matchingContextHashes = [...pendingQuestionContexts.entries()]
|
|
79
|
+
.filter(([, context]) => {
|
|
80
|
+
return context.thread.id === threadId && context.requestId === requestId
|
|
81
|
+
})
|
|
82
|
+
.map(([contextHash]) => {
|
|
83
|
+
return contextHash
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
matchingContextHashes.map((contextHash) => {
|
|
87
|
+
pendingQuestionContexts.delete(contextHash)
|
|
88
|
+
return contextHash
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return matchingContextHashes.length
|
|
92
|
+
}
|
|
93
|
+
|
|
52
94
|
export function hasPendingQuestionForThread(threadId: string): boolean {
|
|
53
95
|
return [...pendingQuestionContexts.values()].some((ctx) => {
|
|
54
96
|
return ctx.thread.id === threadId
|
|
@@ -75,6 +117,17 @@ export async function showAskUserQuestionDropdowns({
|
|
|
75
117
|
/** Suppress notification when queue has pending items */
|
|
76
118
|
silent?: boolean
|
|
77
119
|
}): Promise<void> {
|
|
120
|
+
const existingPending = findPendingQuestionContextForRequest({
|
|
121
|
+
threadId: thread.id,
|
|
122
|
+
requestId,
|
|
123
|
+
})
|
|
124
|
+
if (existingPending) {
|
|
125
|
+
logger.log(
|
|
126
|
+
`Deduped question ${requestId} for thread ${thread.id} (existing context ${existingPending.contextHash})`,
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
79
132
|
|
|
80
133
|
const context: PendingQuestionContext = {
|
|
@@ -103,7 +156,10 @@ export async function showAskUserQuestionDropdowns({
|
|
|
103
156
|
// Without this, a user clicking during the abort() await would still
|
|
104
157
|
// be accepted by handleAskQuestionSelectMenu, then abort() would
|
|
105
158
|
// kill that valid run.
|
|
106
|
-
|
|
159
|
+
deletePendingQuestionContextsForRequest({
|
|
160
|
+
threadId: ctx.thread.id,
|
|
161
|
+
requestId: ctx.requestId,
|
|
162
|
+
})
|
|
107
163
|
// Abort the session so OpenCode isn't stuck waiting for a reply
|
|
108
164
|
const client = getOpencodeClient(ctx.directory)
|
|
109
165
|
if (client) {
|
|
@@ -232,7 +288,10 @@ export async function handleAskQuestionSelectMenu(
|
|
|
232
288
|
if (context.answeredCount >= context.totalQuestions) {
|
|
233
289
|
// All questions answered - send result back to session
|
|
234
290
|
await submitQuestionAnswers(context)
|
|
235
|
-
|
|
291
|
+
deletePendingQuestionContextsForRequest({
|
|
292
|
+
threadId: context.thread.id,
|
|
293
|
+
requestId: context.requestId,
|
|
294
|
+
})
|
|
236
295
|
}
|
|
237
296
|
}
|
|
238
297
|
|
|
@@ -357,7 +416,10 @@ export async function cancelPendingQuestion(
|
|
|
357
416
|
// the question without providing an answer (e.g. voice/attachment-only
|
|
358
417
|
// messages where content needs transcription before it can be an answer).
|
|
359
418
|
if (userMessage === undefined) {
|
|
360
|
-
|
|
419
|
+
deletePendingQuestionContextsForRequest({
|
|
420
|
+
threadId: context.thread.id,
|
|
421
|
+
requestId: context.requestId,
|
|
422
|
+
})
|
|
361
423
|
return 'no-pending'
|
|
362
424
|
}
|
|
363
425
|
|
|
@@ -385,6 +447,9 @@ export async function cancelPendingQuestion(
|
|
|
385
447
|
return 'reply-failed'
|
|
386
448
|
}
|
|
387
449
|
|
|
388
|
-
|
|
450
|
+
deletePendingQuestionContextsForRequest({
|
|
451
|
+
threadId: context.thread.id,
|
|
452
|
+
requestId: context.requestId,
|
|
453
|
+
})
|
|
389
454
|
return 'replied'
|
|
390
455
|
}
|
package/src/commands/btw.ts
CHANGED
|
@@ -22,6 +22,94 @@ import type { CommandContext } from './types.js'
|
|
|
22
22
|
|
|
23
23
|
const logger = createLogger(LogPrefix.FORK)
|
|
24
24
|
|
|
25
|
+
export async function forkSessionToBtwThread({
|
|
26
|
+
sourceThread,
|
|
27
|
+
projectDirectory,
|
|
28
|
+
prompt,
|
|
29
|
+
userId,
|
|
30
|
+
username,
|
|
31
|
+
appId,
|
|
32
|
+
}: {
|
|
33
|
+
sourceThread: ThreadChannel
|
|
34
|
+
projectDirectory: string
|
|
35
|
+
prompt: string
|
|
36
|
+
userId: string
|
|
37
|
+
username: string
|
|
38
|
+
appId: string | undefined
|
|
39
|
+
}): Promise<{ thread: ThreadChannel; forkedSessionId: string } | Error> {
|
|
40
|
+
const sessionId = await getThreadSession(sourceThread.id)
|
|
41
|
+
if (!sessionId) {
|
|
42
|
+
return new Error('No active session in this thread')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
46
|
+
if (getClient instanceof Error) {
|
|
47
|
+
return new Error(`Failed to fork session: ${getClient.message}`, {
|
|
48
|
+
cause: getClient,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const forkResponse = await getClient().session.fork({
|
|
53
|
+
sessionID: sessionId,
|
|
54
|
+
})
|
|
55
|
+
if (!forkResponse.data) {
|
|
56
|
+
return new Error('Failed to fork session')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const textChannel = await resolveTextChannel(sourceThread)
|
|
60
|
+
if (!textChannel) {
|
|
61
|
+
return new Error('Could not resolve parent text channel')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const forkedSession = forkResponse.data
|
|
65
|
+
const thread = await textChannel.threads.create({
|
|
66
|
+
name: `btw: ${prompt}`.slice(0, 100),
|
|
67
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
68
|
+
reason: `btw fork from session ${sessionId}`,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await setThreadSession(thread.id, forkedSession.id)
|
|
72
|
+
await thread.members.add(userId)
|
|
73
|
+
|
|
74
|
+
logger.log(
|
|
75
|
+
`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const sourceThreadLink = `<#${sourceThread.id}>`
|
|
79
|
+
await sendThreadMessage(
|
|
80
|
+
thread,
|
|
81
|
+
`Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const wrappedPrompt = [
|
|
85
|
+
`The user asked a side question while you were working on another task.`,
|
|
86
|
+
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
87
|
+
`Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
|
|
88
|
+
prompt,
|
|
89
|
+
].join('\n')
|
|
90
|
+
|
|
91
|
+
const runtime = getOrCreateRuntime({
|
|
92
|
+
threadId: thread.id,
|
|
93
|
+
thread,
|
|
94
|
+
projectDirectory,
|
|
95
|
+
sdkDirectory: projectDirectory,
|
|
96
|
+
channelId: textChannel.id,
|
|
97
|
+
appId,
|
|
98
|
+
})
|
|
99
|
+
await runtime.enqueueIncoming({
|
|
100
|
+
prompt: wrappedPrompt,
|
|
101
|
+
userId,
|
|
102
|
+
username,
|
|
103
|
+
appId,
|
|
104
|
+
mode: 'opencode',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
thread,
|
|
109
|
+
forkedSessionId: forkedSession.id,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
25
113
|
export async function handleBtwCommand({
|
|
26
114
|
command,
|
|
27
115
|
appId,
|
|
@@ -36,13 +124,11 @@ export async function handleBtwCommand({
|
|
|
36
124
|
return
|
|
37
125
|
}
|
|
38
126
|
|
|
39
|
-
|
|
40
|
-
ChannelType.PublicThread
|
|
41
|
-
ChannelType.PrivateThread
|
|
42
|
-
ChannelType.AnnouncementThread
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!isThread) {
|
|
127
|
+
if (
|
|
128
|
+
channel.type !== ChannelType.PublicThread
|
|
129
|
+
&& channel.type !== ChannelType.PrivateThread
|
|
130
|
+
&& channel.type !== ChannelType.AnnouncementThread
|
|
131
|
+
) {
|
|
46
132
|
await command.reply({
|
|
47
133
|
content:
|
|
48
134
|
'This command can only be used in a thread with an active session',
|
|
@@ -51,10 +137,12 @@ export async function handleBtwCommand({
|
|
|
51
137
|
return
|
|
52
138
|
}
|
|
53
139
|
|
|
140
|
+
const threadChannel = channel
|
|
141
|
+
|
|
54
142
|
const prompt = command.options.getString('prompt', true)
|
|
55
143
|
|
|
56
144
|
const resolved = await resolveWorkingDirectory({
|
|
57
|
-
channel:
|
|
145
|
+
channel: threadChannel,
|
|
58
146
|
})
|
|
59
147
|
|
|
60
148
|
if (!resolved) {
|
|
@@ -67,93 +155,25 @@ export async function handleBtwCommand({
|
|
|
67
155
|
|
|
68
156
|
const { projectDirectory } = resolved
|
|
69
157
|
|
|
70
|
-
const sessionId = await getThreadSession(channel.id)
|
|
71
|
-
|
|
72
|
-
if (!sessionId) {
|
|
73
|
-
await command.reply({
|
|
74
|
-
content: 'No active session in this thread',
|
|
75
|
-
flags: MessageFlags.Ephemeral,
|
|
76
|
-
})
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
|
|
80
158
|
await command.deferReply({ flags: MessageFlags.Ephemeral })
|
|
81
159
|
|
|
82
|
-
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
83
|
-
if (getClient instanceof Error) {
|
|
84
|
-
await command.editReply({
|
|
85
|
-
content: `Failed to fork session: ${getClient.message}`,
|
|
86
|
-
})
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
|
|
90
160
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sessionID: sessionId,
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
if (!forkResponse.data) {
|
|
97
|
-
await command.editReply('Failed to fork session')
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const forkedSession = forkResponse.data
|
|
102
|
-
|
|
103
|
-
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
104
|
-
if (!textChannel) {
|
|
105
|
-
await command.editReply('Could not resolve parent text channel')
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const threadName = `btw: ${prompt}`.slice(0, 100)
|
|
110
|
-
const thread = await textChannel.threads.create({
|
|
111
|
-
name: threadName,
|
|
112
|
-
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
113
|
-
reason: `btw fork from session ${sessionId}`,
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
// Claim the forked session immediately so external polling does not race
|
|
117
|
-
await setThreadSession(thread.id, forkedSession.id)
|
|
118
|
-
|
|
119
|
-
await thread.members.add(command.user.id)
|
|
120
|
-
|
|
121
|
-
logger.log(
|
|
122
|
-
`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`,
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
// Short status message with prompt instead of replaying past messages
|
|
126
|
-
const sourceThreadLink = `<#${channel.id}>`
|
|
127
|
-
await sendThreadMessage(
|
|
128
|
-
thread,
|
|
129
|
-
`Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`,
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
const wrappedPrompt = [
|
|
133
|
-
`The user asked a side question while you were working on another task.`,
|
|
134
|
-
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
135
|
-
`Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
|
|
136
|
-
prompt,
|
|
137
|
-
].join('\n')
|
|
138
|
-
|
|
139
|
-
const runtime = getOrCreateRuntime({
|
|
140
|
-
threadId: thread.id,
|
|
141
|
-
thread,
|
|
161
|
+
const result = await forkSessionToBtwThread({
|
|
162
|
+
sourceThread: threadChannel,
|
|
142
163
|
projectDirectory,
|
|
143
|
-
|
|
144
|
-
channelId: textChannel.id,
|
|
145
|
-
appId,
|
|
146
|
-
})
|
|
147
|
-
await runtime.enqueueIncoming({
|
|
148
|
-
prompt: wrappedPrompt,
|
|
164
|
+
prompt,
|
|
149
165
|
userId: command.user.id,
|
|
150
166
|
username: command.user.displayName,
|
|
151
167
|
appId,
|
|
152
|
-
mode: 'opencode',
|
|
153
168
|
})
|
|
154
169
|
|
|
170
|
+
if (result instanceof Error) {
|
|
171
|
+
await command.editReply(result.message)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
await command.editReply(
|
|
156
|
-
`Session forked! Continue in ${thread.toString()}`,
|
|
176
|
+
`Session forked! Continue in ${result.thread.toString()}`,
|
|
157
177
|
)
|
|
158
178
|
} catch (error) {
|
|
159
179
|
logger.error('Error in /btw:', error)
|
|
@@ -36,6 +36,26 @@ import type { AutocompleteContext } from './types.js'
|
|
|
36
36
|
import * as errore from 'errore'
|
|
37
37
|
|
|
38
38
|
const logger = createLogger(LogPrefix.WORKTREE)
|
|
39
|
+
const DEFAULT_WORKTREE_BASE_REF = 'HEAD'
|
|
40
|
+
|
|
41
|
+
async function resolveRequestedWorktreeBaseRef({
|
|
42
|
+
projectDirectory,
|
|
43
|
+
rawBaseBranch,
|
|
44
|
+
}: {
|
|
45
|
+
projectDirectory: string
|
|
46
|
+
rawBaseBranch?: string
|
|
47
|
+
}): Promise<string | Error> {
|
|
48
|
+
if (!rawBaseBranch) {
|
|
49
|
+
// Default to the current local HEAD so worktrees can branch from
|
|
50
|
+
// unpublished commits in the main checkout.
|
|
51
|
+
return DEFAULT_WORKTREE_BASE_REF
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return validateBranchRef({
|
|
55
|
+
directory: projectDirectory,
|
|
56
|
+
ref: rawBaseBranch,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
39
59
|
|
|
40
60
|
/** Status message shown while a worktree is being created. */
|
|
41
61
|
export function worktreeCreatingMessage(worktreeName: string): string {
|
|
@@ -43,33 +63,89 @@ export function worktreeCreatingMessage(worktreeName: string): string {
|
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
class WorktreeError extends Error {
|
|
46
|
-
constructor(message: string, options?:
|
|
66
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
47
67
|
super(message, options)
|
|
48
68
|
this.name = 'WorktreeError'
|
|
49
69
|
}
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
73
|
+
* Lowercase, collapse whitespace to dashes, drop non-[a-z0-9-] chars.
|
|
74
|
+
* Does NOT add the `opencode/kimaki-` prefix — callers do that so they can
|
|
75
|
+
* optionally compress the slug first for auto-derived names.
|
|
56
76
|
*/
|
|
57
|
-
export function
|
|
58
|
-
|
|
77
|
+
export function slugifyWorktreeName(name: string): string {
|
|
78
|
+
return name
|
|
59
79
|
.toLowerCase()
|
|
60
80
|
.trim()
|
|
61
81
|
.replace(/\s+/g, '-')
|
|
62
82
|
.replace(/[^a-z0-9-]/g, '')
|
|
83
|
+
}
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Compress a slug by stripping vowels from each dash-separated word, but
|
|
87
|
+
* keeping the first character so the word stays recognizable.
|
|
88
|
+
* Only applied to slugs longer than 20 chars — short names are left alone.
|
|
89
|
+
*
|
|
90
|
+
* "configurable-sidebar-width-by-component" → "cnfgrbl-sdbr-wdth-by-cmpnnt"
|
|
91
|
+
*
|
|
92
|
+
* Used ONLY for auto-derived worktree names (thread name, prompt slug)
|
|
93
|
+
* so long Discord titles don't produce 80-char folder paths that make
|
|
94
|
+
* the agent lazy and reuse the previous worktree. User-provided names
|
|
95
|
+
* via `--worktree <name>` or `/new-worktree name:` are never compressed.
|
|
96
|
+
*/
|
|
97
|
+
export function shortenWorktreeSlug(slug: string): string {
|
|
98
|
+
if (slug.length <= 20) {
|
|
99
|
+
return slug
|
|
100
|
+
}
|
|
101
|
+
const shortened = slug
|
|
102
|
+
.split('-')
|
|
103
|
+
.map((word) => {
|
|
104
|
+
if (!word) {
|
|
105
|
+
return word
|
|
106
|
+
}
|
|
107
|
+
const first = word[0]
|
|
108
|
+
const rest = word.slice(1).replace(/[aeiou]/g, '')
|
|
109
|
+
return first + rest
|
|
110
|
+
})
|
|
111
|
+
.join('-')
|
|
112
|
+
return shortened || slug
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
|
|
117
|
+
* "My Feature" → "opencode/kimaki-my-feature"
|
|
118
|
+
* Returns empty string if no valid name can be extracted.
|
|
119
|
+
*
|
|
120
|
+
* This is the "explicit" path used when the user provides a specific name.
|
|
121
|
+
* The slug is NOT compressed — if you ask for `my-long-explicit-branch-name`
|
|
122
|
+
* you get `opencode/kimaki-my-long-explicit-branch-name` verbatim.
|
|
123
|
+
*/
|
|
124
|
+
export function formatWorktreeName(name: string): string {
|
|
125
|
+
const slug = slugifyWorktreeName(name)
|
|
126
|
+
if (!slug) {
|
|
65
127
|
return ''
|
|
66
128
|
}
|
|
67
|
-
return `opencode/kimaki-${
|
|
129
|
+
return `opencode/kimaki-${slug}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format an auto-derived worktree name (from a Discord thread title or a
|
|
134
|
+
* prompt). Same as formatWorktreeName but compresses slugs longer than 20
|
|
135
|
+
* chars by stripping vowels so the on-disk folder name stays short.
|
|
136
|
+
*/
|
|
137
|
+
export function formatAutoWorktreeName(name: string): string {
|
|
138
|
+
const slug = slugifyWorktreeName(name)
|
|
139
|
+
if (!slug) {
|
|
140
|
+
return ''
|
|
141
|
+
}
|
|
142
|
+
return `opencode/kimaki-${shortenWorktreeSlug(slug)}`
|
|
68
143
|
}
|
|
69
144
|
|
|
70
145
|
/**
|
|
71
146
|
* Derive worktree name from thread name.
|
|
72
147
|
* Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
|
|
148
|
+
* Uses formatAutoWorktreeName so long thread titles get vowel-compressed.
|
|
73
149
|
*/
|
|
74
150
|
function deriveWorktreeNameFromThread(threadName: string): string {
|
|
75
151
|
// Handle existing "⬦ worktree: opencode/kimaki-name" format
|
|
@@ -80,10 +156,10 @@ function deriveWorktreeNameFromThread(threadName: string): string {
|
|
|
80
156
|
if (extractedName.startsWith('opencode/kimaki-')) {
|
|
81
157
|
return extractedName
|
|
82
158
|
}
|
|
83
|
-
return
|
|
159
|
+
return formatAutoWorktreeName(extractedName)
|
|
84
160
|
}
|
|
85
|
-
// Use thread name directly
|
|
86
|
-
return
|
|
161
|
+
// Use thread name directly (compressed if > 20 chars)
|
|
162
|
+
return formatAutoWorktreeName(threadName)
|
|
87
163
|
}
|
|
88
164
|
|
|
89
165
|
/**
|
|
@@ -254,15 +330,14 @@ export async function handleNewWorktreeCommand({
|
|
|
254
330
|
return
|
|
255
331
|
}
|
|
256
332
|
|
|
257
|
-
|
|
333
|
+
// Handle command in existing thread - attach worktree to this thread
|
|
334
|
+
if (
|
|
258
335
|
channel.type === ChannelType.PublicThread ||
|
|
259
336
|
channel.type === ChannelType.PrivateThread
|
|
260
|
-
|
|
261
|
-
// Handle command in existing thread - attach worktree to this thread
|
|
262
|
-
if (isThread) {
|
|
337
|
+
) {
|
|
263
338
|
await handleWorktreeInThread({
|
|
264
339
|
command,
|
|
265
|
-
thread: channel
|
|
340
|
+
thread: channel,
|
|
266
341
|
})
|
|
267
342
|
return
|
|
268
343
|
}
|
|
@@ -292,7 +367,7 @@ export async function handleNewWorktreeCommand({
|
|
|
292
367
|
return
|
|
293
368
|
}
|
|
294
369
|
|
|
295
|
-
const textChannel = channel
|
|
370
|
+
const textChannel = channel
|
|
296
371
|
|
|
297
372
|
const projectDirectory = await getProjectDirectoryFromChannel(
|
|
298
373
|
textChannel,
|
|
@@ -302,17 +377,13 @@ export async function handleNewWorktreeCommand({
|
|
|
302
377
|
return
|
|
303
378
|
}
|
|
304
379
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
await command.editReply(`Invalid base branch: \`${baseBranch}\``)
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
baseBranch = validated
|
|
380
|
+
const baseBranch = await resolveRequestedWorktreeBaseRef({
|
|
381
|
+
projectDirectory,
|
|
382
|
+
rawBaseBranch,
|
|
383
|
+
})
|
|
384
|
+
if (baseBranch instanceof Error) {
|
|
385
|
+
await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
|
|
386
|
+
return
|
|
316
387
|
}
|
|
317
388
|
|
|
318
389
|
const existingWorktree = await findExistingWorktreePath({
|
|
@@ -415,24 +486,20 @@ async function handleWorktreeInThread({
|
|
|
415
486
|
}
|
|
416
487
|
|
|
417
488
|
const projectDirectory = await getProjectDirectoryFromChannel(
|
|
418
|
-
parent
|
|
489
|
+
parent,
|
|
419
490
|
)
|
|
420
491
|
if (errore.isError(projectDirectory)) {
|
|
421
492
|
await command.editReply(projectDirectory.message)
|
|
422
493
|
return
|
|
423
494
|
}
|
|
424
495
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
await command.editReply(`Invalid base branch: \`${baseBranch}\``)
|
|
433
|
-
return
|
|
434
|
-
}
|
|
435
|
-
baseBranch = validated
|
|
496
|
+
const baseBranch = await resolveRequestedWorktreeBaseRef({
|
|
497
|
+
projectDirectory,
|
|
498
|
+
rawBaseBranch,
|
|
499
|
+
})
|
|
500
|
+
if (baseBranch instanceof Error) {
|
|
501
|
+
await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``)
|
|
502
|
+
return
|
|
436
503
|
}
|
|
437
504
|
|
|
438
505
|
const existingWorktreePath = await findExistingWorktreePath({
|
package/src/commands/queue.ts
CHANGED
|
@@ -100,6 +100,7 @@ export async function handleClearQueueCommand({
|
|
|
100
100
|
command,
|
|
101
101
|
}: CommandContext): Promise<void> {
|
|
102
102
|
const channel = command.channel
|
|
103
|
+
const position = command.options.getInteger('position') ?? undefined
|
|
103
104
|
|
|
104
105
|
if (!channel) {
|
|
105
106
|
await command.reply({
|
|
@@ -134,6 +135,27 @@ export async function handleClearQueueCommand({
|
|
|
134
135
|
return
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
if (position !== undefined) {
|
|
139
|
+
const removed = runtime?.removeQueuePosition(position)
|
|
140
|
+
if (!removed) {
|
|
141
|
+
await command.reply({
|
|
142
|
+
content: `No queued message at position ${position}`,
|
|
143
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
144
|
+
})
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await command.reply({
|
|
149
|
+
content: `Cleared queued message at position ${position}`,
|
|
150
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
logger.log(
|
|
154
|
+
`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`,
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
runtime?.clearQueue()
|
|
138
160
|
|
|
139
161
|
await command.reply({
|