@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
|
@@ -742,6 +742,7 @@ describe('agent model resolution', () => {
|
|
|
742
742
|
⬥ ok
|
|
743
743
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
744
744
|
Switched to **plan** agent for this session (was **test-agent**)
|
|
745
|
+
Model: *deterministic-provider/plan-model-v2*
|
|
745
746
|
The agent will change on the next message.
|
|
746
747
|
--- from: user (agent-model-tester)
|
|
747
748
|
Reply with exactly: after-switch-msg
|
|
@@ -460,6 +460,20 @@ function buildAuthorizeHandler(mode) {
|
|
|
460
460
|
function toClaudeCodeToolName(name) {
|
|
461
461
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Strips the OpenCode identity block (from "You are OpenCode…" up to the
|
|
465
|
+
* Anthropic prompt marker "Skills provide specialized instructions") and
|
|
466
|
+
* re-injects essential environment context as a small XML tag.
|
|
467
|
+
*
|
|
468
|
+
* The original OpenCode prompt between those markers contains the current
|
|
469
|
+
* working directory and other runtime context. Stripping it wholesale loses
|
|
470
|
+
* that info, so we add back what the model needs (cwd) in a compact form.
|
|
471
|
+
*
|
|
472
|
+
* Original OpenCode Anthropic prompt structure (for reference):
|
|
473
|
+
* "You are OpenCode, the best coding agent on the planet."
|
|
474
|
+
* + environment block (cwd, OS, shell, date, etc.)
|
|
475
|
+
* + "Skills provide specialized instructions …"
|
|
476
|
+
*/
|
|
463
477
|
function sanitizeAnthropicSystemText(text, onError) {
|
|
464
478
|
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
465
479
|
if (startIdx === -1)
|
|
@@ -470,7 +484,14 @@ function sanitizeAnthropicSystemText(text, onError) {
|
|
|
470
484
|
onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
|
|
471
485
|
return text;
|
|
472
486
|
}
|
|
473
|
-
|
|
487
|
+
// Re-inject the process working directory that was inside the stripped block.
|
|
488
|
+
const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n`;
|
|
489
|
+
// Replace all case-insensitive whole-word occurrences of "opencode" with "openc0de"
|
|
490
|
+
const result = text.slice(0, startIdx) +
|
|
491
|
+
envContext +
|
|
492
|
+
text.slice(endIdx);
|
|
493
|
+
// Use a regex with global, case-insensitive, and word boundary flags
|
|
494
|
+
return result.replace(/\bopencode\b/gi, "openc0de");
|
|
474
495
|
}
|
|
475
496
|
function mapSystemTextPart(part, onError) {
|
|
476
497
|
if (typeof part === "string") {
|
|
@@ -186,6 +186,37 @@ async function writeAnthropicAuthFile(auth) {
|
|
|
186
186
|
}
|
|
187
187
|
await writeJson(file, data);
|
|
188
188
|
}
|
|
189
|
+
function isOAuthStored(value) {
|
|
190
|
+
if (!value || typeof value !== 'object') {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const record = value;
|
|
194
|
+
return (record.type === 'oauth' &&
|
|
195
|
+
typeof record.refresh === 'string' &&
|
|
196
|
+
typeof record.access === 'string' &&
|
|
197
|
+
typeof record.expires === 'number');
|
|
198
|
+
}
|
|
199
|
+
export async function getCurrentAnthropicAccount() {
|
|
200
|
+
const authJson = await readJson(authFilePath(), {});
|
|
201
|
+
const auth = authJson.anthropic;
|
|
202
|
+
if (!isOAuthStored(auth)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const store = await loadAccountStore();
|
|
206
|
+
const index = findCurrentAccountIndex(store, auth);
|
|
207
|
+
const account = store.accounts[index];
|
|
208
|
+
if (!account) {
|
|
209
|
+
return { auth };
|
|
210
|
+
}
|
|
211
|
+
if (account.refresh !== auth.refresh && account.access !== auth.access) {
|
|
212
|
+
return { auth };
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
auth,
|
|
216
|
+
account,
|
|
217
|
+
index,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
189
220
|
export async function setAnthropicAuth(auth, client) {
|
|
190
221
|
await writeAnthropicAuthFile(auth);
|
|
191
222
|
await client.auth.set({ path: { id: 'anthropic' }, body: auth });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Detects the raw `btw ` Discord message shortcut used to fork a side-question
|
|
2
|
+
// thread without invoking the /btw slash command UI.
|
|
3
|
+
export function extractBtwPrefix(content) {
|
|
4
|
+
if (!content) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
// Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
|
|
8
|
+
const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
|
|
9
|
+
if (!match) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const prompt = match[1]?.trim();
|
|
13
|
+
if (!prompt) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return { prompt };
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { extractBtwPrefix } from './btw-prefix-detection.js';
|
|
3
|
+
describe('extractBtwPrefix', () => {
|
|
4
|
+
test('matches lowercase prefix', () => {
|
|
5
|
+
expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
|
|
6
|
+
{
|
|
7
|
+
"prompt": "fix this",
|
|
8
|
+
}
|
|
9
|
+
`);
|
|
10
|
+
});
|
|
11
|
+
test('matches uppercase prefix', () => {
|
|
12
|
+
expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
|
|
13
|
+
{
|
|
14
|
+
"prompt": "check this",
|
|
15
|
+
}
|
|
16
|
+
`);
|
|
17
|
+
});
|
|
18
|
+
test('keeps multiline content', () => {
|
|
19
|
+
expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
|
|
20
|
+
{
|
|
21
|
+
"prompt": "first line
|
|
22
|
+
second line",
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
});
|
|
26
|
+
test('matches dot separator', () => {
|
|
27
|
+
expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
|
|
28
|
+
{
|
|
29
|
+
"prompt": "fix this",
|
|
30
|
+
}
|
|
31
|
+
`);
|
|
32
|
+
});
|
|
33
|
+
test('matches comma separator', () => {
|
|
34
|
+
expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
|
|
35
|
+
{
|
|
36
|
+
"prompt": "fix this",
|
|
37
|
+
}
|
|
38
|
+
`);
|
|
39
|
+
});
|
|
40
|
+
test('matches colon separator', () => {
|
|
41
|
+
expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
|
|
42
|
+
{
|
|
43
|
+
"prompt": "fix this",
|
|
44
|
+
}
|
|
45
|
+
`);
|
|
46
|
+
});
|
|
47
|
+
test('matches punctuation without trailing space', () => {
|
|
48
|
+
expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
|
|
49
|
+
{
|
|
50
|
+
"prompt": "fix this",
|
|
51
|
+
}
|
|
52
|
+
`);
|
|
53
|
+
});
|
|
54
|
+
test('does not match without separating whitespace', () => {
|
|
55
|
+
expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`);
|
|
56
|
+
});
|
|
57
|
+
test('does not match mid-message', () => {
|
|
58
|
+
expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`);
|
|
59
|
+
});
|
|
60
|
+
test('does not match empty payload', () => {
|
|
61
|
+
expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChan
|
|
|
10
10
|
import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, } from './database.js';
|
|
11
11
|
import { ShareMarkdown } from './markdown.js';
|
|
12
12
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
|
|
13
|
-
import { formatWorktreeName } from './commands/new-worktree.js';
|
|
13
|
+
import { formatWorktreeName, formatAutoWorktreeName } from './commands/new-worktree.js';
|
|
14
14
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
15
15
|
import { sendWelcomeMessage } from './onboarding-welcome.js';
|
|
16
16
|
import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
|
|
@@ -21,6 +21,7 @@ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxy
|
|
|
21
21
|
import crypto from 'node:crypto';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import fs from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
24
25
|
import * as errore from 'errore';
|
|
25
26
|
import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js';
|
|
26
27
|
import { initSentry, notifyError } from './sentry.js';
|
|
@@ -32,7 +33,7 @@ import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.
|
|
|
32
33
|
import { startHranaServer } from './hrana-server.js';
|
|
33
34
|
import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
|
|
34
35
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
|
|
35
|
-
import { accountLabel, accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
36
|
+
import { accountLabel, accountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
|
|
36
37
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
37
38
|
// Gateway bot mode constants.
|
|
38
39
|
// KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
|
|
@@ -1287,6 +1288,14 @@ cli
|
|
|
1287
1288
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
1288
1289
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
1289
1290
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
1291
|
+
.option('--enable-skill <name>', z
|
|
1292
|
+
.array(z.string())
|
|
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/cli/skills for available skills.'))
|
|
1295
|
+
.option('--disable-skill <name>', z
|
|
1296
|
+
.array(z.string())
|
|
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/cli/skills for available skills.'))
|
|
1290
1299
|
.action(async (options) => {
|
|
1291
1300
|
// Guard: only one kimaki bot process can run at a time (they share a lock
|
|
1292
1301
|
// port). Running `kimaki` here would kill the already-running bot process
|
|
@@ -1321,13 +1330,54 @@ cli
|
|
|
1321
1330
|
process.exit(EXIT_NO_RESTART);
|
|
1322
1331
|
}
|
|
1323
1332
|
}
|
|
1333
|
+
// --enable-skill and --disable-skill are mutually exclusive: the user
|
|
1334
|
+
// either whitelists a small allowlist or blacklists a few unwanted
|
|
1335
|
+
// skills, never both. Applied later in opencode.ts as permission.skill
|
|
1336
|
+
// rules via computeSkillPermission().
|
|
1337
|
+
const enabledSkills = options.enableSkill ?? [];
|
|
1338
|
+
const disabledSkills = options.disableSkill ?? [];
|
|
1339
|
+
if (enabledSkills.length > 0 && disabledSkills.length > 0) {
|
|
1340
|
+
cliLogger.error('Cannot use --enable-skill and --disable-skill at the same time. Use one or the other.');
|
|
1341
|
+
process.exit(EXIT_NO_RESTART);
|
|
1342
|
+
}
|
|
1343
|
+
// Soft-validate skill names against the bundled skills/ folder. Users
|
|
1344
|
+
// may rely on skills loaded from their own .opencode / .claude / .agents
|
|
1345
|
+
// dirs, so unknown names only emit a warning rather than hard-failing.
|
|
1346
|
+
if (enabledSkills.length > 0 || disabledSkills.length > 0) {
|
|
1347
|
+
const bundledSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
1348
|
+
const availableBundledSkills = (() => {
|
|
1349
|
+
try {
|
|
1350
|
+
return fs
|
|
1351
|
+
.readdirSync(bundledSkillsDir, { withFileTypes: true })
|
|
1352
|
+
.filter((entry) => entry.isDirectory())
|
|
1353
|
+
.map((entry) => entry.name);
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return [];
|
|
1357
|
+
}
|
|
1358
|
+
})();
|
|
1359
|
+
const availableSet = new Set(availableBundledSkills);
|
|
1360
|
+
for (const name of [...enabledSkills, ...disabledSkills]) {
|
|
1361
|
+
if (!availableSet.has(name)) {
|
|
1362
|
+
cliLogger.warn(`Skill "${name}" is not a bundled kimaki skill. Rule will still apply (user-provided skills from .opencode/.claude/.agents dirs may match). Available bundled skills: ${availableBundledSkills.join(', ')}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1324
1366
|
store.setState({
|
|
1325
1367
|
...(options.verbosity && {
|
|
1326
1368
|
defaultVerbosity: options.verbosity,
|
|
1327
1369
|
}),
|
|
1328
1370
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
1329
1371
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
1372
|
+
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
1373
|
+
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
1330
1374
|
});
|
|
1375
|
+
if (enabledSkills.length > 0) {
|
|
1376
|
+
cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
|
|
1377
|
+
}
|
|
1378
|
+
if (disabledSkills.length > 0) {
|
|
1379
|
+
cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
|
|
1380
|
+
}
|
|
1331
1381
|
if (options.verbosity) {
|
|
1332
1382
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
1333
1383
|
}
|
|
@@ -1637,7 +1687,12 @@ cli
|
|
|
1637
1687
|
.option('--wait', 'Wait for session to complete, then print session text to stdout')
|
|
1638
1688
|
.action(async (options) => {
|
|
1639
1689
|
try {
|
|
1640
|
-
|
|
1690
|
+
// `--name` / `--app-id` are optional-value flags: `undefined` when
|
|
1691
|
+
// omitted, `''` when passed bare, a real string when given a value.
|
|
1692
|
+
// `||` collapses `''` to `undefined` for downstream consumers.
|
|
1693
|
+
const optionAppId = options.appId || undefined;
|
|
1694
|
+
let { channel: channelId, prompt, notifyOnly, thread: threadId, session: sessionId, } = options;
|
|
1695
|
+
let name = options.name || undefined;
|
|
1641
1696
|
const { project: projectPath } = options;
|
|
1642
1697
|
const sendAt = options.sendAt;
|
|
1643
1698
|
const existingThreadMode = Boolean(threadId || sessionId);
|
|
@@ -1981,10 +2036,12 @@ cli
|
|
|
1981
2036
|
(cleanPrompt.length > 80
|
|
1982
2037
|
? cleanPrompt.slice(0, 77) + '...'
|
|
1983
2038
|
: cleanPrompt);
|
|
2039
|
+
// Explicit string => use as-is via formatWorktreeName (no vowel strip).
|
|
2040
|
+
// Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
|
|
1984
2041
|
const worktreeName = options.worktree
|
|
1985
|
-
?
|
|
1986
|
-
? options.worktree
|
|
1987
|
-
: baseThreadName)
|
|
2042
|
+
? typeof options.worktree === 'string'
|
|
2043
|
+
? formatWorktreeName(options.worktree)
|
|
2044
|
+
: formatAutoWorktreeName(baseThreadName)
|
|
1988
2045
|
: undefined;
|
|
1989
2046
|
const threadName = worktreeName
|
|
1990
2047
|
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
@@ -2238,6 +2295,33 @@ cli
|
|
|
2238
2295
|
});
|
|
2239
2296
|
process.exit(0);
|
|
2240
2297
|
});
|
|
2298
|
+
cli
|
|
2299
|
+
.command('anthropic-accounts current', 'Show the current Anthropic OAuth account being used, if any')
|
|
2300
|
+
.action(async () => {
|
|
2301
|
+
const current = await getCurrentAnthropicAccount();
|
|
2302
|
+
console.log(`Store: ${accountsFilePath()}`);
|
|
2303
|
+
console.log(`Auth: ${authFilePath()}`);
|
|
2304
|
+
if (!current) {
|
|
2305
|
+
console.log('No active Anthropic OAuth account configured.');
|
|
2306
|
+
process.exit(0);
|
|
2307
|
+
}
|
|
2308
|
+
const lines = [];
|
|
2309
|
+
lines.push(`Current: ${accountLabel(current.account || current.auth, current.index)}`);
|
|
2310
|
+
if (current.account?.email) {
|
|
2311
|
+
lines.push(`Email: ${current.account.email}`);
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
lines.push('Email: unavailable');
|
|
2315
|
+
}
|
|
2316
|
+
if (current.account?.accountId) {
|
|
2317
|
+
lines.push(`Account ID: ${current.account.accountId}`);
|
|
2318
|
+
}
|
|
2319
|
+
if (!current.account) {
|
|
2320
|
+
lines.push('Rotation pool entry: not found');
|
|
2321
|
+
}
|
|
2322
|
+
console.log(lines.join('\n'));
|
|
2323
|
+
process.exit(0);
|
|
2324
|
+
});
|
|
2241
2325
|
cli
|
|
2242
2326
|
.command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
|
|
2243
2327
|
.action(async (indexOrEmail) => {
|
|
@@ -2633,13 +2717,15 @@ cli
|
|
|
2633
2717
|
process.exit(EXIT_NO_RESTART);
|
|
2634
2718
|
}
|
|
2635
2719
|
const guildId = String(options.guild);
|
|
2720
|
+
// Bare `--query` comes through as `''`; collapse it to undefined
|
|
2721
|
+
const query = options.query || undefined;
|
|
2636
2722
|
await initDatabase();
|
|
2637
2723
|
const { token: botToken } = await resolveBotCredentials();
|
|
2638
2724
|
const rest = createDiscordRest(botToken);
|
|
2639
2725
|
const members = await (async () => {
|
|
2640
|
-
if (
|
|
2726
|
+
if (query) {
|
|
2641
2727
|
return (await rest.get(Routes.guildMembersSearch(guildId), {
|
|
2642
|
-
query: new URLSearchParams({ query
|
|
2728
|
+
query: new URLSearchParams({ query, limit: '20' }),
|
|
2643
2729
|
}));
|
|
2644
2730
|
}
|
|
2645
2731
|
return (await rest.get(Routes.guildMembers(guildId), {
|
|
@@ -2647,8 +2733,8 @@ cli
|
|
|
2647
2733
|
}));
|
|
2648
2734
|
})();
|
|
2649
2735
|
if (members.length === 0) {
|
|
2650
|
-
const msg =
|
|
2651
|
-
? `No users found matching "${
|
|
2736
|
+
const msg = query
|
|
2737
|
+
? `No users found matching "${query}"`
|
|
2652
2738
|
: 'No users found in guild';
|
|
2653
2739
|
cliLogger.log(msg);
|
|
2654
2740
|
process.exit(0);
|
|
@@ -2659,8 +2745,8 @@ cli
|
|
|
2659
2745
|
return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
|
|
2660
2746
|
})
|
|
2661
2747
|
.join('\n');
|
|
2662
|
-
const header =
|
|
2663
|
-
? `Found ${members.length} users matching "${
|
|
2748
|
+
const header = query
|
|
2749
|
+
? `Found ${members.length} users matching "${query}":`
|
|
2664
2750
|
: `Found ${members.length} users:`;
|
|
2665
2751
|
console.log(`${header}\n${userList}`);
|
|
2666
2752
|
process.exit(0);
|
|
@@ -2693,10 +2779,10 @@ cli
|
|
|
2693
2779
|
const { command } = parseCommandFromArgv(process.argv);
|
|
2694
2780
|
await runTunnel({
|
|
2695
2781
|
port,
|
|
2696
|
-
tunnelId: options.tunnelId,
|
|
2697
|
-
localHost: options.host,
|
|
2782
|
+
tunnelId: options.tunnelId || undefined,
|
|
2783
|
+
localHost: options.host || undefined,
|
|
2698
2784
|
baseDomain: 'kimaki.dev',
|
|
2699
|
-
serverUrl: options.server,
|
|
2785
|
+
serverUrl: options.server || undefined,
|
|
2700
2786
|
command: command.length > 0 ? command : undefined,
|
|
2701
2787
|
kill: options.kill,
|
|
2702
2788
|
});
|
package/dist/commands/agent.js
CHANGED
|
@@ -6,6 +6,7 @@ import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession,
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
8
8
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
|
+
import { getCurrentModelInfo } from './model.js';
|
|
9
10
|
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
10
11
|
const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
11
12
|
const pendingAgentContexts = new Map();
|
|
@@ -315,14 +316,32 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
315
316
|
const previousText = previousAgentName
|
|
316
317
|
? ` (was **${previousAgentName}**)`
|
|
317
318
|
: '';
|
|
319
|
+
// Resolve the model that will now be used for the new agent so we can
|
|
320
|
+
// show it in the reply. setAgentForContext already cleared any session
|
|
321
|
+
// model preference, so getCurrentModelInfo falls through to the agent's
|
|
322
|
+
// configured model (or channel/global/default).
|
|
323
|
+
const modelInfo = await (async () => {
|
|
324
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
325
|
+
if (getClient instanceof Error) {
|
|
326
|
+
return { type: 'none' };
|
|
327
|
+
}
|
|
328
|
+
return getCurrentModelInfo({
|
|
329
|
+
sessionId: context.sessionId,
|
|
330
|
+
channelId: context.channelId,
|
|
331
|
+
appId,
|
|
332
|
+
agentPreference: resolvedAgentName,
|
|
333
|
+
getClient,
|
|
334
|
+
});
|
|
335
|
+
})();
|
|
336
|
+
const modelText = modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`;
|
|
318
337
|
if (context.isThread && context.sessionId) {
|
|
319
338
|
await command.editReply({
|
|
320
|
-
content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
|
|
339
|
+
content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
|
|
321
340
|
});
|
|
322
341
|
}
|
|
323
342
|
else {
|
|
324
343
|
await command.editReply({
|
|
325
|
-
content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}\nAll new sessions will use this agent.`,
|
|
344
|
+
content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
|
|
326
345
|
});
|
|
327
346
|
}
|
|
328
347
|
}
|
|
@@ -11,6 +11,32 @@ const logger = createLogger(LogPrefix.ASK_QUESTION);
|
|
|
11
11
|
// TTL prevents unbounded growth if user never answers a question.
|
|
12
12
|
const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
13
13
|
export const pendingQuestionContexts = new Map();
|
|
14
|
+
export function findPendingQuestionContextForRequest({ threadId, requestId, }) {
|
|
15
|
+
for (const [contextHash, context] of pendingQuestionContexts) {
|
|
16
|
+
if (context.thread.id !== threadId) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (context.requestId !== requestId) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
return { contextHash, context };
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function deletePendingQuestionContextsForRequest({ threadId, requestId, }) {
|
|
27
|
+
const matchingContextHashes = [...pendingQuestionContexts.entries()]
|
|
28
|
+
.filter(([, context]) => {
|
|
29
|
+
return context.thread.id === threadId && context.requestId === requestId;
|
|
30
|
+
})
|
|
31
|
+
.map(([contextHash]) => {
|
|
32
|
+
return contextHash;
|
|
33
|
+
});
|
|
34
|
+
matchingContextHashes.map((contextHash) => {
|
|
35
|
+
pendingQuestionContexts.delete(contextHash);
|
|
36
|
+
return contextHash;
|
|
37
|
+
});
|
|
38
|
+
return matchingContextHashes.length;
|
|
39
|
+
}
|
|
14
40
|
export function hasPendingQuestionForThread(threadId) {
|
|
15
41
|
return [...pendingQuestionContexts.values()].some((ctx) => {
|
|
16
42
|
return ctx.thread.id === threadId;
|
|
@@ -21,6 +47,14 @@ export function hasPendingQuestionForThread(threadId) {
|
|
|
21
47
|
* Sends one message per question with the dropdown directly under the question text.
|
|
22
48
|
*/
|
|
23
49
|
export async function showAskUserQuestionDropdowns({ thread, sessionId, directory, requestId, input, silent, }) {
|
|
50
|
+
const existingPending = findPendingQuestionContextForRequest({
|
|
51
|
+
threadId: thread.id,
|
|
52
|
+
requestId,
|
|
53
|
+
});
|
|
54
|
+
if (existingPending) {
|
|
55
|
+
logger.log(`Deduped question ${requestId} for thread ${thread.id} (existing context ${existingPending.contextHash})`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
24
58
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
25
59
|
const context = {
|
|
26
60
|
sessionId,
|
|
@@ -46,7 +80,10 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
|
|
|
46
80
|
// Without this, a user clicking during the abort() await would still
|
|
47
81
|
// be accepted by handleAskQuestionSelectMenu, then abort() would
|
|
48
82
|
// kill that valid run.
|
|
49
|
-
|
|
83
|
+
deletePendingQuestionContextsForRequest({
|
|
84
|
+
threadId: ctx.thread.id,
|
|
85
|
+
requestId: ctx.requestId,
|
|
86
|
+
});
|
|
50
87
|
// Abort the session so OpenCode isn't stuck waiting for a reply
|
|
51
88
|
const client = getOpencodeClient(ctx.directory);
|
|
52
89
|
if (client) {
|
|
@@ -150,7 +187,10 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
150
187
|
if (context.answeredCount >= context.totalQuestions) {
|
|
151
188
|
// All questions answered - send result back to session
|
|
152
189
|
await submitQuestionAnswers(context);
|
|
153
|
-
|
|
190
|
+
deletePendingQuestionContextsForRequest({
|
|
191
|
+
threadId: context.thread.id,
|
|
192
|
+
requestId: context.requestId,
|
|
193
|
+
});
|
|
154
194
|
}
|
|
155
195
|
}
|
|
156
196
|
/**
|
|
@@ -245,7 +285,10 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
245
285
|
// the question without providing an answer (e.g. voice/attachment-only
|
|
246
286
|
// messages where content needs transcription before it can be an answer).
|
|
247
287
|
if (userMessage === undefined) {
|
|
248
|
-
|
|
288
|
+
deletePendingQuestionContextsForRequest({
|
|
289
|
+
threadId: context.thread.id,
|
|
290
|
+
requestId: context.requestId,
|
|
291
|
+
});
|
|
249
292
|
return 'no-pending';
|
|
250
293
|
}
|
|
251
294
|
try {
|
|
@@ -269,6 +312,9 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
269
312
|
// Caller should not consume the user message since reply failed.
|
|
270
313
|
return 'reply-failed';
|
|
271
314
|
}
|
|
272
|
-
|
|
315
|
+
deletePendingQuestionContextsForRequest({
|
|
316
|
+
threadId: context.thread.id,
|
|
317
|
+
requestId: context.requestId,
|
|
318
|
+
});
|
|
273
319
|
return 'replied';
|
|
274
320
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Tests AskUserQuestion request deduplication and cleanup helpers.
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
|
|
4
|
+
function createFakeThread() {
|
|
5
|
+
const send = vi.fn(async () => {
|
|
6
|
+
return { id: 'msg-1' };
|
|
7
|
+
});
|
|
8
|
+
return {
|
|
9
|
+
id: 'thread-1',
|
|
10
|
+
send,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
pendingQuestionContexts.clear();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
describe('ask-question', () => {
|
|
18
|
+
test('dedupes duplicate question requests for the same thread', async () => {
|
|
19
|
+
const thread = createFakeThread();
|
|
20
|
+
await showAskUserQuestionDropdowns({
|
|
21
|
+
thread,
|
|
22
|
+
sessionId: 'ses-1',
|
|
23
|
+
directory: '/project',
|
|
24
|
+
requestId: 'req-1',
|
|
25
|
+
input: {
|
|
26
|
+
questions: [{
|
|
27
|
+
question: 'Choose one',
|
|
28
|
+
header: 'Pick',
|
|
29
|
+
options: [
|
|
30
|
+
{ label: 'Alpha', description: 'A' },
|
|
31
|
+
{ label: 'Beta', description: 'B' },
|
|
32
|
+
],
|
|
33
|
+
}],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
await showAskUserQuestionDropdowns({
|
|
37
|
+
thread,
|
|
38
|
+
sessionId: 'ses-1',
|
|
39
|
+
directory: '/project',
|
|
40
|
+
requestId: 'req-1',
|
|
41
|
+
input: {
|
|
42
|
+
questions: [{
|
|
43
|
+
question: 'Choose one',
|
|
44
|
+
header: 'Pick',
|
|
45
|
+
options: [
|
|
46
|
+
{ label: 'Alpha', description: 'A' },
|
|
47
|
+
{ label: 'Beta', description: 'B' },
|
|
48
|
+
],
|
|
49
|
+
}],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
expect(thread.send).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(pendingQuestionContexts.size).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
test('removes all duplicate contexts for one request', () => {
|
|
56
|
+
const thread = createFakeThread();
|
|
57
|
+
const baseContext = {
|
|
58
|
+
sessionId: 'ses-1',
|
|
59
|
+
directory: '/project',
|
|
60
|
+
thread,
|
|
61
|
+
requestId: 'req-1',
|
|
62
|
+
questions: [{
|
|
63
|
+
question: 'Choose one',
|
|
64
|
+
header: 'Pick',
|
|
65
|
+
options: [
|
|
66
|
+
{ label: 'Alpha', description: 'A' },
|
|
67
|
+
{ label: 'Beta', description: 'B' },
|
|
68
|
+
],
|
|
69
|
+
}],
|
|
70
|
+
answers: {},
|
|
71
|
+
totalQuestions: 1,
|
|
72
|
+
answeredCount: 0,
|
|
73
|
+
contextHash: 'ctx-1',
|
|
74
|
+
};
|
|
75
|
+
pendingQuestionContexts.set('ctx-1', baseContext);
|
|
76
|
+
pendingQuestionContexts.set('ctx-2', {
|
|
77
|
+
...baseContext,
|
|
78
|
+
contextHash: 'ctx-2',
|
|
79
|
+
});
|
|
80
|
+
pendingQuestionContexts.set('ctx-3', {
|
|
81
|
+
...baseContext,
|
|
82
|
+
requestId: 'req-2',
|
|
83
|
+
contextHash: 'ctx-3',
|
|
84
|
+
});
|
|
85
|
+
const removed = deletePendingQuestionContextsForRequest({
|
|
86
|
+
threadId: thread.id,
|
|
87
|
+
requestId: 'req-1',
|
|
88
|
+
});
|
|
89
|
+
expect(removed).toBe(2);
|
|
90
|
+
expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3']);
|
|
91
|
+
});
|
|
92
|
+
});
|