@grinev/opencode-telegram-bot 0.21.2 → 0.22.1
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/.env.example +0 -15
- package/README.md +15 -8
- package/dist/app/formatters/summary-formatter.js +16 -0
- package/dist/app/managers/summary-aggregation-manager.js +174 -10
- package/dist/app/services/file-download-service.js +51 -2
- package/dist/app/services/scheduled-task-executor-service.js +22 -7
- package/dist/app/stores/settings-store.js +46 -4
- package/dist/bot/callbacks/callback-router.js +4 -1
- package/dist/bot/callbacks/scheduled-task-callback-handler.js +20 -1
- package/dist/bot/callbacks/settings-callback-handler.js +105 -0
- package/dist/bot/commands/definitions.js +1 -1
- package/dist/bot/commands/settings-command.js +10 -0
- package/dist/bot/commands/status-command.js +7 -2
- package/dist/bot/handlers/document-handler.js +1 -1
- package/dist/bot/handlers/media-group-handler.js +1 -1
- package/dist/bot/handlers/prompt.js +3 -3
- package/dist/bot/handlers/voice-handler.js +5 -2
- package/dist/bot/menus/inline-menu.js +3 -1
- package/dist/bot/menus/settings-menu.js +52 -0
- package/dist/bot/messages/thinking-message.js +1 -4
- package/dist/bot/messages/thinking-rendering.js +66 -0
- package/dist/bot/routers/command-router.js +2 -2
- package/dist/bot/services/event-subscription-service.js +304 -30
- package/dist/bot/streaming/compact-progress-streamer.js +168 -0
- package/dist/bot/streaming/finalize-assistant-response.js +9 -3
- package/dist/bot/streaming/response-streamer.js +16 -1
- package/dist/config.js +0 -15
- package/dist/i18n/ar.js +31 -6
- package/dist/i18n/de.js +32 -7
- package/dist/i18n/en.js +32 -7
- package/dist/i18n/es.js +32 -7
- package/dist/i18n/fr.js +32 -7
- package/dist/i18n/ru.js +32 -7
- package/dist/i18n/zh.js +32 -7
- package/dist/opencode/process.js +40 -3
- package/package.json +1 -1
- package/dist/bot/commands/tts-command.js +0 -13
package/.env.example
CHANGED
|
@@ -83,12 +83,6 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
83
83
|
# Send scheduled task result/error messages without Telegram push notifications (default: false)
|
|
84
84
|
# SCHEDULED_TASK_DISABLE_NOTIFICATION=false
|
|
85
85
|
|
|
86
|
-
# Response streaming mode: "edit" (default) or "draft" (default: edit)
|
|
87
|
-
# edit = uses editMessageText to update messages incrementally (may flicker)
|
|
88
|
-
# draft = uses sendMessageDraft (Bot API 9.5+) for smooth animated draft previews,
|
|
89
|
-
# then finalizes with sendMessage. Only works in private chats.
|
|
90
|
-
# RESPONSE_STREAMING_MODE=edit
|
|
91
|
-
|
|
92
86
|
# Stream update throttle in milliseconds for assistant/tool message edits (default: 1000)
|
|
93
87
|
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
|
|
94
88
|
# RESPONSE_STREAM_THROTTLE_MS=1000
|
|
@@ -101,15 +95,6 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
101
95
|
# Supported locales: en, ar, de, es, fr, ru, zh
|
|
102
96
|
# BOT_LOCALE=en
|
|
103
97
|
|
|
104
|
-
# Hide thinking indicator messages (default: false)
|
|
105
|
-
# HIDE_THINKING_MESSAGES=false
|
|
106
|
-
|
|
107
|
-
# Hide tool call service messages (default: false)
|
|
108
|
-
# HIDE_TOOL_CALL_MESSAGES=false
|
|
109
|
-
|
|
110
|
-
# Hide tool file edit documents sent as .txt attachments (default: false)
|
|
111
|
-
# HIDE_TOOL_FILE_MESSAGES=false
|
|
112
|
-
|
|
113
98
|
# Track background sessions in the currently selected project/worktree (default: true)
|
|
114
99
|
# Sends short notifications when detached or non-current sessions in the current directory reply,
|
|
115
100
|
# ask questions, or request permissions. Other projects/worktrees are not tracked.
|
package/README.md
CHANGED
|
@@ -38,7 +38,8 @@ Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`
|
|
|
38
38
|
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
|
|
39
39
|
- **Skills Catalog** — browse OpenCode skills from an inline menu and run them immediately or with arguments in the next message
|
|
40
40
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
41
|
-
- **
|
|
41
|
+
- **Runtime settings** — use `/settings` to change runtime preferences; see [Runtime Settings](#runtime-settings)
|
|
42
|
+
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies in `/settings`
|
|
42
43
|
- **File attachments** — send images, PDF documents, and text-based files to OpenCode, including multiple files in one Telegram album
|
|
43
44
|
- **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
|
|
44
45
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
@@ -141,7 +142,7 @@ opencode-telegram config
|
|
|
141
142
|
| `/worktree` | Switch between existing git worktrees |
|
|
142
143
|
| `/open` | Add a project by browsing directories |
|
|
143
144
|
| `/ls` | List directory contents, then tap to open or download |
|
|
144
|
-
| `/
|
|
145
|
+
| `/settings` | Change bot settings |
|
|
145
146
|
| `/rename` | Rename the current session |
|
|
146
147
|
| `/commands` | Browse and run custom commands |
|
|
147
148
|
| `/skills` | Browse and run OpenCode skills |
|
|
@@ -229,12 +230,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
229
230
|
| `SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES` | Maximum time the bot waits for one scheduled task run before marking it failed | No | `120` |
|
|
230
231
|
| `SCHEDULED_TASK_DISABLE_NOTIFICATION` | Send scheduled task result/error messages without Telegram push notifications | No | `false` |
|
|
231
232
|
| `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
|
|
232
|
-
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
233
|
-
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
234
|
-
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
235
|
-
| `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
|
|
236
233
|
| `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
|
|
237
|
-
| `
|
|
234
|
+
| `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` |
|
|
238
235
|
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
239
236
|
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
240
237
|
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
@@ -255,6 +252,16 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
255
252
|
|
|
256
253
|
Logs are written to `./logs` when running from sources and to the runtime config directory `logs/` folder in `installed` mode. Log rotation depends on runtime mode: `sources` creates one file per bot launch, while `installed` appends to one file per day. Old log files are removed according to `LOG_RETENTION`.
|
|
257
254
|
|
|
255
|
+
### Runtime Settings
|
|
256
|
+
|
|
257
|
+
Runtime preferences are changed from `/settings` and stored in `settings.json`:
|
|
258
|
+
|
|
259
|
+
- Compact output mode
|
|
260
|
+
- Thinking content display
|
|
261
|
+
- Diff file attachments
|
|
262
|
+
- Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
|
|
263
|
+
- Audio replies: `off`, `all`, or `auto` when TTS is configured
|
|
264
|
+
|
|
258
265
|
### Reverse Proxy (Optional)
|
|
259
266
|
|
|
260
267
|
For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`.
|
|
@@ -314,7 +321,7 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
|
314
321
|
|
|
315
322
|
If `STT_NOTE_PROMPT` is set to a non-empty value other than `false` or `0`, the bot prepends `[Note: ...]` to the transcription before sending it to the LLM. The recognized text shown in Telegram stays unchanged.
|
|
316
323
|
|
|
317
|
-
If TTS credentials are configured, you can
|
|
324
|
+
If TTS credentials are configured, you can choose spoken reply behavior in `/settings`: `off` disables audio replies, `all` sends audio for every assistant reply, and `auto` sends audio only after voice/audio prompts. The preference is stored in `settings.json` and persists across restarts.
|
|
318
325
|
|
|
319
326
|
OpenAI-compatible TTS configuration example:
|
|
320
327
|
|
|
@@ -227,6 +227,22 @@ export function formatCompactToolInfo(toolInfo, maxLength = 64, fallback = "-")
|
|
|
227
227
|
}
|
|
228
228
|
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
229
229
|
}
|
|
230
|
+
export function formatCompactToolActivity(toolInfo, maxLength = 64) {
|
|
231
|
+
if (!hasCompactToolDetails(toolInfo)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const formatted = formatCompactToolInfo(toolInfo, maxLength, "").trim();
|
|
235
|
+
return formatted || null;
|
|
236
|
+
}
|
|
237
|
+
function hasCompactToolDetails(toolInfo) {
|
|
238
|
+
if (typeof toolInfo.title === "string" && toolInfo.title.trim().length > 0) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (toolInfo.tool === "todowrite" && Array.isArray(toolInfo.metadata?.todos)) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return getToolDetails(toolInfo.tool, toolInfo.input).trim().length > 0;
|
|
245
|
+
}
|
|
230
246
|
function countLines(text) {
|
|
231
247
|
return text.split("\n").length;
|
|
232
248
|
}
|
|
@@ -38,6 +38,7 @@ function normalizeSnapshotValue(value) {
|
|
|
38
38
|
class SummaryAggregator {
|
|
39
39
|
currentSessionId = null;
|
|
40
40
|
textMessageStates = new Map();
|
|
41
|
+
thinkingMessageStates = new Map();
|
|
41
42
|
messages = new Map();
|
|
42
43
|
messageCount = 0;
|
|
43
44
|
lastUpdated = 0;
|
|
@@ -45,10 +46,12 @@ class SummaryAggregator {
|
|
|
45
46
|
onPartialCallback = null;
|
|
46
47
|
onExternalUserInputCallback = null;
|
|
47
48
|
onToolCallback = null;
|
|
49
|
+
onRootToolUpdateCallback = null;
|
|
48
50
|
onToolFileCallback = null;
|
|
49
51
|
onQuestionCallback = null;
|
|
50
52
|
onQuestionErrorCallback = null;
|
|
51
53
|
onThinkingCallback = null;
|
|
54
|
+
onThinkingFinishedCallback = null;
|
|
52
55
|
onTokensCallback = null;
|
|
53
56
|
onCostCallback = null;
|
|
54
57
|
onSubagentCallback = null;
|
|
@@ -62,6 +65,7 @@ class SummaryAggregator {
|
|
|
62
65
|
onClearedCallback = null;
|
|
63
66
|
processedToolStates = new Set();
|
|
64
67
|
thinkingFiredForMessages = new Set();
|
|
68
|
+
thinkingFinishedForMessages = new Set();
|
|
65
69
|
deliveredExternalUserMessageIds = new Set();
|
|
66
70
|
knownTextPartIds = new Map();
|
|
67
71
|
bot = null;
|
|
@@ -93,6 +97,9 @@ class SummaryAggregator {
|
|
|
93
97
|
setOnTool(callback) {
|
|
94
98
|
this.onToolCallback = callback;
|
|
95
99
|
}
|
|
100
|
+
setOnRootToolUpdate(callback) {
|
|
101
|
+
this.onRootToolUpdateCallback = callback;
|
|
102
|
+
}
|
|
96
103
|
setOnToolFile(callback) {
|
|
97
104
|
this.onToolFileCallback = callback;
|
|
98
105
|
}
|
|
@@ -105,6 +112,9 @@ class SummaryAggregator {
|
|
|
105
112
|
setOnThinking(callback) {
|
|
106
113
|
this.onThinkingCallback = callback;
|
|
107
114
|
}
|
|
115
|
+
setOnThinkingFinished(callback) {
|
|
116
|
+
this.onThinkingFinishedCallback = callback;
|
|
117
|
+
}
|
|
108
118
|
setOnTokens(callback) {
|
|
109
119
|
this.onTokensCallback = callback;
|
|
110
120
|
}
|
|
@@ -242,11 +252,13 @@ class SummaryAggregator {
|
|
|
242
252
|
this.stopTypingIndicator();
|
|
243
253
|
this.currentSessionId = null;
|
|
244
254
|
this.textMessageStates.clear();
|
|
255
|
+
this.thinkingMessageStates.clear();
|
|
245
256
|
this.messages.clear();
|
|
246
257
|
this.partHashes.clear();
|
|
247
258
|
this.knownTextPartIds.clear();
|
|
248
259
|
this.processedToolStates.clear();
|
|
249
260
|
this.thinkingFiredForMessages.clear();
|
|
261
|
+
this.thinkingFinishedForMessages.clear();
|
|
250
262
|
this.deliveredExternalUserMessageIds.clear();
|
|
251
263
|
this.trackedSessionParents.clear();
|
|
252
264
|
this.subagentStates.clear();
|
|
@@ -755,26 +767,38 @@ class SummaryAggregator {
|
|
|
755
767
|
this.registerKnownTextPart(messageID, part.id);
|
|
756
768
|
this.registerTextPart(messageID, part.id);
|
|
757
769
|
}
|
|
770
|
+
if (part.type === "reasoning") {
|
|
771
|
+
this.registerThinkingPart(messageID, part.id, this.extractReasoningTitle(part));
|
|
772
|
+
}
|
|
758
773
|
const deltaFromUpdated = event.properties.delta;
|
|
759
774
|
if (part.type === "text" &&
|
|
760
775
|
typeof deltaFromUpdated === "string" &&
|
|
761
776
|
deltaFromUpdated.length > 0) {
|
|
777
|
+
this.emitThinkingFinishedOnce(part.sessionID, messageID);
|
|
762
778
|
this.applyTextDelta(part.sessionID, messageID, part.id, deltaFromUpdated, part.text);
|
|
763
779
|
this.lastUpdated = Date.now();
|
|
764
780
|
return;
|
|
765
781
|
}
|
|
782
|
+
if (part.type === "reasoning" &&
|
|
783
|
+
typeof deltaFromUpdated === "string" &&
|
|
784
|
+
deltaFromUpdated.length > 0) {
|
|
785
|
+
const partText = "text" in part && typeof part.text === "string" ? part.text : undefined;
|
|
786
|
+
this.applyThinkingDelta(part.sessionID, messageID, part.id, deltaFromUpdated, partText, this.extractReasoningTitle(part));
|
|
787
|
+
this.lastUpdated = Date.now();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
766
790
|
if (part.type === "reasoning") {
|
|
767
|
-
// Fire the thinking callback
|
|
768
|
-
//
|
|
769
|
-
|
|
791
|
+
// Fire the thinking callback on every reasoning update. The first update
|
|
792
|
+
// preserves the old lightweight indicator behavior for callers that do
|
|
793
|
+
// not display full reasoning content.
|
|
794
|
+
const isFirstUpdate = !this.thinkingFiredForMessages.has(messageID);
|
|
795
|
+
if (isFirstUpdate) {
|
|
770
796
|
this.thinkingFiredForMessages.add(messageID);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
});
|
|
797
|
+
}
|
|
798
|
+
const partText = "text" in part && typeof part.text === "string" ? part.text : "";
|
|
799
|
+
const wasUpdated = this.setThinkingPartSnapshot(messageID, part.id, partText, this.extractReasoningTitle(part));
|
|
800
|
+
if (isFirstUpdate || wasUpdated) {
|
|
801
|
+
this.emitThinkingUpdate(part.sessionID, messageID, isFirstUpdate);
|
|
778
802
|
}
|
|
779
803
|
}
|
|
780
804
|
else if (part.type === "text" && "text" in part && part.text) {
|
|
@@ -784,6 +808,7 @@ class SummaryAggregator {
|
|
|
784
808
|
if (!wasUpdated) {
|
|
785
809
|
return;
|
|
786
810
|
}
|
|
811
|
+
this.emitThinkingFinishedOnce(part.sessionID, messageID);
|
|
787
812
|
const fullText = this.getCombinedMessageText(messageID);
|
|
788
813
|
if (messageInfo && messageInfo.role === "assistant") {
|
|
789
814
|
this.startTypingIndicator();
|
|
@@ -808,6 +833,19 @@ class SummaryAggregator {
|
|
|
808
833
|
this.updateSubagentFromTaskTool(part.sessionID, input);
|
|
809
834
|
}
|
|
810
835
|
logger.debug(`[Aggregator] Tool event: callID=${part.callID}, tool=${part.tool}, status=${"status" in state ? state.status : "unknown"}`);
|
|
836
|
+
if (this.onRootToolUpdateCallback) {
|
|
837
|
+
this.onRootToolUpdateCallback({
|
|
838
|
+
sessionId: part.sessionID,
|
|
839
|
+
messageId: messageID,
|
|
840
|
+
callId: part.callID,
|
|
841
|
+
tool: part.tool,
|
|
842
|
+
state: part.state,
|
|
843
|
+
input,
|
|
844
|
+
title,
|
|
845
|
+
metadata: "metadata" in state ? state.metadata : undefined,
|
|
846
|
+
hasFileAttachment: false,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
811
849
|
if (part.tool === "question") {
|
|
812
850
|
logger.debug(`[Aggregator] Question tool part update:`, JSON.stringify(part, null, 2));
|
|
813
851
|
// If the question tool fails, clear the active poll
|
|
@@ -871,6 +909,13 @@ class SummaryAggregator {
|
|
|
871
909
|
if (!sessionID || !messageID || typeof delta !== "string" || delta.length === 0) {
|
|
872
910
|
return;
|
|
873
911
|
}
|
|
912
|
+
if (partType === "reasoning" || (!partType && this.isKnownThinkingPart(messageID, partID))) {
|
|
913
|
+
const title = part
|
|
914
|
+
? this.extractReasoningTitle(part)
|
|
915
|
+
: undefined;
|
|
916
|
+
this.applyThinkingDelta(sessionID, messageID, partID, delta, part?.text, title);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
874
919
|
if (partType && partType !== "text") {
|
|
875
920
|
return;
|
|
876
921
|
}
|
|
@@ -890,6 +935,7 @@ class SummaryAggregator {
|
|
|
890
935
|
this.registerTextPart(messageID, partID);
|
|
891
936
|
}
|
|
892
937
|
}
|
|
938
|
+
this.emitThinkingFinishedOnce(sessionID, messageID);
|
|
893
939
|
this.applyTextDelta(sessionID, messageID, partID, delta, part?.text);
|
|
894
940
|
}
|
|
895
941
|
applyTextDelta(sessionID, messageID, partID, delta, fullTextHint) {
|
|
@@ -916,6 +962,121 @@ class SummaryAggregator {
|
|
|
916
962
|
this.startTypingIndicator();
|
|
917
963
|
this.emitPartialText(sessionID, messageID, combined);
|
|
918
964
|
}
|
|
965
|
+
extractReasoningTitle(part) {
|
|
966
|
+
for (const key of ["title", "heading", "summary", "name"]) {
|
|
967
|
+
const value = part[key];
|
|
968
|
+
if (typeof value === "string" && value.trim()) {
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const metadata = part.metadata;
|
|
973
|
+
if (metadata && typeof metadata === "object") {
|
|
974
|
+
for (const key of ["title", "heading", "summary", "name"]) {
|
|
975
|
+
const value = metadata[key];
|
|
976
|
+
if (typeof value === "string" && value.trim()) {
|
|
977
|
+
return value;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
getOrCreateThinkingMessageState(messageID) {
|
|
984
|
+
let state = this.thinkingMessageStates.get(messageID);
|
|
985
|
+
if (!state) {
|
|
986
|
+
state = {
|
|
987
|
+
orderedPartIds: [],
|
|
988
|
+
sections: new Map(),
|
|
989
|
+
};
|
|
990
|
+
this.thinkingMessageStates.set(messageID, state);
|
|
991
|
+
}
|
|
992
|
+
return state;
|
|
993
|
+
}
|
|
994
|
+
isKnownThinkingPart(messageID, partID) {
|
|
995
|
+
return this.thinkingMessageStates.get(messageID)?.sections.has(partID) ?? false;
|
|
996
|
+
}
|
|
997
|
+
registerThinkingPart(messageID, partID, title) {
|
|
998
|
+
const state = this.getOrCreateThinkingMessageState(messageID);
|
|
999
|
+
if (!state.orderedPartIds.includes(partID)) {
|
|
1000
|
+
state.orderedPartIds.push(partID);
|
|
1001
|
+
}
|
|
1002
|
+
const existing = state.sections.get(partID);
|
|
1003
|
+
if (!existing) {
|
|
1004
|
+
const section = { id: partID, text: "" };
|
|
1005
|
+
if (title) {
|
|
1006
|
+
section.title = title;
|
|
1007
|
+
}
|
|
1008
|
+
state.sections.set(partID, section);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (title && existing.title !== title) {
|
|
1012
|
+
existing.title = title;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
setThinkingPartSnapshot(messageID, partID, text, title) {
|
|
1016
|
+
this.registerThinkingPart(messageID, partID, title);
|
|
1017
|
+
const state = this.getOrCreateThinkingMessageState(messageID);
|
|
1018
|
+
const existing = state.sections.get(partID);
|
|
1019
|
+
const nextTitle = title ?? existing?.title;
|
|
1020
|
+
if (existing && existing.text === text && existing.title === nextTitle) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
const next = { id: partID, text };
|
|
1024
|
+
if (nextTitle) {
|
|
1025
|
+
next.title = nextTitle;
|
|
1026
|
+
}
|
|
1027
|
+
state.sections.set(partID, next);
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
applyThinkingDelta(sessionID, messageID, partID, delta, fullTextHint, title) {
|
|
1031
|
+
if (sessionID !== this.currentSessionId) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
this.registerThinkingPart(messageID, partID, title);
|
|
1035
|
+
const state = this.getOrCreateThinkingMessageState(messageID);
|
|
1036
|
+
const existing = state.sections.get(partID);
|
|
1037
|
+
const previous = existing?.text ?? "";
|
|
1038
|
+
let accumulated = `${previous}${delta}`;
|
|
1039
|
+
if (typeof fullTextHint === "string" && fullTextHint.length > accumulated.length) {
|
|
1040
|
+
accumulated = fullTextHint;
|
|
1041
|
+
}
|
|
1042
|
+
this.setThinkingPartSnapshot(messageID, partID, accumulated, title ?? existing?.title);
|
|
1043
|
+
this.emitThinkingUpdate(sessionID, messageID, false);
|
|
1044
|
+
}
|
|
1045
|
+
getThinkingSections(messageID) {
|
|
1046
|
+
const state = this.thinkingMessageStates.get(messageID);
|
|
1047
|
+
if (!state) {
|
|
1048
|
+
return [];
|
|
1049
|
+
}
|
|
1050
|
+
return state.orderedPartIds
|
|
1051
|
+
.map((partID) => state.sections.get(partID))
|
|
1052
|
+
.filter((section) => Boolean(section))
|
|
1053
|
+
.map((section) => ({ ...section }));
|
|
1054
|
+
}
|
|
1055
|
+
emitThinkingUpdate(sessionId, messageId, isFirstUpdate) {
|
|
1056
|
+
if (!this.onThinkingCallback) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const sections = this.getThinkingSections(messageId);
|
|
1060
|
+
if (sections.length === 0) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const callback = this.onThinkingCallback;
|
|
1064
|
+
setImmediate(() => {
|
|
1065
|
+
callback({ sessionId, messageId, sections, isFirstUpdate });
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
emitThinkingFinishedOnce(sessionId, messageId) {
|
|
1069
|
+
if (!this.onThinkingFinishedCallback ||
|
|
1070
|
+
!this.thinkingFiredForMessages.has(messageId) ||
|
|
1071
|
+
this.thinkingFinishedForMessages.has(messageId)) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
this.thinkingFinishedForMessages.add(messageId);
|
|
1075
|
+
const callback = this.onThinkingFinishedCallback;
|
|
1076
|
+
setImmediate(() => {
|
|
1077
|
+
callback(sessionId, messageId);
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
919
1080
|
emitExternalUserInputIfReady(sessionId, messageId) {
|
|
920
1081
|
if (sessionId !== this.currentSessionId || this.deliveredExternalUserMessageIds.has(messageId)) {
|
|
921
1082
|
return;
|
|
@@ -942,9 +1103,12 @@ class SummaryAggregator {
|
|
|
942
1103
|
}
|
|
943
1104
|
cleanupCompletedMessage(messageId) {
|
|
944
1105
|
this.textMessageStates.delete(messageId);
|
|
1106
|
+
this.thinkingMessageStates.delete(messageId);
|
|
945
1107
|
this.messages.delete(messageId);
|
|
946
1108
|
this.partHashes.delete(messageId);
|
|
947
1109
|
this.knownTextPartIds.delete(messageId);
|
|
1110
|
+
this.thinkingFiredForMessages.delete(messageId);
|
|
1111
|
+
this.thinkingFinishedForMessages.delete(messageId);
|
|
948
1112
|
if (this.textMessageStates.size === 0) {
|
|
949
1113
|
logger.debug("[Aggregator] No more active messages, stopping typing indicator");
|
|
950
1114
|
this.stopTypingIndicator();
|
|
@@ -79,12 +79,61 @@ const APPLICATION_TEXT_MIME_TYPES = new Set([
|
|
|
79
79
|
"application/x-yaml",
|
|
80
80
|
"application/sql",
|
|
81
81
|
]);
|
|
82
|
-
|
|
82
|
+
const TEXT_FILE_EXTENSIONS = new Set([
|
|
83
|
+
"svelte",
|
|
84
|
+
"vue",
|
|
85
|
+
"ts",
|
|
86
|
+
"tsx",
|
|
87
|
+
"jsx",
|
|
88
|
+
"mjs",
|
|
89
|
+
"cjs",
|
|
90
|
+
"go",
|
|
91
|
+
"rs",
|
|
92
|
+
"rb",
|
|
93
|
+
"py",
|
|
94
|
+
"java",
|
|
95
|
+
"c",
|
|
96
|
+
"cpp",
|
|
97
|
+
"h",
|
|
98
|
+
"hpp",
|
|
99
|
+
"cs",
|
|
100
|
+
"swift",
|
|
101
|
+
"kt",
|
|
102
|
+
"kts",
|
|
103
|
+
"sh",
|
|
104
|
+
"bash",
|
|
105
|
+
"yaml",
|
|
106
|
+
"yml",
|
|
107
|
+
"toml",
|
|
108
|
+
"ini",
|
|
109
|
+
"cfg",
|
|
110
|
+
"md",
|
|
111
|
+
"mdx",
|
|
112
|
+
"css",
|
|
113
|
+
"scss",
|
|
114
|
+
"less",
|
|
115
|
+
"html",
|
|
116
|
+
"htm",
|
|
117
|
+
"graphql",
|
|
118
|
+
"gql",
|
|
119
|
+
"proto",
|
|
120
|
+
"gradle",
|
|
121
|
+
]);
|
|
122
|
+
export function isTextMimeType(mimeType, filename) {
|
|
83
123
|
if (!mimeType) {
|
|
84
124
|
return false;
|
|
85
125
|
}
|
|
86
126
|
if (mimeType.startsWith("text/")) {
|
|
87
127
|
return true;
|
|
88
128
|
}
|
|
89
|
-
|
|
129
|
+
if (APPLICATION_TEXT_MIME_TYPES.has(mimeType)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (filename) {
|
|
133
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
134
|
+
if (ext && TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
90
139
|
}
|
|
@@ -7,6 +7,8 @@ export const SCHEDULED_TASK_AGENT = "build";
|
|
|
7
7
|
const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
|
|
8
8
|
const EXECUTION_POLL_INTERVAL_MS = 2000;
|
|
9
9
|
const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
|
|
10
|
+
// Grace period for the server to start the session before any activity is seen.
|
|
11
|
+
const MAX_STARTUP_POLLS_WITHOUT_ACTIVITY = 45;
|
|
10
12
|
const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
|
|
11
13
|
const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
|
|
12
14
|
const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
|
|
@@ -258,6 +260,8 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
|
|
|
258
260
|
const startedAtMs = Date.now();
|
|
259
261
|
const executionTimeoutMs = getExecutionTimeoutMs();
|
|
260
262
|
let idlePollsWithoutResult = 0;
|
|
263
|
+
let startupPollsWithoutActivity = 0;
|
|
264
|
+
let hasObservedActivity = false;
|
|
261
265
|
let completedEmptyResultReadCount = 0;
|
|
262
266
|
while (true) {
|
|
263
267
|
if (Date.now() - startedAtMs >= executionTimeoutMs) {
|
|
@@ -288,7 +292,13 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
|
|
|
288
292
|
throw statusError || new Error("Failed to load scheduled task status");
|
|
289
293
|
}
|
|
290
294
|
const sessionStatus = statuses[sessionId];
|
|
291
|
-
|
|
295
|
+
const sessionIsActive = sessionStatus !== undefined && sessionStatus.type !== "idle";
|
|
296
|
+
if (sessionIsActive) {
|
|
297
|
+
hasObservedActivity = true;
|
|
298
|
+
idlePollsWithoutResult = 0;
|
|
299
|
+
startupPollsWithoutActivity = 0;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
292
302
|
const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);
|
|
293
303
|
if (confirmedAssistantResult.errorMessage) {
|
|
294
304
|
throw new Error(confirmedAssistantResult.errorMessage);
|
|
@@ -305,13 +315,18 @@ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
|
|
|
305
315
|
await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
|
|
306
316
|
continue;
|
|
307
317
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
318
|
+
if (hasObservedActivity) {
|
|
319
|
+
idlePollsWithoutResult += 1;
|
|
320
|
+
if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
|
|
321
|
+
throw new Error("Scheduled task finished without a completed assistant response");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
startupPollsWithoutActivity += 1;
|
|
326
|
+
if (startupPollsWithoutActivity >= MAX_STARTUP_POLLS_WITHOUT_ACTIVITY) {
|
|
327
|
+
throw new Error("Scheduled task did not start producing a response in time");
|
|
328
|
+
}
|
|
311
329
|
}
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
idlePollsWithoutResult = 0;
|
|
315
330
|
}
|
|
316
331
|
await sleep(EXECUTION_POLL_INTERVAL_MS);
|
|
317
332
|
}
|
|
@@ -66,11 +66,46 @@ export function clearSession() {
|
|
|
66
66
|
currentSettings.currentSession = undefined;
|
|
67
67
|
void writeSettingsFile(currentSettings);
|
|
68
68
|
}
|
|
69
|
-
export function
|
|
70
|
-
return currentSettings.
|
|
69
|
+
export function getTtsMode() {
|
|
70
|
+
return currentSettings.ttsMode ?? "off";
|
|
71
71
|
}
|
|
72
|
-
export function
|
|
73
|
-
currentSettings.
|
|
72
|
+
export function setTtsMode(mode) {
|
|
73
|
+
currentSettings.ttsMode = mode;
|
|
74
|
+
void writeSettingsFile(currentSettings);
|
|
75
|
+
}
|
|
76
|
+
export function getCompactOutputMode() {
|
|
77
|
+
return currentSettings.compactOutputMode ?? false;
|
|
78
|
+
}
|
|
79
|
+
export function setCompactOutputMode(enabled) {
|
|
80
|
+
currentSettings.compactOutputMode = enabled;
|
|
81
|
+
void writeSettingsFile(currentSettings);
|
|
82
|
+
}
|
|
83
|
+
export function getShowThinkingContent() {
|
|
84
|
+
return currentSettings.showThinkingContent ?? true;
|
|
85
|
+
}
|
|
86
|
+
export function setShowThinkingContent(enabled) {
|
|
87
|
+
currentSettings.showThinkingContent = enabled;
|
|
88
|
+
void writeSettingsFile(currentSettings);
|
|
89
|
+
}
|
|
90
|
+
export function getShowAssistantRunFooter() {
|
|
91
|
+
return currentSettings.showAssistantRunFooter ?? true;
|
|
92
|
+
}
|
|
93
|
+
export function setShowAssistantRunFooter(enabled) {
|
|
94
|
+
currentSettings.showAssistantRunFooter = enabled;
|
|
95
|
+
void writeSettingsFile(currentSettings);
|
|
96
|
+
}
|
|
97
|
+
export function getResponseStreamingMode() {
|
|
98
|
+
return currentSettings.responseStreamingMode === "draft" ? "draft" : "edit";
|
|
99
|
+
}
|
|
100
|
+
export function setResponseStreamingMode(mode) {
|
|
101
|
+
currentSettings.responseStreamingMode = mode;
|
|
102
|
+
void writeSettingsFile(currentSettings);
|
|
103
|
+
}
|
|
104
|
+
export function getSendDiffFileAttachments() {
|
|
105
|
+
return currentSettings.sendDiffFileAttachments ?? true;
|
|
106
|
+
}
|
|
107
|
+
export function setSendDiffFileAttachments(enabled) {
|
|
108
|
+
currentSettings.sendDiffFileAttachments = enabled;
|
|
74
109
|
void writeSettingsFile(currentSettings);
|
|
75
110
|
}
|
|
76
111
|
export function getCurrentAgent() {
|
|
@@ -146,6 +181,13 @@ export async function loadSettings() {
|
|
|
146
181
|
delete loadedSettings.serverProcess;
|
|
147
182
|
requiresRewrite = true;
|
|
148
183
|
}
|
|
184
|
+
// Migrate old ttsEnabled boolean to new ttsMode
|
|
185
|
+
if ("ttsEnabled" in loadedSettings) {
|
|
186
|
+
const oldEnabled = loadedSettings.ttsEnabled;
|
|
187
|
+
loadedSettings.ttsMode = oldEnabled === true ? "all" : "off";
|
|
188
|
+
delete loadedSettings.ttsEnabled;
|
|
189
|
+
requiresRewrite = true;
|
|
190
|
+
}
|
|
149
191
|
currentSettings = loadedSettings;
|
|
150
192
|
currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
|
|
151
193
|
currentSettings.scheduledTaskSessionIgnores =
|
|
@@ -13,6 +13,7 @@ import { handlePermissionCallback } from "./permission-callback-handler.js";
|
|
|
13
13
|
import { handleProjectSelect } from "./project-callback-handler.js";
|
|
14
14
|
import { handleQuestionCallback } from "./question-callback-handler.js";
|
|
15
15
|
import { handleRenameCancel } from "./rename-callback-handler.js";
|
|
16
|
+
import { handleSettingsCallback } from "./settings-callback-handler.js";
|
|
16
17
|
import { handleBackgroundSessionOpen, handleSessionSelect, } from "./session-callback-handler.js";
|
|
17
18
|
import { handleSkillsCallback } from "./skills-catalog-callback-handler.js";
|
|
18
19
|
import { handleTaskCallback, handleTaskListCallback, } from "./scheduled-task-callback-handler.js";
|
|
@@ -57,6 +58,7 @@ export function registerCallbackRouter(bot, deps) {
|
|
|
57
58
|
const handledModelSearchResults = await handleModelSearchResults(ctx);
|
|
58
59
|
const handledModel = await handleModelSelect(ctx);
|
|
59
60
|
const handledVariant = await handleVariantSelect(ctx);
|
|
61
|
+
const handledSettings = await handleSettingsCallback(ctx);
|
|
60
62
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
61
63
|
const handledTask = await handleTaskCallback(ctx);
|
|
62
64
|
const handledTaskList = await handleTaskListCallback(ctx);
|
|
@@ -74,7 +76,7 @@ export function registerCallbackRouter(bot, deps) {
|
|
|
74
76
|
ensureEventSubscription: deps.ensureEventSubscription,
|
|
75
77
|
});
|
|
76
78
|
const handledMcps = await handleMcpsCallback(ctx);
|
|
77
|
-
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
79
|
+
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, settings=${handledSettings}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
78
80
|
if (!handledBackgroundSession &&
|
|
79
81
|
!handledInlineCancel &&
|
|
80
82
|
!handledSession &&
|
|
@@ -89,6 +91,7 @@ export function registerCallbackRouter(bot, deps) {
|
|
|
89
91
|
!handledModelSearchResults &&
|
|
90
92
|
!handledModel &&
|
|
91
93
|
!handledVariant &&
|
|
94
|
+
!handledSettings &&
|
|
92
95
|
!handledCompactConfirm &&
|
|
93
96
|
!handledTask &&
|
|
94
97
|
!handledTaskList &&
|
|
@@ -100,12 +100,31 @@ function formatDateTime(dateIso, timezone) {
|
|
|
100
100
|
return dateIso;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3400;
|
|
104
|
+
function truncatePromptForDetails(prompt) {
|
|
105
|
+
if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) {
|
|
106
|
+
return prompt;
|
|
107
|
+
}
|
|
108
|
+
const budget = TASK_DETAIL_PROMPT_BYTE_BUDGET - 3;
|
|
109
|
+
let lo = 0;
|
|
110
|
+
let hi = prompt.length;
|
|
111
|
+
while (lo < hi) {
|
|
112
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
113
|
+
if (Buffer.byteLength(prompt.slice(0, mid), "utf-8") <= budget) {
|
|
114
|
+
lo = mid;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
hi = mid - 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return `${prompt.slice(0, lo)}...`;
|
|
121
|
+
}
|
|
103
122
|
function formatTaskDetails(task) {
|
|
104
123
|
const variant = task.model.variant ? ` (${task.model.variant})` : "";
|
|
105
124
|
const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
|
|
106
125
|
const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
|
|
107
126
|
return t("tasklist.details", {
|
|
108
|
-
prompt: task.prompt,
|
|
127
|
+
prompt: truncatePromptForDetails(task.prompt),
|
|
109
128
|
project: `${task.projectWorktree}\n${t("status.line.model", { model })}`,
|
|
110
129
|
schedule: task.scheduleSummary,
|
|
111
130
|
cronLine,
|