kimaki 0.4.90 → 0.4.91
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 +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- package/src/worktrees.ts +152 -156
|
@@ -21,7 +21,7 @@ import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQue
|
|
|
21
21
|
import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButtonContexts, cancelPendingActionButtons, } from '../commands/action-buttons.js';
|
|
22
22
|
import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
|
|
23
23
|
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from '../commands/model.js';
|
|
24
|
-
import { getOpencodeSystemMessage, } from '../system-message.js';
|
|
24
|
+
import { getOpencodePromptContext, getOpencodeSystemMessage, } from '../system-message.js';
|
|
25
25
|
import { resolveValidatedAgentPreference } from './agent-utils.js';
|
|
26
26
|
import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
|
|
27
27
|
import { doesLatestUserTurnHaveNaturalCompletion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
|
|
@@ -36,6 +36,7 @@ import { notifyError } from '../sentry.js';
|
|
|
36
36
|
import { createDebouncedProcessFlush } from '../debounced-process-flush.js';
|
|
37
37
|
import { cancelHtmlActionsForThread } from '../html-actions.js';
|
|
38
38
|
import { createDebouncedTimeout } from '../debounce-timeout.js';
|
|
39
|
+
import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js';
|
|
39
40
|
const logger = createLogger(LogPrefix.SESSION);
|
|
40
41
|
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
41
42
|
const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
|
|
@@ -226,6 +227,58 @@ export function isEssentialToolPart(part) {
|
|
|
226
227
|
}
|
|
227
228
|
return true;
|
|
228
229
|
}
|
|
230
|
+
// ── Thread title derivation ──────────────────────────────────────
|
|
231
|
+
const DISCORD_THREAD_NAME_MAX = 100;
|
|
232
|
+
const WORKTREE_THREAD_PREFIX = '⬦ ';
|
|
233
|
+
// Pure derivation: given an OpenCode session title and the current thread name,
|
|
234
|
+
// return the new thread name to apply, or undefined when no rename is needed.
|
|
235
|
+
// - Skips placeholder titles ("New Session - ...") to match external-sync.
|
|
236
|
+
// - Preserves worktree prefix when the current name carries it.
|
|
237
|
+
// - Returns undefined when the candidate matches currentName already.
|
|
238
|
+
export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
|
|
239
|
+
const trimmed = sessionTitle?.trim();
|
|
240
|
+
if (!trimmed) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
if (/^new session\s*-/i.test(trimmed)) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX);
|
|
247
|
+
const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : '';
|
|
248
|
+
const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
|
|
249
|
+
if (candidate === currentName) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
return candidate;
|
|
253
|
+
}
|
|
254
|
+
// Rewrite `{ prompt: "/build foo" }` → `{ prompt: "", command: { name, arguments }, mode: "local-queue" }`
|
|
255
|
+
// when the prompt's leading token matches a registered opencode command.
|
|
256
|
+
// Skip if a command is already set or there's no prompt to inspect.
|
|
257
|
+
function maybeConvertLeadingCommand(input) {
|
|
258
|
+
if (input.command)
|
|
259
|
+
return input;
|
|
260
|
+
if (!input.prompt)
|
|
261
|
+
return input;
|
|
262
|
+
const extracted = extractLeadingOpencodeCommand(input.prompt);
|
|
263
|
+
if (!extracted)
|
|
264
|
+
return input;
|
|
265
|
+
return {
|
|
266
|
+
...input,
|
|
267
|
+
prompt: '',
|
|
268
|
+
command: extracted.command,
|
|
269
|
+
mode: 'local-queue',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function getWorktreePromptKey(worktree) {
|
|
273
|
+
if (!worktree) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return [
|
|
277
|
+
worktree.worktreeDirectory,
|
|
278
|
+
worktree.branch,
|
|
279
|
+
worktree.mainRepoDirectory,
|
|
280
|
+
].join('::');
|
|
281
|
+
}
|
|
229
282
|
// ── Runtime class ────────────────────────────────────────────────
|
|
230
283
|
export class ThreadSessionRuntime {
|
|
231
284
|
threadId;
|
|
@@ -255,11 +308,19 @@ export class ThreadSessionRuntime {
|
|
|
255
308
|
// Notification throttles for retry/context notices.
|
|
256
309
|
lastDisplayedContextPercentage = 0;
|
|
257
310
|
lastRateLimitDisplayTime = 0;
|
|
311
|
+
// Last OpenCode-generated session title we successfully applied to the
|
|
312
|
+
// Discord thread name. Used to dedupe repeated session.updated events so
|
|
313
|
+
// we only call thread.setName() once per distinct title. Discord rate-limits
|
|
314
|
+
// channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
|
|
315
|
+
// retrying. Not persisted — worst case on restart we re-apply the same title
|
|
316
|
+
// once (which is a no-op via deriveThreadNameFromSessionTitle).
|
|
317
|
+
appliedOpencodeTitle;
|
|
258
318
|
// Part output buffering (write-side cache, not domain state)
|
|
259
319
|
partBuffer = new Map();
|
|
260
320
|
// Derivable cache (perf optimization for provider.list API call)
|
|
261
321
|
modelContextLimit;
|
|
262
322
|
modelContextLimitKey;
|
|
323
|
+
lastPromptWorktreeKey;
|
|
263
324
|
// Bounded buffer of recent SSE events with timestamps.
|
|
264
325
|
// Used by waitForEvent() to scan for specific events that arrived
|
|
265
326
|
// after a given point in time (e.g. wait for session.idle after abort).
|
|
@@ -312,6 +373,12 @@ export class ThreadSessionRuntime {
|
|
|
312
373
|
},
|
|
313
374
|
});
|
|
314
375
|
}
|
|
376
|
+
consumeWorktreePromptChange(worktree) {
|
|
377
|
+
const nextKey = getWorktreePromptKey(worktree);
|
|
378
|
+
const changed = this.lastPromptWorktreeKey !== nextKey;
|
|
379
|
+
this.lastPromptWorktreeKey = nextKey;
|
|
380
|
+
return changed;
|
|
381
|
+
}
|
|
315
382
|
// Read own state from global store
|
|
316
383
|
get state() {
|
|
317
384
|
return threadState.getThreadState(this.threadId);
|
|
@@ -948,6 +1015,9 @@ export class ThreadSessionRuntime {
|
|
|
948
1015
|
case 'session.status':
|
|
949
1016
|
await this.handleSessionStatus(event.properties);
|
|
950
1017
|
break;
|
|
1018
|
+
case 'session.updated':
|
|
1019
|
+
await this.handleSessionUpdated(event.properties.info);
|
|
1020
|
+
break;
|
|
951
1021
|
case 'tui.toast.show':
|
|
952
1022
|
await this.handleTuiToast(event.properties);
|
|
953
1023
|
break;
|
|
@@ -1895,6 +1965,60 @@ export class ThreadSessionRuntime {
|
|
|
1895
1965
|
discordLogger.error('Failed to send retry notice:', retryResult);
|
|
1896
1966
|
}
|
|
1897
1967
|
}
|
|
1968
|
+
// Rename the Discord thread to match the OpenCode-generated session title.
|
|
1969
|
+
//
|
|
1970
|
+
// Discord rate-limits channel/thread renames heavily — reported as ~2 per
|
|
1971
|
+
// 10 minutes per thread (discord/discord-api-docs#1900, discordjs/discord.js#6651)
|
|
1972
|
+
// and discord.js setName() can block silently on the 3rd attempt. We therefore:
|
|
1973
|
+
// - rename at most once per distinct title (deduped via appliedOpencodeTitle)
|
|
1974
|
+
// - race setName() against an AbortSignal.timeout() so a throttled call never
|
|
1975
|
+
// blocks the event loop
|
|
1976
|
+
// - fail soft (log + continue) on timeout, 429, or any other error
|
|
1977
|
+
async handleSessionUpdated(info) {
|
|
1978
|
+
// Only act on the main session for this thread
|
|
1979
|
+
if (info.id !== this.state?.sessionId) {
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
const desiredName = deriveThreadNameFromSessionTitle({
|
|
1983
|
+
sessionTitle: info.title,
|
|
1984
|
+
currentName: this.thread.name,
|
|
1985
|
+
});
|
|
1986
|
+
if (!desiredName) {
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const normalizedTitle = info.title.trim();
|
|
1990
|
+
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
// Mark before the call so concurrent session.updated events don't stack
|
|
1994
|
+
// rename attempts. On failure we keep the mark — a retry won't help
|
|
1995
|
+
// because the failure is almost always a rate limit.
|
|
1996
|
+
this.appliedOpencodeTitle = normalizedTitle;
|
|
1997
|
+
const RENAME_TIMEOUT_MS = 3000;
|
|
1998
|
+
const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS);
|
|
1999
|
+
const renameResult = await Promise.race([
|
|
2000
|
+
errore.tryAsync({
|
|
2001
|
+
try: () => this.thread.setName(desiredName),
|
|
2002
|
+
catch: (e) => new Error('Failed to rename thread from OpenCode title', {
|
|
2003
|
+
cause: e,
|
|
2004
|
+
}),
|
|
2005
|
+
}),
|
|
2006
|
+
new Promise((resolve) => {
|
|
2007
|
+
timeoutSignal.addEventListener('abort', () => {
|
|
2008
|
+
resolve('timeout');
|
|
2009
|
+
});
|
|
2010
|
+
}),
|
|
2011
|
+
]);
|
|
2012
|
+
if (renameResult === 'timeout') {
|
|
2013
|
+
logger.warn(`[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (renameResult instanceof Error) {
|
|
2017
|
+
logger.warn(`[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
logger.log(`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`);
|
|
2021
|
+
}
|
|
1898
2022
|
async handleTuiToast(properties) {
|
|
1899
2023
|
if (properties.variant === 'warning') {
|
|
1900
2024
|
return;
|
|
@@ -2069,18 +2193,7 @@ export class ThreadSessionRuntime {
|
|
|
2069
2193
|
.join('\n');
|
|
2070
2194
|
return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`;
|
|
2071
2195
|
})();
|
|
2072
|
-
|
|
2073
|
-
if (input.username) {
|
|
2074
|
-
const msgAttr = input.sourceMessageId ? ` message-id="${input.sourceMessageId}"` : '';
|
|
2075
|
-
const thrAttr = input.sourceThreadId ? ` thread-id="${input.sourceThreadId}"` : '';
|
|
2076
|
-
syntheticContext += `<discord-user name="${input.username}"${msgAttr}${thrAttr} />`;
|
|
2077
|
-
}
|
|
2078
|
-
const parts = [
|
|
2079
|
-
{ type: 'text', text: promptWithImagePaths },
|
|
2080
|
-
{ type: 'text', text: syntheticContext, synthetic: true },
|
|
2081
|
-
...images,
|
|
2082
|
-
];
|
|
2083
|
-
// ── Worktree + channel topic for system message ─────────
|
|
2196
|
+
// ── Worktree + channel topic for per-turn prompt context ──
|
|
2084
2197
|
const worktreeInfo = await getThreadWorktree(this.thread.id);
|
|
2085
2198
|
const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
2086
2199
|
? {
|
|
@@ -2107,6 +2220,22 @@ export class ThreadSessionRuntime {
|
|
|
2107
2220
|
}
|
|
2108
2221
|
return fetched.topic?.trim() || undefined;
|
|
2109
2222
|
})();
|
|
2223
|
+
const worktreeChanged = this.consumeWorktreePromptChange(worktree);
|
|
2224
|
+
const syntheticContext = getOpencodePromptContext({
|
|
2225
|
+
username: input.username,
|
|
2226
|
+
userId: input.userId,
|
|
2227
|
+
sourceMessageId: input.sourceMessageId,
|
|
2228
|
+
sourceThreadId: input.sourceThreadId,
|
|
2229
|
+
repliedMessage: input.repliedMessage,
|
|
2230
|
+
worktree,
|
|
2231
|
+
currentAgent: resolvedAgent,
|
|
2232
|
+
worktreeChanged,
|
|
2233
|
+
});
|
|
2234
|
+
const parts = [
|
|
2235
|
+
{ type: 'text', text: promptWithImagePaths },
|
|
2236
|
+
{ type: 'text', text: syntheticContext, synthetic: true },
|
|
2237
|
+
...images,
|
|
2238
|
+
];
|
|
2110
2239
|
const request = {
|
|
2111
2240
|
sessionID: session.id,
|
|
2112
2241
|
directory: this.sdkDirectory,
|
|
@@ -2116,12 +2245,9 @@ export class ThreadSessionRuntime {
|
|
|
2116
2245
|
channelId,
|
|
2117
2246
|
guildId: this.thread.guildId,
|
|
2118
2247
|
threadId: this.thread.id,
|
|
2119
|
-
worktree,
|
|
2120
2248
|
channelTopic,
|
|
2121
|
-
username: input.username,
|
|
2122
|
-
userId: input.userId,
|
|
2123
2249
|
agents: availableAgents,
|
|
2124
|
-
|
|
2250
|
+
username: this.state?.sessionUsername || input.username,
|
|
2125
2251
|
}),
|
|
2126
2252
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
2127
2253
|
...(modelField ? { model: modelField } : {}),
|
|
@@ -2184,6 +2310,7 @@ export class ThreadSessionRuntime {
|
|
|
2184
2310
|
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
2185
2311
|
sourceMessageId: input.sourceMessageId,
|
|
2186
2312
|
sourceThreadId: input.sourceThreadId,
|
|
2313
|
+
repliedMessage: input.repliedMessage,
|
|
2187
2314
|
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
2188
2315
|
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
2189
2316
|
};
|
|
@@ -2219,11 +2346,18 @@ export class ThreadSessionRuntime {
|
|
|
2219
2346
|
* discord-bot.ts.
|
|
2220
2347
|
*/
|
|
2221
2348
|
async enqueueIncoming(input) {
|
|
2349
|
+
threadState.setSessionUsername(this.threadId, input.username);
|
|
2222
2350
|
// When a preprocessor is provided, we must resolve it inside
|
|
2223
2351
|
// dispatchAction before we know the final mode for routing.
|
|
2224
2352
|
if (input.preprocess) {
|
|
2225
2353
|
return this.enqueueWithPreprocess(input);
|
|
2226
2354
|
}
|
|
2355
|
+
// If the prompt starts with `/cmdname ...` (and no explicit command is
|
|
2356
|
+
// already set), rewrite it into a command invocation so it goes through
|
|
2357
|
+
// opencode's session.command API instead of being sent to the model as
|
|
2358
|
+
// plain text. Covers Discord chat messages, /new-session, /queue, CLI
|
|
2359
|
+
// `kimaki send --prompt`, and scheduled tasks — all funnel through here.
|
|
2360
|
+
input = maybeConvertLeadingCommand(input);
|
|
2227
2361
|
if (input.mode === 'local-queue') {
|
|
2228
2362
|
return this.enqueueViaLocalQueue(input);
|
|
2229
2363
|
}
|
|
@@ -2264,13 +2398,17 @@ export class ThreadSessionRuntime {
|
|
|
2264
2398
|
resolveOuter({ queued: false });
|
|
2265
2399
|
return;
|
|
2266
2400
|
}
|
|
2267
|
-
const resolvedInput = {
|
|
2401
|
+
const resolvedInput = maybeConvertLeadingCommand({
|
|
2268
2402
|
...input,
|
|
2269
2403
|
prompt: result.prompt,
|
|
2270
2404
|
images: result.images,
|
|
2271
2405
|
mode: result.mode,
|
|
2406
|
+
// Voice transcription can extract an agent name — apply it only if
|
|
2407
|
+
// no explicit agent was already set (CLI --agent flag wins).
|
|
2408
|
+
agent: input.agent || result.agent,
|
|
2409
|
+
repliedMessage: result.repliedMessage,
|
|
2272
2410
|
preprocess: undefined,
|
|
2273
|
-
};
|
|
2411
|
+
});
|
|
2274
2412
|
const hasPromptText = resolvedInput.prompt.trim().length > 0;
|
|
2275
2413
|
const hasImages = (resolvedInput.images?.length || 0) > 0;
|
|
2276
2414
|
if (!hasPromptText && !hasImages && !resolvedInput.command) {
|
|
@@ -2612,18 +2750,7 @@ export class ThreadSessionRuntime {
|
|
|
2612
2750
|
.join('\n');
|
|
2613
2751
|
return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`;
|
|
2614
2752
|
})();
|
|
2615
|
-
|
|
2616
|
-
if (input.username) {
|
|
2617
|
-
const msgAttr = input.sourceMessageId ? ` message-id="${input.sourceMessageId}"` : '';
|
|
2618
|
-
const thrAttr = input.sourceThreadId ? ` thread-id="${input.sourceThreadId}"` : '';
|
|
2619
|
-
syntheticContext += `<discord-user name="${input.username}"${msgAttr}${thrAttr} />`;
|
|
2620
|
-
}
|
|
2621
|
-
const parts = [
|
|
2622
|
-
{ type: 'text', text: promptWithImagePaths },
|
|
2623
|
-
{ type: 'text', text: syntheticContext, synthetic: true },
|
|
2624
|
-
...images,
|
|
2625
|
-
];
|
|
2626
|
-
// ── Worktree info for system message ──────────────────────
|
|
2753
|
+
// ── Worktree info for per-turn prompt context ─────────────
|
|
2627
2754
|
const worktreeInfo = await getThreadWorktree(this.thread.id);
|
|
2628
2755
|
const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
2629
2756
|
? {
|
|
@@ -2650,6 +2777,22 @@ export class ThreadSessionRuntime {
|
|
|
2650
2777
|
}
|
|
2651
2778
|
return fetched.topic?.trim() || undefined;
|
|
2652
2779
|
})();
|
|
2780
|
+
const worktreeChanged = this.consumeWorktreePromptChange(worktree);
|
|
2781
|
+
const syntheticContext = getOpencodePromptContext({
|
|
2782
|
+
username: input.username,
|
|
2783
|
+
userId: input.userId,
|
|
2784
|
+
sourceMessageId: input.sourceMessageId,
|
|
2785
|
+
sourceThreadId: input.sourceThreadId,
|
|
2786
|
+
repliedMessage: input.repliedMessage,
|
|
2787
|
+
worktree,
|
|
2788
|
+
currentAgent: earlyAgentPreference,
|
|
2789
|
+
worktreeChanged,
|
|
2790
|
+
});
|
|
2791
|
+
const parts = [
|
|
2792
|
+
{ type: 'text', text: promptWithImagePaths },
|
|
2793
|
+
{ type: 'text', text: syntheticContext, synthetic: true },
|
|
2794
|
+
...images,
|
|
2795
|
+
];
|
|
2653
2796
|
const variantField = earlyThinkingValue
|
|
2654
2797
|
? { variant: earlyThinkingValue }
|
|
2655
2798
|
: {};
|
|
@@ -2678,15 +2821,19 @@ export class ThreadSessionRuntime {
|
|
|
2678
2821
|
// session.command() only accepts FilePart in parts, not text parts.
|
|
2679
2822
|
// Append <discord-user /> tag to arguments so external sync can
|
|
2680
2823
|
// detect this message came from Discord (same tag as promptAsync).
|
|
2681
|
-
const discordTag =
|
|
2682
|
-
|
|
2683
|
-
:
|
|
2824
|
+
const discordTag = getOpencodePromptContext({
|
|
2825
|
+
username: input.username,
|
|
2826
|
+
userId: input.userId,
|
|
2827
|
+
sourceMessageId: input.sourceMessageId,
|
|
2828
|
+
sourceThreadId: input.sourceThreadId,
|
|
2829
|
+
repliedMessage: input.repliedMessage,
|
|
2830
|
+
});
|
|
2684
2831
|
const commandResponse = await errore.tryAsync(() => {
|
|
2685
2832
|
return getClient().session.command({
|
|
2686
2833
|
sessionID: session.id,
|
|
2687
2834
|
directory: this.sdkDirectory,
|
|
2688
2835
|
command: queuedCommand.name,
|
|
2689
|
-
arguments: queuedCommand.arguments + discordTag,
|
|
2836
|
+
arguments: queuedCommand.arguments + (discordTag ? `\n${discordTag}` : ''),
|
|
2690
2837
|
agent: earlyAgentPreference,
|
|
2691
2838
|
...variantField,
|
|
2692
2839
|
}, { signal: commandSignal });
|
|
@@ -2752,12 +2899,9 @@ export class ThreadSessionRuntime {
|
|
|
2752
2899
|
channelId,
|
|
2753
2900
|
guildId: this.thread.guildId,
|
|
2754
2901
|
threadId: this.thread.id,
|
|
2755
|
-
worktree,
|
|
2756
2902
|
channelTopic,
|
|
2757
|
-
username: input.username,
|
|
2758
|
-
userId: input.userId,
|
|
2759
2903
|
agents: earlyAvailableAgents,
|
|
2760
|
-
|
|
2904
|
+
username: this.state?.sessionUsername || input.username,
|
|
2761
2905
|
}),
|
|
2762
2906
|
model: earlyModelParam,
|
|
2763
2907
|
agent: earlyAgentPreference,
|
|
@@ -2869,13 +3013,21 @@ export class ThreadSessionRuntime {
|
|
|
2869
3013
|
await this.hydrateSessionEventsFromDatabase({ sessionId: session.id });
|
|
2870
3014
|
// Store session start source for scheduled tasks
|
|
2871
3015
|
if (createdNewSession && sessionStartScheduleKind) {
|
|
2872
|
-
await errore.tryAsync(
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
3016
|
+
const sessionStartSourceResult = await errore.tryAsync({
|
|
3017
|
+
try: () => {
|
|
3018
|
+
return setSessionStartSource({
|
|
3019
|
+
sessionId: session.id,
|
|
3020
|
+
scheduleKind: sessionStartScheduleKind,
|
|
3021
|
+
scheduledTaskId: sessionStartScheduledTaskId,
|
|
3022
|
+
});
|
|
3023
|
+
},
|
|
3024
|
+
catch: (e) => new Error('Failed to persist scheduled session start source', {
|
|
3025
|
+
cause: e,
|
|
3026
|
+
}),
|
|
2878
3027
|
});
|
|
3028
|
+
if (sessionStartSourceResult instanceof Error) {
|
|
3029
|
+
logger.warn(`[SESSION START SOURCE] ${sessionStartSourceResult.message}`);
|
|
3030
|
+
}
|
|
2879
3031
|
}
|
|
2880
3032
|
// Store agent preference if provided
|
|
2881
3033
|
if (agent && createdNewSession) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Unit tests for deriveThreadNameFromSessionTitle — the pure helper that
|
|
2
|
+
// decides whether (and how) to rename a Discord thread based on an
|
|
3
|
+
// OpenCode session title. Kept focused and deterministic; no Discord mocks.
|
|
4
|
+
import { describe, test, expect } from 'vitest';
|
|
5
|
+
import { deriveThreadNameFromSessionTitle } from './session-handler/thread-session-runtime.js';
|
|
6
|
+
describe('deriveThreadNameFromSessionTitle', () => {
|
|
7
|
+
test('returns trimmed title for plain thread', () => {
|
|
8
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
9
|
+
sessionTitle: ' Fix auth bug ',
|
|
10
|
+
currentName: 'fix the auth',
|
|
11
|
+
})).toMatchInlineSnapshot(`"Fix auth bug"`);
|
|
12
|
+
});
|
|
13
|
+
test('preserves worktree prefix from current name', () => {
|
|
14
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
15
|
+
sessionTitle: 'Refactor queue',
|
|
16
|
+
currentName: '⬦ refactor queue old',
|
|
17
|
+
})).toMatchInlineSnapshot(`"⬦ Refactor queue"`);
|
|
18
|
+
});
|
|
19
|
+
test('ignores placeholder "New Session -" titles', () => {
|
|
20
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
21
|
+
sessionTitle: 'New Session - 2025-01-02',
|
|
22
|
+
currentName: 'whatever',
|
|
23
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
24
|
+
});
|
|
25
|
+
test('ignores case-insensitive placeholder titles', () => {
|
|
26
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
27
|
+
sessionTitle: 'new session -abc',
|
|
28
|
+
currentName: 'whatever',
|
|
29
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
30
|
+
});
|
|
31
|
+
test('returns undefined when candidate already matches current name', () => {
|
|
32
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
33
|
+
sessionTitle: 'Fix auth bug',
|
|
34
|
+
currentName: 'Fix auth bug',
|
|
35
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
36
|
+
});
|
|
37
|
+
test('returns undefined when candidate (with worktree prefix) already matches', () => {
|
|
38
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
39
|
+
sessionTitle: 'Refactor queue',
|
|
40
|
+
currentName: '⬦ Refactor queue',
|
|
41
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
42
|
+
});
|
|
43
|
+
test('truncates to 100 chars including worktree prefix', () => {
|
|
44
|
+
const result = deriveThreadNameFromSessionTitle({
|
|
45
|
+
sessionTitle: 'x'.repeat(200),
|
|
46
|
+
currentName: '⬦ seed',
|
|
47
|
+
});
|
|
48
|
+
expect(result?.length).toMatchInlineSnapshot(`100`);
|
|
49
|
+
expect(result?.startsWith('⬦ ')).toMatchInlineSnapshot(`true`);
|
|
50
|
+
});
|
|
51
|
+
test('truncates to 100 chars without prefix', () => {
|
|
52
|
+
const result = deriveThreadNameFromSessionTitle({
|
|
53
|
+
sessionTitle: 'y'.repeat(200),
|
|
54
|
+
currentName: 'seed',
|
|
55
|
+
});
|
|
56
|
+
expect(result?.length).toMatchInlineSnapshot(`100`);
|
|
57
|
+
});
|
|
58
|
+
test('returns undefined for empty string', () => {
|
|
59
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
60
|
+
sessionTitle: '',
|
|
61
|
+
currentName: 'seed',
|
|
62
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
63
|
+
});
|
|
64
|
+
test('returns undefined for whitespace-only title', () => {
|
|
65
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
66
|
+
sessionTitle: ' ',
|
|
67
|
+
currentName: 'seed',
|
|
68
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
69
|
+
});
|
|
70
|
+
test('returns undefined for null/undefined title', () => {
|
|
71
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
72
|
+
sessionTitle: null,
|
|
73
|
+
currentName: 'seed',
|
|
74
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
75
|
+
expect(deriveThreadNameFromSessionTitle({
|
|
76
|
+
sessionTitle: undefined,
|
|
77
|
+
currentName: 'seed',
|
|
78
|
+
})).toMatchInlineSnapshot(`undefined`);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/dist/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Centralized zustand/vanilla store for global bot state.
|
|
2
2
|
// Replaces scattered module-level `let` variables, process.env mutations,
|
|
3
3
|
// and mutable arrays with a single immutable state atom.
|
|
4
|
-
// See
|
|
4
|
+
// See cli/skills/zustand-centralized-state/SKILL.md for the pattern.
|
|
5
5
|
import { createStore } from 'zustand/vanilla';
|
|
6
6
|
export const store = createStore(() => ({
|
|
7
7
|
dataDir: null,
|
|
@@ -9,7 +9,6 @@ export const store = createStore(() => ({
|
|
|
9
9
|
defaultVerbosity: 'text_and_essential_tools',
|
|
10
10
|
defaultMentionMode: false,
|
|
11
11
|
critiqueEnabled: true,
|
|
12
|
-
verboseOpencodeServer: false,
|
|
13
12
|
discordBaseUrl: 'https://discord.com',
|
|
14
13
|
gatewayToken: null,
|
|
15
14
|
registeredUserCommands: [],
|