@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.
Files changed (70) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-state.js +31 -0
  4. package/dist/btw-prefix-detection.js +17 -0
  5. package/dist/btw-prefix-detection.test.js +63 -0
  6. package/dist/cli.js +101 -15
  7. package/dist/commands/agent.js +21 -2
  8. package/dist/commands/ask-question.js +50 -4
  9. package/dist/commands/ask-question.test.js +92 -0
  10. package/dist/commands/btw.js +71 -66
  11. package/dist/commands/new-worktree.js +92 -35
  12. package/dist/commands/queue.js +17 -0
  13. package/dist/commands/worktrees.js +196 -139
  14. package/dist/context-awareness-plugin.js +16 -8
  15. package/dist/context-awareness-plugin.test.js +4 -2
  16. package/dist/discord-bot.js +35 -2
  17. package/dist/discord-command-registration.js +9 -2
  18. package/dist/memory-overview-plugin.js +3 -1
  19. package/dist/opencode.js +9 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +135 -10
  21. package/dist/session-handler/thread-runtime-state.js +27 -0
  22. package/dist/session-handler/thread-session-runtime.js +58 -28
  23. package/dist/session-title-rename.test.js +12 -0
  24. package/dist/skill-filter.js +31 -0
  25. package/dist/skill-filter.test.js +65 -0
  26. package/dist/store.js +2 -0
  27. package/dist/system-message.js +12 -3
  28. package/dist/system-message.test.js +10 -6
  29. package/dist/thread-message-queue.e2e.test.js +109 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  31. package/dist/worktrees.js +106 -12
  32. package/dist/worktrees.test.js +232 -6
  33. package/package.json +2 -2
  34. package/skills/goke/SKILL.md +13 -619
  35. package/skills/new-skill/SKILL.md +34 -10
  36. package/skills/npm-package/SKILL.md +336 -2
  37. package/skills/profano/SKILL.md +24 -0
  38. package/skills/zele/SKILL.md +50 -21
  39. package/src/agent-model.e2e.test.ts +1 -0
  40. package/src/anthropic-auth-plugin.ts +24 -4
  41. package/src/anthropic-auth-state.ts +45 -0
  42. package/src/btw-prefix-detection.test.ts +73 -0
  43. package/src/btw-prefix-detection.ts +23 -0
  44. package/src/cli.ts +138 -46
  45. package/src/commands/agent.ts +24 -2
  46. package/src/commands/ask-question.test.ts +111 -0
  47. package/src/commands/ask-question.ts +69 -4
  48. package/src/commands/btw.ts +105 -85
  49. package/src/commands/new-worktree.ts +107 -40
  50. package/src/commands/queue.ts +22 -0
  51. package/src/commands/worktrees.ts +246 -154
  52. package/src/context-awareness-plugin.test.ts +4 -2
  53. package/src/context-awareness-plugin.ts +16 -8
  54. package/src/discord-bot.ts +40 -2
  55. package/src/discord-command-registration.ts +12 -2
  56. package/src/memory-overview-plugin.ts +3 -1
  57. package/src/opencode.ts +9 -0
  58. package/src/queue-question-select-drain.e2e.test.ts +174 -10
  59. package/src/session-handler/thread-runtime-state.ts +36 -1
  60. package/src/session-handler/thread-session-runtime.ts +72 -32
  61. package/src/session-title-rename.test.ts +18 -0
  62. package/src/skill-filter.test.ts +83 -0
  63. package/src/skill-filter.ts +42 -0
  64. package/src/store.ts +17 -0
  65. package/src/system-message.test.ts +10 -6
  66. package/src/system-message.ts +12 -3
  67. package/src/thread-message-queue.e2e.test.ts +126 -0
  68. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  69. package/src/worktrees.test.ts +274 -9
  70. package/src/worktrees.ts +144 -23
@@ -9,6 +9,64 @@ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from
9
9
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
10
10
  import { createLogger, LogPrefix } from '../logger.js';
11
11
  const logger = createLogger(LogPrefix.FORK);
12
+ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
13
+ const sessionId = await getThreadSession(sourceThread.id);
14
+ if (!sessionId) {
15
+ return new Error('No active session in this thread');
16
+ }
17
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
18
+ if (getClient instanceof Error) {
19
+ return new Error(`Failed to fork session: ${getClient.message}`, {
20
+ cause: getClient,
21
+ });
22
+ }
23
+ const forkResponse = await getClient().session.fork({
24
+ sessionID: sessionId,
25
+ });
26
+ if (!forkResponse.data) {
27
+ return new Error('Failed to fork session');
28
+ }
29
+ const textChannel = await resolveTextChannel(sourceThread);
30
+ if (!textChannel) {
31
+ return new Error('Could not resolve parent text channel');
32
+ }
33
+ const forkedSession = forkResponse.data;
34
+ const thread = await textChannel.threads.create({
35
+ name: `btw: ${prompt}`.slice(0, 100),
36
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
37
+ reason: `btw fork from session ${sessionId}`,
38
+ });
39
+ await setThreadSession(thread.id, forkedSession.id);
40
+ await thread.members.add(userId);
41
+ logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`);
42
+ const sourceThreadLink = `<#${sourceThread.id}>`;
43
+ await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
44
+ const wrappedPrompt = [
45
+ `The user asked a side question while you were working on another task.`,
46
+ `This is a forked session whose ONLY goal is to answer this question.`,
47
+ `Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
48
+ prompt,
49
+ ].join('\n');
50
+ const runtime = getOrCreateRuntime({
51
+ threadId: thread.id,
52
+ thread,
53
+ projectDirectory,
54
+ sdkDirectory: projectDirectory,
55
+ channelId: textChannel.id,
56
+ appId,
57
+ });
58
+ await runtime.enqueueIncoming({
59
+ prompt: wrappedPrompt,
60
+ userId,
61
+ username,
62
+ appId,
63
+ mode: 'opencode',
64
+ });
65
+ return {
66
+ thread,
67
+ forkedSessionId: forkedSession.id,
68
+ };
69
+ }
12
70
  export async function handleBtwCommand({ command, appId, }) {
13
71
  const channel = command.channel;
14
72
  if (!channel) {
@@ -18,21 +76,19 @@ export async function handleBtwCommand({ command, appId, }) {
18
76
  });
19
77
  return;
20
78
  }
21
- const isThread = [
22
- ChannelType.PublicThread,
23
- ChannelType.PrivateThread,
24
- ChannelType.AnnouncementThread,
25
- ].includes(channel.type);
26
- if (!isThread) {
79
+ if (channel.type !== ChannelType.PublicThread
80
+ && channel.type !== ChannelType.PrivateThread
81
+ && channel.type !== ChannelType.AnnouncementThread) {
27
82
  await command.reply({
28
83
  content: 'This command can only be used in a thread with an active session',
29
84
  flags: MessageFlags.Ephemeral,
30
85
  });
31
86
  return;
32
87
  }
88
+ const threadChannel = channel;
33
89
  const prompt = command.options.getString('prompt', true);
34
90
  const resolved = await resolveWorkingDirectory({
35
- channel: channel,
91
+ channel: threadChannel,
36
92
  });
37
93
  if (!resolved) {
38
94
  await command.reply({
@@ -42,72 +98,21 @@ export async function handleBtwCommand({ command, appId, }) {
42
98
  return;
43
99
  }
44
100
  const { projectDirectory } = resolved;
45
- const sessionId = await getThreadSession(channel.id);
46
- if (!sessionId) {
47
- await command.reply({
48
- content: 'No active session in this thread',
49
- flags: MessageFlags.Ephemeral,
50
- });
51
- return;
52
- }
53
101
  await command.deferReply({ flags: MessageFlags.Ephemeral });
54
- const getClient = await initializeOpencodeForDirectory(projectDirectory);
55
- if (getClient instanceof Error) {
56
- await command.editReply({
57
- content: `Failed to fork session: ${getClient.message}`,
58
- });
59
- return;
60
- }
61
102
  try {
62
- // Fork the entire session (no messageID = fork at the latest point)
63
- const forkResponse = await getClient().session.fork({
64
- sessionID: sessionId,
65
- });
66
- if (!forkResponse.data) {
67
- await command.editReply('Failed to fork session');
68
- return;
69
- }
70
- const forkedSession = forkResponse.data;
71
- const textChannel = await resolveTextChannel(channel);
72
- if (!textChannel) {
73
- await command.editReply('Could not resolve parent text channel');
74
- return;
75
- }
76
- const threadName = `btw: ${prompt}`.slice(0, 100);
77
- const thread = await textChannel.threads.create({
78
- name: threadName,
79
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
80
- reason: `btw fork from session ${sessionId}`,
81
- });
82
- // Claim the forked session immediately so external polling does not race
83
- await setThreadSession(thread.id, forkedSession.id);
84
- await thread.members.add(command.user.id);
85
- logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`);
86
- // Short status message with prompt instead of replaying past messages
87
- const sourceThreadLink = `<#${channel.id}>`;
88
- await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
89
- const wrappedPrompt = [
90
- `The user asked a side question while you were working on another task.`,
91
- `This is a forked session whose ONLY goal is to answer this question.`,
92
- `Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
93
- prompt,
94
- ].join('\n');
95
- const runtime = getOrCreateRuntime({
96
- threadId: thread.id,
97
- thread,
103
+ const result = await forkSessionToBtwThread({
104
+ sourceThread: threadChannel,
98
105
  projectDirectory,
99
- sdkDirectory: projectDirectory,
100
- channelId: textChannel.id,
101
- appId,
102
- });
103
- await runtime.enqueueIncoming({
104
- prompt: wrappedPrompt,
106
+ prompt,
105
107
  userId: command.user.id,
106
108
  username: command.user.displayName,
107
109
  appId,
108
- mode: 'opencode',
109
110
  });
110
- await command.editReply(`Session forked! Continue in ${thread.toString()}`);
111
+ if (result instanceof Error) {
112
+ await command.editReply(result.message);
113
+ return;
114
+ }
115
+ await command.editReply(`Session forked! Continue in ${result.thread.toString()}`);
111
116
  }
112
117
  catch (error) {
113
118
  logger.error('Error in /btw:', error);
@@ -11,6 +11,18 @@ import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, vali
11
11
  import { WORKTREE_PREFIX } from './merge-worktree.js';
12
12
  import * as errore from 'errore';
13
13
  const logger = createLogger(LogPrefix.WORKTREE);
14
+ const DEFAULT_WORKTREE_BASE_REF = 'HEAD';
15
+ async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
16
+ if (!rawBaseBranch) {
17
+ // Default to the current local HEAD so worktrees can branch from
18
+ // unpublished commits in the main checkout.
19
+ return DEFAULT_WORKTREE_BASE_REF;
20
+ }
21
+ return validateBranchRef({
22
+ directory: projectDirectory,
23
+ ref: rawBaseBranch,
24
+ });
25
+ }
14
26
  /** Status message shown while a worktree is being created. */
15
27
  export function worktreeCreatingMessage(worktreeName) {
16
28
  return `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`;
@@ -22,24 +34,78 @@ class WorktreeError extends Error {
22
34
  }
23
35
  }
24
36
  /**
25
- * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
26
- * "My Feature" "opencode/kimaki-my-feature"
27
- * Returns empty string if no valid name can be extracted.
37
+ * Lowercase, collapse whitespace to dashes, drop non-[a-z0-9-] chars.
38
+ * Does NOT add the `opencode/kimaki-` prefix — callers do that so they can
39
+ * optionally compress the slug first for auto-derived names.
28
40
  */
29
- export function formatWorktreeName(name) {
30
- const formatted = name
41
+ export function slugifyWorktreeName(name) {
42
+ return name
31
43
  .toLowerCase()
32
44
  .trim()
33
45
  .replace(/\s+/g, '-')
34
46
  .replace(/[^a-z0-9-]/g, '');
35
- if (!formatted) {
47
+ }
48
+ /**
49
+ * Compress a slug by stripping vowels from each dash-separated word, but
50
+ * keeping the first character so the word stays recognizable.
51
+ * Only applied to slugs longer than 20 chars — short names are left alone.
52
+ *
53
+ * "configurable-sidebar-width-by-component" → "cnfgrbl-sdbr-wdth-by-cmpnnt"
54
+ *
55
+ * Used ONLY for auto-derived worktree names (thread name, prompt slug)
56
+ * so long Discord titles don't produce 80-char folder paths that make
57
+ * the agent lazy and reuse the previous worktree. User-provided names
58
+ * via `--worktree <name>` or `/new-worktree name:` are never compressed.
59
+ */
60
+ export function shortenWorktreeSlug(slug) {
61
+ if (slug.length <= 20) {
62
+ return slug;
63
+ }
64
+ const shortened = slug
65
+ .split('-')
66
+ .map((word) => {
67
+ if (!word) {
68
+ return word;
69
+ }
70
+ const first = word[0];
71
+ const rest = word.slice(1).replace(/[aeiou]/g, '');
72
+ return first + rest;
73
+ })
74
+ .join('-');
75
+ return shortened || slug;
76
+ }
77
+ /**
78
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
79
+ * "My Feature" → "opencode/kimaki-my-feature"
80
+ * Returns empty string if no valid name can be extracted.
81
+ *
82
+ * This is the "explicit" path used when the user provides a specific name.
83
+ * The slug is NOT compressed — if you ask for `my-long-explicit-branch-name`
84
+ * you get `opencode/kimaki-my-long-explicit-branch-name` verbatim.
85
+ */
86
+ export function formatWorktreeName(name) {
87
+ const slug = slugifyWorktreeName(name);
88
+ if (!slug) {
89
+ return '';
90
+ }
91
+ return `opencode/kimaki-${slug}`;
92
+ }
93
+ /**
94
+ * Format an auto-derived worktree name (from a Discord thread title or a
95
+ * prompt). Same as formatWorktreeName but compresses slugs longer than 20
96
+ * chars by stripping vowels so the on-disk folder name stays short.
97
+ */
98
+ export function formatAutoWorktreeName(name) {
99
+ const slug = slugifyWorktreeName(name);
100
+ if (!slug) {
36
101
  return '';
37
102
  }
38
- return `opencode/kimaki-${formatted}`;
103
+ return `opencode/kimaki-${shortenWorktreeSlug(slug)}`;
39
104
  }
40
105
  /**
41
106
  * Derive worktree name from thread name.
42
107
  * Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
108
+ * Uses formatAutoWorktreeName so long thread titles get vowel-compressed.
43
109
  */
44
110
  function deriveWorktreeNameFromThread(threadName) {
45
111
  // Handle existing "⬦ worktree: opencode/kimaki-name" format
@@ -50,10 +116,10 @@ function deriveWorktreeNameFromThread(threadName) {
50
116
  if (extractedName.startsWith('opencode/kimaki-')) {
51
117
  return extractedName;
52
118
  }
53
- return formatWorktreeName(extractedName);
119
+ return formatAutoWorktreeName(extractedName);
54
120
  }
55
- // Use thread name directly
56
- return formatWorktreeName(threadName);
121
+ // Use thread name directly (compressed if > 20 chars)
122
+ return formatAutoWorktreeName(threadName);
57
123
  }
58
124
  /**
59
125
  * Get project directory from database.
@@ -169,10 +235,9 @@ export async function handleNewWorktreeCommand({ command, }) {
169
235
  await command.editReply('Cannot determine channel');
170
236
  return;
171
237
  }
172
- const isThread = channel.type === ChannelType.PublicThread ||
173
- channel.type === ChannelType.PrivateThread;
174
238
  // Handle command in existing thread - attach worktree to this thread
175
- if (isThread) {
239
+ if (channel.type === ChannelType.PublicThread ||
240
+ channel.type === ChannelType.PrivateThread) {
176
241
  await handleWorktreeInThread({
177
242
  command,
178
243
  thread: channel,
@@ -201,17 +266,13 @@ export async function handleNewWorktreeCommand({ command, }) {
201
266
  await command.editReply(projectDirectory.message);
202
267
  return;
203
268
  }
204
- let baseBranch = rawBaseBranch;
205
- if (baseBranch) {
206
- const validated = await validateBranchRef({
207
- directory: projectDirectory,
208
- ref: baseBranch,
209
- });
210
- if (validated instanceof Error) {
211
- await command.editReply(`Invalid base branch: \`${baseBranch}\``);
212
- return;
213
- }
214
- baseBranch = validated;
269
+ const baseBranch = await resolveRequestedWorktreeBaseRef({
270
+ projectDirectory,
271
+ rawBaseBranch,
272
+ });
273
+ if (baseBranch instanceof Error) {
274
+ await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
275
+ return;
215
276
  }
216
277
  const existingWorktree = await findExistingWorktreePath({
217
278
  projectDirectory,
@@ -294,17 +355,13 @@ async function handleWorktreeInThread({ command, thread, }) {
294
355
  await command.editReply(projectDirectory.message);
295
356
  return;
296
357
  }
297
- let baseBranch = rawBaseBranch;
298
- if (baseBranch) {
299
- const validated = await validateBranchRef({
300
- directory: projectDirectory,
301
- ref: baseBranch,
302
- });
303
- if (validated instanceof Error) {
304
- await command.editReply(`Invalid base branch: \`${baseBranch}\``);
305
- return;
306
- }
307
- baseBranch = validated;
358
+ const baseBranch = await resolveRequestedWorktreeBaseRef({
359
+ projectDirectory,
360
+ rawBaseBranch,
361
+ });
362
+ if (baseBranch instanceof Error) {
363
+ await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
364
+ return;
308
365
  }
309
366
  const existingWorktreePath = await findExistingWorktreePath({
310
367
  projectDirectory,
@@ -72,6 +72,7 @@ export async function handleQueueCommand({ command, appId, }) {
72
72
  }
73
73
  export async function handleClearQueueCommand({ command, }) {
74
74
  const channel = command.channel;
75
+ const position = command.options.getInteger('position') ?? undefined;
75
76
  if (!channel) {
76
77
  await command.reply({
77
78
  content: 'This command can only be used in a channel',
@@ -100,6 +101,22 @@ export async function handleClearQueueCommand({ command, }) {
100
101
  });
101
102
  return;
102
103
  }
104
+ if (position !== undefined) {
105
+ const removed = runtime?.removeQueuePosition(position);
106
+ if (!removed) {
107
+ await command.reply({
108
+ content: `No queued message at position ${position}`,
109
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
110
+ });
111
+ return;
112
+ }
113
+ await command.reply({
114
+ content: `Cleared queued message at position ${position}`,
115
+ flags: SILENT_MESSAGE_FLAGS,
116
+ });
117
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
118
+ return;
119
+ }
103
120
  runtime?.clearQueue();
104
121
  await command.reply({
105
122
  content: `Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,