@otto-assistant/bridge 0.4.101 → 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 +24 -1
- 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 +31 -1
- 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
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// /worktrees command — list
|
|
1
|
+
// /worktrees command — list all git worktrees for the current channel's project.
|
|
2
|
+
// Uses `git worktree list --porcelain` as source of truth, enriched with
|
|
3
|
+
// DB metadata (thread link, created_at) when available. Shows kimaki-created,
|
|
4
|
+
// opencode-created, and manually created worktrees in a single table.
|
|
2
5
|
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
3
6
|
// including HTML-backed action buttons for deletable worktrees.
|
|
4
7
|
import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
|
|
5
|
-
import { deleteThreadWorktree,
|
|
8
|
+
import { deleteThreadWorktree, } from '../database.js';
|
|
6
9
|
import { getPrisma } from '../db.js';
|
|
7
10
|
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
8
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
9
12
|
import * as errore from 'errore';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
10
14
|
import { GitCommandError } from '../errors.js';
|
|
11
15
|
import { resolveWorkingDirectory } from '../discord-utils.js';
|
|
12
|
-
import { deleteWorktree, git, getDefaultBranch } from '../worktrees.js';
|
|
16
|
+
import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
|
|
17
|
+
import path from 'node:path';
|
|
13
18
|
// Extracts the git stderr from a deleteWorktree error via errore.findCause.
|
|
14
19
|
// Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
|
|
15
20
|
export function extractGitStderr(error) {
|
|
@@ -42,33 +47,36 @@ export function formatTimeAgo(date) {
|
|
|
42
47
|
const remainingHours = hours % 24;
|
|
43
48
|
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (wt.status === 'error') {
|
|
50
|
-
return 'error';
|
|
51
|
-
}
|
|
52
|
-
return 'pending';
|
|
50
|
+
// Stable button ID derived from directory path via sha1 hash.
|
|
51
|
+
// Avoids collisions that truncated path suffixes can cause.
|
|
52
|
+
function worktreeButtonKey(directory) {
|
|
53
|
+
return crypto.createHash('sha1').update(directory).digest('hex').slice(0, 12);
|
|
53
54
|
}
|
|
54
55
|
// 5s timeout per git call — prevents hangs from deleted dirs, git locks, slow disks.
|
|
55
56
|
// Returns null on timeout/error so the table shows "unknown" for that worktree.
|
|
56
57
|
const GIT_CMD_TIMEOUT = 5_000;
|
|
57
58
|
const GLOBAL_TIMEOUT = 10_000;
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// Detect worktree source from branch name and directory path.
|
|
60
|
+
// opencode/kimaki-* branches → kimaki, opencode worktree paths → opencode, else manual.
|
|
61
|
+
function detectWorktreeSource({ branch, directory, }) {
|
|
62
|
+
if (branch?.startsWith('opencode/kimaki-')) {
|
|
63
|
+
return 'kimaki';
|
|
64
|
+
}
|
|
65
|
+
// opencode stores worktrees under ~/.local/share/opencode/worktree/
|
|
66
|
+
if (directory.includes('/opencode/worktree/')) {
|
|
67
|
+
return 'opencode';
|
|
64
68
|
}
|
|
69
|
+
return 'manual';
|
|
70
|
+
}
|
|
71
|
+
// Checks dirty state and commits ahead of default branch in parallel.
|
|
72
|
+
// Returns null when the directory is missing / git commands fail / timeout.
|
|
73
|
+
async function getWorktreeGitStatus({ directory, defaultBranch, }) {
|
|
65
74
|
try {
|
|
66
|
-
const dir = wt.worktree_directory;
|
|
67
75
|
// Use raw git calls so errors/timeouts are visible — isDirty() swallows
|
|
68
76
|
// errors and returns false, which would render "merged" instead of "unknown".
|
|
69
77
|
const [statusResult, aheadResult] = await Promise.all([
|
|
70
|
-
git(
|
|
71
|
-
git(
|
|
78
|
+
git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
|
|
79
|
+
git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {
|
|
72
80
|
timeout: GIT_CMD_TIMEOUT,
|
|
73
81
|
}),
|
|
74
82
|
]);
|
|
@@ -85,16 +93,28 @@ async function getWorktreeGitStatus({ wt, defaultBranch, }) {
|
|
|
85
93
|
return null;
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
|
-
function buildWorktreeTable({
|
|
89
|
-
const header = '|
|
|
96
|
+
function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
|
|
97
|
+
const header = '| Source | Name | Status | Created | Folder | Action |';
|
|
90
98
|
const separator = '|---|---|---|---|---|---|';
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
99
|
+
const tableRows = rows.map((row, i) => {
|
|
100
|
+
const sourceCell = (() => {
|
|
101
|
+
if (row.threadId && row.guildId) {
|
|
102
|
+
const threadLink = `[${row.source}](https://discord.com/channels/${row.guildId}/${row.threadId})`;
|
|
103
|
+
return threadLink;
|
|
104
|
+
}
|
|
105
|
+
return row.source;
|
|
106
|
+
})();
|
|
107
|
+
const name = row.name;
|
|
94
108
|
const gs = gitStatuses[i] ?? null;
|
|
95
109
|
const status = (() => {
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
110
|
+
if (row.dbStatus !== 'ready') {
|
|
111
|
+
return row.dbStatus;
|
|
112
|
+
}
|
|
113
|
+
if (row.locked) {
|
|
114
|
+
return 'locked';
|
|
115
|
+
}
|
|
116
|
+
if (row.prunable) {
|
|
117
|
+
return 'prunable';
|
|
98
118
|
}
|
|
99
119
|
if (!gs) {
|
|
100
120
|
return 'unknown';
|
|
@@ -111,26 +131,29 @@ function buildWorktreeTable({ worktrees, gitStatuses, guildId, }) {
|
|
|
111
131
|
}
|
|
112
132
|
return parts.join(', ');
|
|
113
133
|
})();
|
|
114
|
-
const created =
|
|
115
|
-
const folder =
|
|
116
|
-
const action = buildActionCell({
|
|
117
|
-
return `| ${
|
|
134
|
+
const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
|
|
135
|
+
const folder = row.directory;
|
|
136
|
+
const action = buildActionCell({ row, gitStatus: gs });
|
|
137
|
+
return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
|
|
118
138
|
});
|
|
119
|
-
return [header, separator, ...
|
|
139
|
+
return [header, separator, ...tableRows].join('\n');
|
|
120
140
|
}
|
|
121
|
-
function buildActionCell({
|
|
122
|
-
if (!canDeleteWorktree({
|
|
141
|
+
function buildActionCell({ row, gitStatus, }) {
|
|
142
|
+
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
123
143
|
return '-';
|
|
124
144
|
}
|
|
125
145
|
return buildDeleteButtonHtml({
|
|
126
|
-
buttonId: `
|
|
146
|
+
buttonId: `del-wt-${worktreeButtonKey(row.directory)}`,
|
|
127
147
|
});
|
|
128
148
|
}
|
|
129
149
|
function buildDeleteButtonHtml({ buttonId, }) {
|
|
130
150
|
return `<button id="${buttonId}" variant="secondary">Delete</button>`;
|
|
131
151
|
}
|
|
132
|
-
function canDeleteWorktree({
|
|
133
|
-
if (
|
|
152
|
+
function canDeleteWorktree({ row, gitStatus, }) {
|
|
153
|
+
if (row.dbStatus !== 'ready') {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (row.locked) {
|
|
134
157
|
return false;
|
|
135
158
|
}
|
|
136
159
|
if (!gitStatus) {
|
|
@@ -142,11 +165,8 @@ function canDeleteWorktree({ wt, gitStatus, }) {
|
|
|
142
165
|
return gitStatus.aheadCount === 0;
|
|
143
166
|
}
|
|
144
167
|
// Resolves git statuses for all worktrees within a single global deadline.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// the global deadline — the table renders those as "unknown".
|
|
148
|
-
async function resolveGitStatuses({ worktrees, timeout, }) {
|
|
149
|
-
const nullFallback = worktrees.map(() => null);
|
|
168
|
+
async function resolveGitStatuses({ rows, projectDirectory, timeout, }) {
|
|
169
|
+
const nullFallback = rows.map(() => null);
|
|
150
170
|
let timer;
|
|
151
171
|
const deadline = new Promise((resolve) => {
|
|
152
172
|
timer = setTimeout(() => {
|
|
@@ -154,19 +174,14 @@ async function resolveGitStatuses({ worktrees, timeout, }) {
|
|
|
154
174
|
}, timeout);
|
|
155
175
|
});
|
|
156
176
|
const work = (async () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
}));
|
|
166
|
-
const defaultBranchByProject = new Map(defaultBranchEntries);
|
|
167
|
-
return Promise.all(worktrees.map((wt) => {
|
|
168
|
-
const defaultBranch = defaultBranchByProject.get(wt.project_directory) ?? 'main';
|
|
169
|
-
return getWorktreeGitStatus({ wt, defaultBranch });
|
|
177
|
+
const defaultBranch = await getDefaultBranch(projectDirectory, {
|
|
178
|
+
timeout: GIT_CMD_TIMEOUT,
|
|
179
|
+
});
|
|
180
|
+
return Promise.all(rows.map((row) => {
|
|
181
|
+
if (row.dbStatus !== 'ready' || row.locked || row.prunable) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return getWorktreeGitStatus({ directory: row.directory, defaultBranch });
|
|
170
185
|
}));
|
|
171
186
|
})();
|
|
172
187
|
try {
|
|
@@ -176,15 +191,91 @@ async function resolveGitStatuses({ worktrees, timeout, }) {
|
|
|
176
191
|
clearTimeout(timer);
|
|
177
192
|
}
|
|
178
193
|
}
|
|
179
|
-
|
|
194
|
+
// Merge git worktrees with DB metadata into unified WorktreeRows.
|
|
195
|
+
// Git is the source of truth for what exists on disk. DB rows that aren't
|
|
196
|
+
// in the git list (pending/error) are appended at the end.
|
|
197
|
+
async function buildWorktreeRows({ projectDirectory, gitWorktrees, }) {
|
|
180
198
|
const prisma = await getPrisma();
|
|
181
|
-
|
|
182
|
-
where: {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
const dbWorktrees = await prisma.thread_worktrees.findMany({
|
|
200
|
+
where: { project_directory: projectDirectory },
|
|
201
|
+
});
|
|
202
|
+
// Index DB worktrees by directory for fast lookup
|
|
203
|
+
const dbByDirectory = new Map();
|
|
204
|
+
for (const dbWt of dbWorktrees) {
|
|
205
|
+
if (dbWt.worktree_directory) {
|
|
206
|
+
dbByDirectory.set(dbWt.worktree_directory, dbWt);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Track which DB rows got matched so we can append unmatched ones
|
|
210
|
+
const matchedDbThreadIds = new Set();
|
|
211
|
+
// Build rows from git worktrees (the source of truth for on-disk state).
|
|
212
|
+
// Use real DB status when available — a git-visible worktree whose DB row
|
|
213
|
+
// is still 'pending' means setup hasn't finished (race window).
|
|
214
|
+
const gitRows = gitWorktrees.map((gw) => {
|
|
215
|
+
const dbMatch = dbByDirectory.get(gw.directory);
|
|
216
|
+
if (dbMatch) {
|
|
217
|
+
matchedDbThreadIds.add(dbMatch.thread_id);
|
|
218
|
+
}
|
|
219
|
+
const source = detectWorktreeSource({
|
|
220
|
+
branch: gw.branch,
|
|
221
|
+
directory: gw.directory,
|
|
222
|
+
});
|
|
223
|
+
const name = gw.branch ?? path.basename(gw.directory);
|
|
224
|
+
const dbStatus = (() => {
|
|
225
|
+
if (!dbMatch) {
|
|
226
|
+
return 'ready';
|
|
227
|
+
}
|
|
228
|
+
if (dbMatch.status === 'error') {
|
|
229
|
+
return 'error';
|
|
230
|
+
}
|
|
231
|
+
if (dbMatch.status === 'pending') {
|
|
232
|
+
return 'pending';
|
|
233
|
+
}
|
|
234
|
+
return 'ready';
|
|
235
|
+
})();
|
|
236
|
+
return {
|
|
237
|
+
directory: gw.directory,
|
|
238
|
+
branch: gw.branch,
|
|
239
|
+
name,
|
|
240
|
+
threadId: dbMatch?.thread_id ?? null,
|
|
241
|
+
guildId: null, // filled in by caller
|
|
242
|
+
createdAt: dbMatch?.created_at ?? null,
|
|
243
|
+
source,
|
|
244
|
+
dbStatus,
|
|
245
|
+
locked: gw.locked,
|
|
246
|
+
prunable: gw.prunable,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
// Append DB-only worktrees (pending/error/stale — not visible to git).
|
|
250
|
+
// Preserve actual DB status so stale 'ready' rows show as 'ready' (missing).
|
|
251
|
+
const dbOnlyRows = dbWorktrees
|
|
252
|
+
.filter((dbWt) => {
|
|
253
|
+
return !matchedDbThreadIds.has(dbWt.thread_id);
|
|
254
|
+
})
|
|
255
|
+
.map((dbWt) => {
|
|
256
|
+
const dbStatus = (() => {
|
|
257
|
+
if (dbWt.status === 'error') {
|
|
258
|
+
return 'error';
|
|
259
|
+
}
|
|
260
|
+
if (dbWt.status === 'pending') {
|
|
261
|
+
return 'pending';
|
|
262
|
+
}
|
|
263
|
+
return 'ready';
|
|
264
|
+
})();
|
|
265
|
+
return {
|
|
266
|
+
directory: dbWt.worktree_directory ?? dbWt.project_directory,
|
|
267
|
+
branch: null,
|
|
268
|
+
name: dbWt.worktree_name,
|
|
269
|
+
threadId: dbWt.thread_id,
|
|
270
|
+
guildId: null,
|
|
271
|
+
createdAt: dbWt.created_at,
|
|
272
|
+
source: 'kimaki',
|
|
273
|
+
dbStatus,
|
|
274
|
+
locked: false,
|
|
275
|
+
prunable: false,
|
|
276
|
+
};
|
|
187
277
|
});
|
|
278
|
+
return [...gitRows, ...dbOnlyRows];
|
|
188
279
|
}
|
|
189
280
|
function getWorktreesActionOwnerKey({ userId, channelId, }) {
|
|
190
281
|
return `worktrees:${userId}:${channelId}`;
|
|
@@ -203,9 +294,21 @@ function isProjectChannel(channel) {
|
|
|
203
294
|
async function renderWorktreesReply({ guildId, userId, channelId, projectDirectory, notice, editReply, }) {
|
|
204
295
|
const ownerKey = getWorktreesActionOwnerKey({ userId, channelId });
|
|
205
296
|
cancelHtmlActionsForOwner(ownerKey);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
297
|
+
const gitWorktrees = await listGitWorktrees({
|
|
298
|
+
projectDirectory,
|
|
299
|
+
timeout: GIT_CMD_TIMEOUT,
|
|
300
|
+
});
|
|
301
|
+
// On git failure, fall back to empty list (DB-only rows still shown)
|
|
302
|
+
const gitList = gitWorktrees instanceof Error ? [] : gitWorktrees;
|
|
303
|
+
const rows = await buildWorktreeRows({ projectDirectory, gitWorktrees: gitList });
|
|
304
|
+
// Inject guildId into all rows for thread link rendering
|
|
305
|
+
for (const row of rows) {
|
|
306
|
+
row.guildId = guildId;
|
|
307
|
+
}
|
|
308
|
+
if (rows.length === 0) {
|
|
309
|
+
const message = notice
|
|
310
|
+
? `${notice}\n\nNo worktrees found.`
|
|
311
|
+
: 'No worktrees found.';
|
|
209
312
|
const textDisplay = {
|
|
210
313
|
type: ComponentType.TextDisplay,
|
|
211
314
|
content: message,
|
|
@@ -217,36 +320,40 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
217
320
|
return;
|
|
218
321
|
}
|
|
219
322
|
const gitStatuses = await resolveGitStatuses({
|
|
220
|
-
|
|
323
|
+
rows,
|
|
324
|
+
projectDirectory,
|
|
221
325
|
timeout: GLOBAL_TIMEOUT,
|
|
222
326
|
});
|
|
223
|
-
|
|
224
|
-
|
|
327
|
+
// Map deletable worktrees by button ID for the HTML action resolver.
|
|
328
|
+
// Uses the same worktreeButtonKey() as buildActionCell.
|
|
329
|
+
const deletableRowsByButtonId = new Map();
|
|
330
|
+
rows.forEach((row, index) => {
|
|
225
331
|
const gitStatus = gitStatuses[index] ?? null;
|
|
226
|
-
if (!canDeleteWorktree({
|
|
332
|
+
if (!canDeleteWorktree({ row, gitStatus })) {
|
|
227
333
|
return;
|
|
228
334
|
}
|
|
229
|
-
|
|
335
|
+
deletableRowsByButtonId.set(`del-wt-${worktreeButtonKey(row.directory)}`, row);
|
|
230
336
|
});
|
|
231
337
|
const tableMarkdown = buildWorktreeTable({
|
|
232
|
-
|
|
338
|
+
rows,
|
|
233
339
|
gitStatuses,
|
|
234
340
|
guildId,
|
|
235
341
|
});
|
|
236
342
|
const markdown = notice ? `${notice}\n\n${tableMarkdown}` : tableMarkdown;
|
|
237
343
|
const segments = splitTablesFromMarkdown(markdown, {
|
|
238
344
|
resolveButtonCustomId: ({ button }) => {
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
345
|
+
const row = deletableRowsByButtonId.get(button.id);
|
|
346
|
+
if (!row) {
|
|
241
347
|
return new Error(`No worktree registered for button ${button.id}`);
|
|
242
348
|
}
|
|
243
349
|
const actionId = registerHtmlAction({
|
|
244
350
|
ownerKey,
|
|
245
|
-
threadId:
|
|
351
|
+
threadId: row.threadId ?? row.directory,
|
|
246
352
|
run: async ({ interaction }) => {
|
|
247
353
|
await handleDeleteWorktreeAction({
|
|
248
354
|
interaction,
|
|
249
|
-
|
|
355
|
+
row,
|
|
356
|
+
projectDirectory,
|
|
250
357
|
});
|
|
251
358
|
},
|
|
252
359
|
});
|
|
@@ -268,7 +375,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
268
375
|
flags: MessageFlags.IsComponentsV2,
|
|
269
376
|
});
|
|
270
377
|
}
|
|
271
|
-
async function handleDeleteWorktreeAction({ interaction,
|
|
378
|
+
async function handleDeleteWorktreeAction({ interaction, row, projectDirectory, }) {
|
|
272
379
|
const guildId = interaction.guildId;
|
|
273
380
|
if (!guildId) {
|
|
274
381
|
await interaction.editReply({
|
|
@@ -282,75 +389,22 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
282
389
|
});
|
|
283
390
|
return;
|
|
284
391
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
await interaction.editReply({
|
|
289
|
-
components: [
|
|
290
|
-
{
|
|
291
|
-
type: ComponentType.TextDisplay,
|
|
292
|
-
content: 'This action can only be used in a project channel or thread.',
|
|
293
|
-
},
|
|
294
|
-
],
|
|
295
|
-
flags: MessageFlags.IsComponentsV2,
|
|
296
|
-
});
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const resolved = await resolveWorkingDirectory({
|
|
300
|
-
channel: interaction.channel,
|
|
301
|
-
});
|
|
302
|
-
if (!resolved) {
|
|
303
|
-
await interaction.editReply({
|
|
304
|
-
components: [
|
|
305
|
-
{
|
|
306
|
-
type: ComponentType.TextDisplay,
|
|
307
|
-
content: 'Could not determine the project folder for this channel.',
|
|
308
|
-
},
|
|
309
|
-
],
|
|
310
|
-
flags: MessageFlags.IsComponentsV2,
|
|
311
|
-
});
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
await renderWorktreesReply({
|
|
315
|
-
guildId,
|
|
316
|
-
userId: interaction.user.id,
|
|
317
|
-
channelId: interaction.channelId,
|
|
318
|
-
projectDirectory: resolved.projectDirectory,
|
|
319
|
-
notice: 'Worktree was already removed.',
|
|
320
|
-
editReply: (options) => {
|
|
321
|
-
return interaction.editReply(options);
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
if (worktree.status !== 'ready' || !worktree.worktree_directory) {
|
|
327
|
-
await renderWorktreesReply({
|
|
328
|
-
guildId,
|
|
329
|
-
userId: interaction.user.id,
|
|
330
|
-
channelId: interaction.channelId,
|
|
331
|
-
projectDirectory: worktree.project_directory,
|
|
332
|
-
notice: `Cannot delete \`${worktree.worktree_name}\` because it is ${worktree.status}.`,
|
|
333
|
-
editReply: (options) => {
|
|
334
|
-
return interaction.editReply(options);
|
|
335
|
-
},
|
|
336
|
-
});
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
392
|
+
// Pass branch name for branch cleanup. Empty string for detached HEAD
|
|
393
|
+
// worktrees so deleteWorktree skips the `git branch -d` step.
|
|
394
|
+
const displayName = row.branch ?? row.name;
|
|
339
395
|
const deleteResult = await deleteWorktree({
|
|
340
|
-
projectDirectory
|
|
341
|
-
worktreeDirectory:
|
|
342
|
-
worktreeName:
|
|
396
|
+
projectDirectory,
|
|
397
|
+
worktreeDirectory: row.directory,
|
|
398
|
+
worktreeName: row.branch ?? '',
|
|
343
399
|
});
|
|
344
400
|
if (deleteResult instanceof Error) {
|
|
345
|
-
// Send error as a separate ephemeral follow-up so the table stays intact.
|
|
346
|
-
// Dig into cause chain to surface the actual git stderr when available.
|
|
347
401
|
const gitStderr = extractGitStderr(deleteResult);
|
|
348
402
|
const detail = gitStderr
|
|
349
403
|
? `\`\`\`\n${gitStderr}\n\`\`\``
|
|
350
404
|
: deleteResult.message;
|
|
351
405
|
await interaction
|
|
352
406
|
.followUp({
|
|
353
|
-
content: `Failed to delete \`${
|
|
407
|
+
content: `Failed to delete \`${displayName}\`\n${detail}`,
|
|
354
408
|
flags: MessageFlags.Ephemeral,
|
|
355
409
|
})
|
|
356
410
|
.catch(() => {
|
|
@@ -358,13 +412,16 @@ async function handleDeleteWorktreeAction({ interaction, threadId, }) {
|
|
|
358
412
|
});
|
|
359
413
|
return;
|
|
360
414
|
}
|
|
361
|
-
|
|
415
|
+
// Clean up DB row if this was a kimaki-tracked worktree
|
|
416
|
+
if (row.threadId) {
|
|
417
|
+
await deleteThreadWorktree(row.threadId);
|
|
418
|
+
}
|
|
362
419
|
await renderWorktreesReply({
|
|
363
420
|
guildId,
|
|
364
421
|
userId: interaction.user.id,
|
|
365
422
|
channelId: interaction.channelId,
|
|
366
|
-
projectDirectory
|
|
367
|
-
notice: `Deleted \`${
|
|
423
|
+
projectDirectory,
|
|
424
|
+
notice: `Deleted \`${displayName}\`.`,
|
|
368
425
|
editReply: (options) => {
|
|
369
426
|
return interaction.editReply(options);
|
|
370
427
|
},
|
|
@@ -32,8 +32,10 @@ export function shouldInjectBranch({ previousGitState, currentGitState, }) {
|
|
|
32
32
|
if (previousGitState && previousGitState.key === currentGitState.key) {
|
|
33
33
|
return { inject: false };
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Trailing newline so this synthetic part does not fuse with the next text
|
|
36
|
+
// part when the model concatenates message parts.
|
|
37
|
+
const base = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
|
|
38
|
+
return { inject: true, text: `${base}\n` };
|
|
37
39
|
}
|
|
38
40
|
export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
39
41
|
if (announcedDir === currentDir) {
|
|
@@ -45,10 +47,16 @@ export function shouldInjectPwd({ currentDir, previousDir, announcedDir, }) {
|
|
|
45
47
|
}
|
|
46
48
|
return {
|
|
47
49
|
inject: true,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`
|
|
50
|
+
// Trailing newline so this synthetic part does not fuse with the next text
|
|
51
|
+
// part when the model concatenates message parts.
|
|
52
|
+
text: `\n[working directory changed (cwd / pwd has changed). ` +
|
|
53
|
+
`The user expects you to edit files in the new cwd. ` +
|
|
54
|
+
`Previous folder (DO NOT TOUCH): ${priorDirectory}. ` +
|
|
55
|
+
`New folder (new cwd / pwd, edit files here): ${currentDir}. ` +
|
|
56
|
+
`You MUST read, write, and edit files only under the new folder ${currentDir}. ` +
|
|
57
|
+
`You MUST NOT read, write, or edit any files under the previous folder ${priorDirectory} — ` +
|
|
58
|
+
`that folder is a separate checkout and the user or another agent may be actively working there, ` +
|
|
59
|
+
`so writing to it would override their unrelated changes.]\n`,
|
|
52
60
|
};
|
|
53
61
|
}
|
|
54
62
|
const MEMORY_REMINDER_OUTPUT_TOKENS = 12_000;
|
|
@@ -198,7 +206,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
198
206
|
sessionID,
|
|
199
207
|
messageID: firstTextPart.messageID,
|
|
200
208
|
type: 'text',
|
|
201
|
-
text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder
|
|
209
|
+
text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>\n`,
|
|
202
210
|
synthetic: true,
|
|
203
211
|
});
|
|
204
212
|
}
|
|
@@ -273,7 +281,7 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
|
|
|
273
281
|
sessionID,
|
|
274
282
|
messageID,
|
|
275
283
|
type: 'text',
|
|
276
|
-
text: '<system-reminder>The previous assistant message was large. If the conversation had non-obvious learnings that prevent future mistakes and are not already in code comments or AGENTS.md, add them to MEMORY.md with concise titles and brief content (2-3 sentences max).</system-reminder
|
|
284
|
+
text: '<system-reminder>The previous assistant message was large. If the conversation had non-obvious learnings that prevent future mistakes and are not already in code comments or AGENTS.md, add them to MEMORY.md with concise titles and brief content (2-3 sentences max).</system-reminder>\n',
|
|
277
285
|
synthetic: true,
|
|
278
286
|
});
|
|
279
287
|
state.lastMemoryReminderAssistantMessageId =
|
|
@@ -36,7 +36,8 @@ describe('shouldInjectPwd', () => {
|
|
|
36
36
|
{
|
|
37
37
|
"inject": true,
|
|
38
38
|
"text": "
|
|
39
|
-
[working directory changed. Previous
|
|
39
|
+
[working directory changed (cwd / pwd has changed). The user expects you to edit files in the new cwd. Previous folder (DO NOT TOUCH): /repo/main. New folder (new cwd / pwd, edit files here): /repo/worktree. You MUST read, write, and edit files only under the new folder /repo/worktree. You MUST NOT read, write, or edit any files under the previous folder /repo/main — that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes.]
|
|
40
|
+
",
|
|
40
41
|
}
|
|
41
42
|
`);
|
|
42
43
|
});
|
|
@@ -50,7 +51,8 @@ describe('shouldInjectPwd', () => {
|
|
|
50
51
|
{
|
|
51
52
|
"inject": true,
|
|
52
53
|
"text": "
|
|
53
|
-
[working directory changed. Previous
|
|
54
|
+
[working directory changed (cwd / pwd has changed). The user expects you to edit files in the new cwd. Previous folder (DO NOT TOUCH): /repo/worktree-a. New folder (new cwd / pwd, edit files here): /repo/worktree-b. You MUST read, write, and edit files only under the new folder /repo/worktree-b. You MUST NOT read, write, or edit any files under the previous folder /repo/worktree-a — that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes.]
|
|
55
|
+
",
|
|
54
56
|
}
|
|
55
57
|
`);
|
|
56
58
|
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
4
|
import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
|
|
5
5
|
import { stopOpencodeServer, } from './opencode.js';
|
|
6
|
-
import {
|
|
6
|
+
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
7
|
import { validateWorktreeDirectory, git } from './worktrees.js';
|
|
8
8
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
9
9
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
10
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
13
|
+
import { extractBtwPrefix } from './btw-prefix-detection.js';
|
|
13
14
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
15
|
+
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
14
16
|
import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
15
17
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
16
18
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
@@ -430,6 +432,35 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
430
432
|
return;
|
|
431
433
|
}
|
|
432
434
|
}
|
|
435
|
+
// Raw `btw ` mirrors /btw for fast side-question forks from Discord.
|
|
436
|
+
// Keep this at ingress instead of preprocess because it must create a
|
|
437
|
+
// new thread/runtime, not just transform the current prompt.
|
|
438
|
+
// Voice-transcribed `btw` still goes through normal preprocessing.
|
|
439
|
+
const btwShortcut = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
440
|
+
? extractBtwPrefix(message.content || '')
|
|
441
|
+
: null;
|
|
442
|
+
if (btwShortcut && projectDirectory) {
|
|
443
|
+
const result = await forkSessionToBtwThread({
|
|
444
|
+
sourceThread: thread,
|
|
445
|
+
projectDirectory,
|
|
446
|
+
prompt: btwShortcut.prompt,
|
|
447
|
+
userId: message.author.id,
|
|
448
|
+
username: message.member?.displayName || message.author.displayName,
|
|
449
|
+
appId: currentAppId,
|
|
450
|
+
});
|
|
451
|
+
if (result instanceof Error) {
|
|
452
|
+
await message.reply({
|
|
453
|
+
content: result.message,
|
|
454
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
await message.reply({
|
|
459
|
+
content: `Session forked! Continue in ${result.thread.toString()}`,
|
|
460
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
433
464
|
const hasVoiceAttachment = message.attachments.some((attachment) => {
|
|
434
465
|
return isVoiceAttachment(attachment);
|
|
435
466
|
});
|
|
@@ -592,7 +623,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
592
623
|
// and the first message's preprocess callback awaits it before resolving.
|
|
593
624
|
let worktreePromise;
|
|
594
625
|
if (shouldUseWorktrees) {
|
|
595
|
-
|
|
626
|
+
// Auto-derived from thread name -- compress long slugs so the
|
|
627
|
+
// folder path stays short and the agent doesn't reuse old worktrees.
|
|
628
|
+
const worktreeName = formatAutoWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
|
|
596
629
|
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
597
630
|
const worktreeStatusMessage = await thread
|
|
598
631
|
.send({
|
|
@@ -110,7 +110,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
110
110
|
.toJSON(),
|
|
111
111
|
new SlashCommandBuilder()
|
|
112
112
|
.setName('new-worktree')
|
|
113
|
-
.setDescription(truncateCommandDescription('Create a git worktree
|
|
113
|
+
.setDescription(truncateCommandDescription('Create a git worktree from the current HEAD by default. Optionally pick a base branch.'))
|
|
114
114
|
.addStringOption((option) => {
|
|
115
115
|
option
|
|
116
116
|
.setName('name')
|
|
@@ -121,7 +121,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
121
121
|
.addStringOption((option) => {
|
|
122
122
|
option
|
|
123
123
|
.setName('base-branch')
|
|
124
|
-
.setDescription(truncateCommandDescription('Branch to create the worktree from (default: HEAD)'))
|
|
124
|
+
.setDescription(truncateCommandDescription('Branch to create the worktree from (default: current HEAD)'))
|
|
125
125
|
.setRequired(false)
|
|
126
126
|
.setAutocomplete(true);
|
|
127
127
|
return option;
|
|
@@ -276,6 +276,13 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
276
276
|
new SlashCommandBuilder()
|
|
277
277
|
.setName('clear-queue')
|
|
278
278
|
.setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
|
|
279
|
+
.addIntegerOption((option) => {
|
|
280
|
+
option
|
|
281
|
+
.setName('position')
|
|
282
|
+
.setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
|
|
283
|
+
.setMinValue(1);
|
|
284
|
+
return option;
|
|
285
|
+
})
|
|
279
286
|
.setDMPermission(false)
|
|
280
287
|
.toJSON(),
|
|
281
288
|
new SlashCommandBuilder()
|