claude-telegram-bot 0.3.40 → 0.4.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/CHANGELOG.md +332 -0
- package/README.ko.md +94 -24
- package/README.md +107 -30
- package/bot.mjs +413 -63
- package/config.example.json +11 -1
- package/ctb.mjs +87 -31
- package/package.json +5 -2
package/bot.mjs
CHANGED
|
@@ -84,6 +84,7 @@ const BOT_DIR = join(DATA_DIR, ".claude-bot");
|
|
|
84
84
|
const STATE_PATH = join(BOT_DIR, stateFile);
|
|
85
85
|
const ATTACH_DIR = join(BOT_DIR, "attachments");
|
|
86
86
|
const MEMORY_PATH = join(BOT_DIR, "memory.md"); // /new 로 초기화해도 유지되는 퍼시스턴트 메모리
|
|
87
|
+
const CODEX_HANDOFF_PATH = join(BOT_DIR, "codex-handoff.md"); // Codex fallback 작업을 Claude에 넘길 요약
|
|
87
88
|
const LEGACY_STATE_PATH = join(DATA_DIR, stateFile); // 구버전(루트 직하) 호환
|
|
88
89
|
const LEGACY_ATTACH_DIR = join(DATA_DIR, "attachments");
|
|
89
90
|
|
|
@@ -112,6 +113,11 @@ if (!existsSync(CONFIG_PATH)) {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
116
|
+
const DEFAULT_PROVIDER = cfg.provider || "claude";
|
|
117
|
+
if (!["claude", "codex"].includes(DEFAULT_PROVIDER)) {
|
|
118
|
+
console.error(`Invalid provider: ${DEFAULT_PROVIDER} (expected claude or codex)`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
115
121
|
console.log({ ...cfg, token: cfg.token ? "<redacted>" : "(none)" });
|
|
116
122
|
const TG = `https://api.telegram.org/bot${cfg.token}`;
|
|
117
123
|
// allowedChatId 는 문자열 또는 배열 모두 허용 (하위 호환)
|
|
@@ -136,6 +142,7 @@ const STR = {
|
|
|
136
142
|
"• /new — reset conversation context (new session)\n" +
|
|
137
143
|
"• /compact — compress context to free up space (keeps the session)\n" +
|
|
138
144
|
"• /plan <request> — plan only (no edits), then approve/cancel to run for real\n" +
|
|
145
|
+
"• Codex fallback can run automatically when Claude hits a limit (if enabled)\n" +
|
|
139
146
|
"• /ollama — toggle Ollama chat mode (bypass Claude, use local LLM)\n" +
|
|
140
147
|
"• /stop — stop the current task · /stop --reset to also roll back the session\n" +
|
|
141
148
|
"• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
|
|
@@ -144,6 +151,7 @@ const STR = {
|
|
|
144
151
|
"• /reserve — show retry queue status at usage-limit reset · /reserve rm to cancel\n" +
|
|
145
152
|
"• /restart — restart the bot (after a syntax check)\n" +
|
|
146
153
|
"• /status — bot status & version\n" +
|
|
154
|
+
"• /provider — view / switch the default provider\n" +
|
|
147
155
|
"• /model — view / switch the model\n" +
|
|
148
156
|
"• /autocompact — view / set the auto-compact token threshold\n" +
|
|
149
157
|
"• /id — show this chat ID\n" +
|
|
@@ -152,14 +160,17 @@ const STR = {
|
|
|
152
160
|
compactOk: "🗜️ Context compacted. The conversation continues with a summary.",
|
|
153
161
|
compactFail: (m) => `⚠️ Compact failed: ${m}`,
|
|
154
162
|
compactNoSession: "No active session to compact. Just send a message to start one.",
|
|
163
|
+
compactProviderUnsupported: "/compact is currently available only with provider=claude.",
|
|
155
164
|
autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
|
|
156
165
|
planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
|
|
157
166
|
planApprove: "✅ Proceed",
|
|
158
167
|
planCancel: "❌ Cancel",
|
|
159
168
|
planCancelled: "❌ Plan cancelled. No changes were made.",
|
|
160
169
|
planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
planProviderUnsupported: "/plan approval flow currently requires provider=claude.",
|
|
171
|
+
testFallbackDisabled: "⚠️ No fallback is enabled. Set `\"codexFallback\": true` (recommended) or `\"ollamaFallback\": true` in config.json.",
|
|
172
|
+
testFallbackFail: (m) => `⚠️ Fallback test failed: ${m}`,
|
|
173
|
+
ollamaDisabled: "⚠️ Ollama mode is not enabled. Set `\"ollamaFallback\": true` in config.json.",
|
|
163
174
|
ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
|
|
164
175
|
ollamaOff: "✅ Ollama mode off. Back to Claude.",
|
|
165
176
|
busy: "⏳ A previous task is still running. Please try again when it finishes.",
|
|
@@ -167,7 +178,7 @@ const STR = {
|
|
|
167
178
|
stopOk: "🛑 Task stopped.",
|
|
168
179
|
stopReset: "🛑 Task stopped and session rolled back to before the task.",
|
|
169
180
|
stopNoop: "No task is running.",
|
|
170
|
-
localBusy: "💻 A local `ctb
|
|
181
|
+
localBusy: "💻 A local `ctb` session is active. Send a message when it's done.",
|
|
171
182
|
needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
|
|
172
183
|
cronEmpty:
|
|
173
184
|
"No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
|
|
@@ -198,17 +209,28 @@ const STR = {
|
|
|
198
209
|
status: (i) =>
|
|
199
210
|
`🤖 ${i.name}\n` +
|
|
200
211
|
`• Version: ${i.version}\n` +
|
|
212
|
+
`• Provider: ${i.provider}\n` +
|
|
213
|
+
`• CLIs: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
|
|
201
214
|
`• Model: ${i.model}\n` +
|
|
215
|
+
`• Fallback: ${i.fallback}\n` +
|
|
202
216
|
`• Session: ${i.hasSession ? "active" : "none (fresh)"}\n` +
|
|
203
217
|
`• Scheduled jobs: ${i.jobs}\n` +
|
|
204
218
|
`• Project: ${i.projectDir}\n` +
|
|
205
219
|
`• Permission: ${i.permissionMode}`,
|
|
206
|
-
|
|
207
|
-
`🧠
|
|
220
|
+
claudeModelStatus: (cur, list) =>
|
|
221
|
+
`🧠 Claude model: ${cur}\n` +
|
|
208
222
|
`Switch: ${list.map((x) => `/model ${x}`).join(" · ")} (or a full model id)\n` +
|
|
209
223
|
`/model default — clear the override`,
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
codexModelStatus: (cur) =>
|
|
225
|
+
`🧠 Codex model: ${cur}\n` +
|
|
226
|
+
"Set: `/model <full-codex-model-id>`\n" +
|
|
227
|
+
`/model default — clear the override and use codexModel/CLI default`,
|
|
228
|
+
modelSet: (provider, m) => `🧠 ${provider} model set to: ${m}`,
|
|
229
|
+
modelReset: (provider, def) => `🧠 ${provider} model reset to default (${def}).`,
|
|
230
|
+
providerStatus: (cur, def) => `🤖 Provider: ${cur}${cur === def ? " (config default)" : ` (config default: ${def})`}\nSwitch: /provider claude · /provider codex · /provider default`,
|
|
231
|
+
providerSet: (provider) => `🤖 Default provider set to ${provider}. Existing Claude and Codex sessions are preserved separately.`,
|
|
232
|
+
providerReset: (provider) => `🤖 Provider reset to the config default (${provider}).`,
|
|
233
|
+
providerUsage: "Usage: /provider claude · /provider codex · /provider default",
|
|
212
234
|
autoCompactStatus: (cur, def) =>
|
|
213
235
|
`🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
|
|
214
236
|
"Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
|
|
@@ -236,6 +258,7 @@ const STR = {
|
|
|
236
258
|
"• /new — 대화 맥락 초기화 (새 세션)\n" +
|
|
237
259
|
"• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
|
|
238
260
|
"• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
|
|
261
|
+
"• Codex 폴백 활성화 시 Claude 한도 도달 때 자동으로 대신 실행\n" +
|
|
239
262
|
"• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
|
|
240
263
|
"• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
|
|
241
264
|
"• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
|
|
@@ -244,6 +267,7 @@ const STR = {
|
|
|
244
267
|
"• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
|
|
245
268
|
"• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
|
|
246
269
|
"• /status — 봇 상태·버전 보기\n" +
|
|
270
|
+
"• /provider — 기본 provider 보기·전환\n" +
|
|
247
271
|
"• /model — 모델 보기·전환\n" +
|
|
248
272
|
"• /autocompact — 자동 압축 임계값 보기·설정\n" +
|
|
249
273
|
"• /id — 이 채팅 ID 확인\n" +
|
|
@@ -254,7 +278,7 @@ const STR = {
|
|
|
254
278
|
stopOk: "🛑 작업을 중단했습니다.",
|
|
255
279
|
stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
|
|
256
280
|
stopNoop: "실행 중인 작업이 없습니다.",
|
|
257
|
-
localBusy: "💻 로컬 `ctb
|
|
281
|
+
localBusy: "💻 로컬 `ctb` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
|
|
258
282
|
needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
|
|
259
283
|
cronEmpty:
|
|
260
284
|
"등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
|
|
@@ -284,17 +308,28 @@ const STR = {
|
|
|
284
308
|
status: (i) =>
|
|
285
309
|
`🤖 ${i.name}\n` +
|
|
286
310
|
`• 버전: ${i.version}\n` +
|
|
311
|
+
`• 메인 provider: ${i.provider}\n` +
|
|
312
|
+
`• CLI: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
|
|
287
313
|
`• 모델: ${i.model}\n` +
|
|
314
|
+
`• 폴백: ${i.fallback}\n` +
|
|
288
315
|
`• 세션: ${i.hasSession ? "이어가는 중" : "없음 (새 세션)"}\n` +
|
|
289
316
|
`• 예약 작업: ${i.jobs}개\n` +
|
|
290
317
|
`• 작업 폴더: ${i.projectDir}\n` +
|
|
291
318
|
`• 권한 모드: ${i.permissionMode}`,
|
|
292
|
-
|
|
293
|
-
`🧠 현재 모델: ${cur}\n` +
|
|
319
|
+
claudeModelStatus: (cur, list) =>
|
|
320
|
+
`🧠 현재 Claude 모델: ${cur}\n` +
|
|
294
321
|
`전환: ${list.map((x) => `/model ${x}`).join(" · ")} (또는 전체 모델 ID)\n` +
|
|
295
322
|
`/model default — 오버라이드 해제`,
|
|
296
|
-
|
|
297
|
-
|
|
323
|
+
codexModelStatus: (cur) =>
|
|
324
|
+
`🧠 현재 Codex 모델: ${cur}\n` +
|
|
325
|
+
"설정: `/model <Codex 전체 모델 ID>`\n" +
|
|
326
|
+
`/model default — 오버라이드를 해제하고 codexModel/CLI 기본값 사용`,
|
|
327
|
+
modelSet: (provider, m) => `🧠 ${provider} 모델을 ${m}(으)로 설정했습니다.`,
|
|
328
|
+
modelReset: (provider, def) => `🧠 ${provider} 모델을 기본값(${def})으로 되돌렸습니다.`,
|
|
329
|
+
providerStatus: (cur, def) => `🤖 현재 provider: ${cur}${cur === def ? " (config 기본값)" : ` (config 기본값: ${def})`}\n전환: /provider claude · /provider codex · /provider default`,
|
|
330
|
+
providerSet: (provider) => `🤖 기본 provider를 ${provider}(으)로 변경했습니다. Claude와 Codex의 기존 세션은 각각 유지됩니다.`,
|
|
331
|
+
providerReset: (provider) => `🤖 provider를 config 기본값(${provider})으로 되돌렸습니다.`,
|
|
332
|
+
providerUsage: "사용법: /provider claude · /provider codex · /provider default",
|
|
298
333
|
autoCompactStatus: (cur, def) =>
|
|
299
334
|
`🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
|
|
300
335
|
"설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
|
|
@@ -316,15 +351,18 @@ const STR = {
|
|
|
316
351
|
compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
|
|
317
352
|
compactFail: (m) => `⚠️ compact 실패: ${m}`,
|
|
318
353
|
compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
|
|
354
|
+
compactProviderUnsupported: "/compact는 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
319
355
|
autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
|
|
320
356
|
planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
|
|
321
357
|
planApprove: "✅ 진행",
|
|
322
358
|
planCancel: "❌ 취소",
|
|
323
359
|
planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
|
|
324
360
|
planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
|
|
361
|
+
planProviderUnsupported: "/plan 승인 흐름은 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
325
362
|
contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
|
|
326
|
-
testFallbackDisabled: "⚠️
|
|
327
|
-
testFallbackFail: (m) => `⚠️
|
|
363
|
+
testFallbackDisabled: "⚠️ 폴백이 비활성화 상태입니다. config.json에 `\"codexFallback\": true`(권장) 또는 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
364
|
+
testFallbackFail: (m) => `⚠️ 폴백 테스트 실패: ${m}`,
|
|
365
|
+
ollamaDisabled: "⚠️ Ollama 모드가 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
328
366
|
ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
|
|
329
367
|
ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
|
|
330
368
|
},
|
|
@@ -335,7 +373,7 @@ const t = (l, key, ...a) => {
|
|
|
335
373
|
};
|
|
336
374
|
|
|
337
375
|
// /model 에서 보여줄 추천 별칭(claude CLI 가 별칭·전체 모델 ID 모두 허용).
|
|
338
|
-
const
|
|
376
|
+
const CLAUDE_MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
|
|
339
377
|
|
|
340
378
|
// /(슬래시) 자동완성 메뉴용 명령 목록 (언어별). setMyCommands 로 등록.
|
|
341
379
|
const COMMANDS = {
|
|
@@ -350,6 +388,7 @@ const COMMANDS = {
|
|
|
350
388
|
{ command: "cron", description: "List / add / remove scheduled tasks" },
|
|
351
389
|
{ command: "restart", description: "Restart the bot (after syntax check)" },
|
|
352
390
|
{ command: "status", description: "Bot status / version" },
|
|
391
|
+
{ command: "provider", description: "View / switch the default provider" },
|
|
353
392
|
{ command: "model", description: "View / switch the model" },
|
|
354
393
|
{ command: "autocompact", description: "View / set the auto-compact token threshold" },
|
|
355
394
|
{ command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
|
|
@@ -367,6 +406,7 @@ const COMMANDS = {
|
|
|
367
406
|
{ command: "cron", description: "예약 작업 보기·추가·삭제" },
|
|
368
407
|
{ command: "restart", description: "봇 재시작 (문법 검사 후)" },
|
|
369
408
|
{ command: "status", description: "봇 상태·버전 보기" },
|
|
409
|
+
{ command: "provider", description: "기본 provider 보기·전환" },
|
|
370
410
|
{ command: "model", description: "모델 보기·전환" },
|
|
371
411
|
{ command: "autocompact", description: "자동 압축 임계값 보기·설정" },
|
|
372
412
|
{ command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
|
|
@@ -442,6 +482,28 @@ function saveMemory(content) {
|
|
|
442
482
|
writeFileSync(MEMORY_PATH, content);
|
|
443
483
|
}
|
|
444
484
|
|
|
485
|
+
function loadCodexHandoff(maxChars = 12000) {
|
|
486
|
+
try {
|
|
487
|
+
const text = readFileSync(CODEX_HANDOFF_PATH, "utf8").trim();
|
|
488
|
+
return text.length > maxChars ? text.slice(-maxChars) : text;
|
|
489
|
+
} catch {
|
|
490
|
+
return "";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function appendCodexHandoff({ prompt, response, sessionId }) {
|
|
495
|
+
const entry =
|
|
496
|
+
`\n\n## ${new Date().toISOString()}\n` +
|
|
497
|
+
`Codex session: ${sessionId || "(unknown)"}\n\n` +
|
|
498
|
+
`### Telegram prompt\n${String(prompt || "").trim() || "(empty)"}\n\n` +
|
|
499
|
+
`### Codex response\n${String(response || "").trim() || "(empty)"}\n`;
|
|
500
|
+
try {
|
|
501
|
+
writeFileSync(CODEX_HANDOFF_PATH, loadCodexHandoff(80000) + entry);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
console.error("Failed to append Codex handoff", e.message);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
445
507
|
// ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
|
|
446
508
|
function loadState() {
|
|
447
509
|
// 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
|
|
@@ -460,7 +522,12 @@ function saveState(s) {
|
|
|
460
522
|
}
|
|
461
523
|
}
|
|
462
524
|
migrateData(); // 루트 직하 → .claude-bot/ 1회 이주(있으면) 후 state 로드
|
|
463
|
-
let state = loadState(); // { sessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
525
|
+
let state = loadState(); // { sessionId?, codexSessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
526
|
+
if (state.provider && !["claude", "codex"].includes(state.provider)) {
|
|
527
|
+
console.warn(`Ignoring invalid provider override in state: ${state.provider}`);
|
|
528
|
+
state.provider = undefined;
|
|
529
|
+
}
|
|
530
|
+
const currentProvider = () => state.provider || DEFAULT_PROVIDER;
|
|
464
531
|
|
|
465
532
|
// ── 텔레그램 헬퍼 ─────────────────────────────────────────────────────────
|
|
466
533
|
async function tg(method, body) {
|
|
@@ -655,7 +722,11 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
655
722
|
const mem = opts.injectMemory ? loadMemory() : "";
|
|
656
723
|
// 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
|
|
657
724
|
const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
|
|
658
|
-
const
|
|
725
|
+
const handoff = opts.injectHandoff !== false ? loadCodexHandoff() : "";
|
|
726
|
+
const handoffBlock = handoff
|
|
727
|
+
? `## CODEX FALLBACK HANDOFF\nClaude and Codex sessions are separate. The notes below summarize work Codex handled while Claude was unavailable; use them as context, not as your own prior messages.\n${handoff}`
|
|
728
|
+
: null;
|
|
729
|
+
const appendSys = [memoryBlock, handoffBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
|
|
659
730
|
if (appendSys) args.push("--append-system-prompt", appendSys);
|
|
660
731
|
if (model) args.push("--model", model);
|
|
661
732
|
if (sessionId) args.push("--resume", sessionId);
|
|
@@ -699,27 +770,224 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
699
770
|
});
|
|
700
771
|
}
|
|
701
772
|
|
|
773
|
+
function extractCodexTextFromJsonl(out) {
|
|
774
|
+
let sessionId;
|
|
775
|
+
let text = "";
|
|
776
|
+
for (const line of String(out || "").split("\n")) {
|
|
777
|
+
if (!line.trim()) continue;
|
|
778
|
+
try {
|
|
779
|
+
const j = JSON.parse(line);
|
|
780
|
+
// Current Codex CLI JSONL format.
|
|
781
|
+
if (j.type === "thread.started" && j.thread_id) sessionId = j.thread_id;
|
|
782
|
+
if (j.type === "item.completed" && j.item?.type === "agent_message" && j.item.text) {
|
|
783
|
+
text = j.item.text;
|
|
784
|
+
}
|
|
785
|
+
// Older app-server event format, kept for compatibility.
|
|
786
|
+
if (j.type === "session_meta" && j.payload?.id) sessionId = j.payload.id;
|
|
787
|
+
const p = j.payload;
|
|
788
|
+
if (p?.type === "message" && p.role === "assistant" && Array.isArray(p.content)) {
|
|
789
|
+
const parts = p.content
|
|
790
|
+
.filter((c) => c.type === "output_text" && c.text)
|
|
791
|
+
.map((c) => c.text);
|
|
792
|
+
if (parts.length) text = parts.join("\n");
|
|
793
|
+
}
|
|
794
|
+
} catch {}
|
|
795
|
+
}
|
|
796
|
+
return { sessionId, text: text.trim() };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function resolveCodexBin() {
|
|
800
|
+
if (cfg.codexBin) return cfg.codexBin;
|
|
801
|
+
const candidates = [
|
|
802
|
+
join(dirname(process.execPath), "codex"),
|
|
803
|
+
join(process.env.HOME || "", ".local", "bin", "codex"),
|
|
804
|
+
"/opt/homebrew/bin/codex",
|
|
805
|
+
"/usr/local/bin/codex",
|
|
806
|
+
];
|
|
807
|
+
return candidates.find((p) => p && existsSync(p)) || "codex";
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ── Codex 폴백 실행 ──────────────────────────────────────────────────────
|
|
811
|
+
// Claude와 Codex 세션은 호환되지 않는다. Codex는 별도 session id를 state.codexSessionId에
|
|
812
|
+
// 저장하고, Claude 복귀 시 codex-handoff.md 요약을 시스템 프롬프트로 넘겨 맥락을 연결한다.
|
|
813
|
+
function runCodex(prompt, lang = "en", opts = {}) {
|
|
814
|
+
return new Promise((resolve) => {
|
|
815
|
+
const header = opts.noHeader ? "" : (lang === "ko" ? "🤖 Codex 폴백\n\n" : "🤖 Codex fallback\n\n");
|
|
816
|
+
const lastPath = join(BOT_DIR, `codex-last-message-${process.pid}.txt`);
|
|
817
|
+
const model = state.codexModel || cfg.codexModel;
|
|
818
|
+
const timeoutMs = cfg.codexTimeout || 600_000;
|
|
819
|
+
const args = ["exec"];
|
|
820
|
+
const resumeSessionId = Object.prototype.hasOwnProperty.call(opts, "sessionId")
|
|
821
|
+
? opts.sessionId
|
|
822
|
+
: state.codexSessionId;
|
|
823
|
+
let codexPrompt = prompt;
|
|
824
|
+
if (!resumeSessionId && opts.injectMemory) {
|
|
825
|
+
const context = [loadMemory(), cfg.persona, cfg.appendSystemPrompt].filter(Boolean).join("\n\n");
|
|
826
|
+
if (context) codexPrompt = `Project instructions and persistent context:\n${context}\n\nUser request:\n${prompt}`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (resumeSessionId) {
|
|
830
|
+
args.push("resume", "--json", "-o", lastPath);
|
|
831
|
+
if (model) args.push("--model", model);
|
|
832
|
+
args.push(resumeSessionId, "-");
|
|
833
|
+
} else {
|
|
834
|
+
args.push("--json", "-o", lastPath, "-C", cfg.projectDir, "--sandbox", cfg.codexSandbox || "workspace-write");
|
|
835
|
+
if (model) args.push("--model", model);
|
|
836
|
+
args.push("-");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const child = spawn(resolveCodexBin(), args, {
|
|
840
|
+
cwd: cfg.projectDir,
|
|
841
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
842
|
+
});
|
|
843
|
+
if (opts.trackChild) currentChild = child;
|
|
844
|
+
|
|
845
|
+
let settled = false;
|
|
846
|
+
const finish = (value) => {
|
|
847
|
+
if (settled) return;
|
|
848
|
+
settled = true;
|
|
849
|
+
clearTimeout(timer);
|
|
850
|
+
currentChild = null;
|
|
851
|
+
resolve(value);
|
|
852
|
+
};
|
|
853
|
+
const timer = setTimeout(() => {
|
|
854
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
855
|
+
finish({ ok: false, text: `Codex timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
856
|
+
}, timeoutMs);
|
|
857
|
+
|
|
858
|
+
let out = "", err = "";
|
|
859
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
860
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
861
|
+
child.stdin.on("error", () => {});
|
|
862
|
+
child.on("error", (e) => finish({ ok: false, text: `Failed to start codex: ${e.message}` }));
|
|
863
|
+
child.on("close", (code) => {
|
|
864
|
+
const parsed = extractCodexTextFromJsonl(out);
|
|
865
|
+
let finalText = parsed.text;
|
|
866
|
+
if (!finalText) {
|
|
867
|
+
try { finalText = readFileSync(lastPath, "utf8").trim(); } catch {}
|
|
868
|
+
}
|
|
869
|
+
const sessionId = parsed.sessionId || resumeSessionId;
|
|
870
|
+
if (code === 0 && finalText) {
|
|
871
|
+
if (opts.recordHandoff !== false) appendCodexHandoff({ prompt, response: finalText, sessionId });
|
|
872
|
+
return finish({ ok: true, text: header + finalText, sessionId });
|
|
873
|
+
}
|
|
874
|
+
const raw = (err || out || finalText || "no output").slice(0, 1000);
|
|
875
|
+
finish({ ok: false, text: `Codex failed (exit ${code}):\n${raw}`, canFallback: isFallbackError(raw, code) });
|
|
876
|
+
});
|
|
877
|
+
child.stdin.end(codexPrompt);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function runPrimary(prompt, opts = {}) {
|
|
882
|
+
if (currentProvider() === "codex") {
|
|
883
|
+
return runCodex(prompt, opts.lang || BOT_LANG, {
|
|
884
|
+
noHeader: true,
|
|
885
|
+
trackChild: opts.trackChild,
|
|
886
|
+
injectMemory: opts.injectMemory,
|
|
887
|
+
recordHandoff: opts.recordHandoff,
|
|
888
|
+
...(Object.prototype.hasOwnProperty.call(opts, "sessionId") ? { sessionId: opts.sessionId } : {}),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return runClaude(prompt, opts.sessionId, opts);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// launchd 데몬은 로그인 셸(zsh)을 거치지 않아 .zshrc/brew shellenv의 PATH를 상속받지 못한다
|
|
895
|
+
// (claudeBin과 동일한 이유). ollamaBin 미지정 시 흔한 설치 경로를 순서대로 탐색한다.
|
|
896
|
+
function resolveOllamaBin() {
|
|
897
|
+
if (cfg.ollamaBin) return cfg.ollamaBin;
|
|
898
|
+
const candidates = ["/opt/homebrew/bin/ollama", "/usr/local/bin/ollama", "/usr/bin/ollama"];
|
|
899
|
+
return candidates.find(existsSync) || "ollama";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function getCliVersion(bin, kind) {
|
|
903
|
+
return new Promise((resolve) => {
|
|
904
|
+
const child = spawn(bin, ["--version"], {
|
|
905
|
+
cwd: cfg.projectDir,
|
|
906
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
907
|
+
});
|
|
908
|
+
let output = "", settled = false;
|
|
909
|
+
const finish = (value) => {
|
|
910
|
+
if (settled) return;
|
|
911
|
+
settled = true;
|
|
912
|
+
clearTimeout(timer);
|
|
913
|
+
resolve(value);
|
|
914
|
+
};
|
|
915
|
+
child.stdout.on("data", (d) => { output += d; });
|
|
916
|
+
child.stderr.on("data", (d) => { output += d; });
|
|
917
|
+
child.on("error", () => finish("unavailable"));
|
|
918
|
+
child.on("close", () => {
|
|
919
|
+
const patterns = {
|
|
920
|
+
claude: /([0-9]+\.[0-9]+\.[0-9]+)/,
|
|
921
|
+
codex: /codex(?:-cli)?\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
922
|
+
ollama: /(?:client\s+version\s+is|ollama\s+version\s+is)\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
923
|
+
};
|
|
924
|
+
finish(output.match(patterns[kind])?.[1] || "unknown");
|
|
925
|
+
});
|
|
926
|
+
const timer = setTimeout(() => {
|
|
927
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
928
|
+
finish("unavailable");
|
|
929
|
+
}, 3000);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function getCliVersions() {
|
|
934
|
+
const [claude, codex, ollama] = await Promise.all([
|
|
935
|
+
getCliVersion(cfg.claudeBin || "claude", "claude"),
|
|
936
|
+
getCliVersion(resolveCodexBin(), "codex"),
|
|
937
|
+
getCliVersion(resolveOllamaBin(), "ollama"),
|
|
938
|
+
]);
|
|
939
|
+
return { claude, codex, ollama };
|
|
940
|
+
}
|
|
941
|
+
|
|
702
942
|
// ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
943
|
+
// `ollama launch claude` 로 Claude Code CLI를 로컬 모델로 구동한다. opts.sessionId가
|
|
944
|
+
// 있으면 `--resume`으로 기존 대화 맥락을 그대로 이어받는다(HTTP api/chat 방식과 달리 세션 유지).
|
|
945
|
+
// 터미널 검증: ollama launch claude --model <m> --yes -- -p -- <prompt> --resume <id>
|
|
946
|
+
function runOllama(prompt, lang = "en", opts = {}) {
|
|
947
|
+
return new Promise((resolve) => {
|
|
948
|
+
const header = opts.noHeader ? "" : (lang === "ko"
|
|
949
|
+
? "🌙 Claude가 잠시 쉬고 있어요. 그동안 저(로컬 모델)는 복귀하면 Claude에게 넘길 내용을 정리하는 걸 도와드릴게요 — 코딩·파일 작업은 Claude가 돌아온 뒤에요.\n\n"
|
|
950
|
+
: "🌙 Claude is resting right now. Meanwhile I (a local model) can help you jot down and organize what to hand off once it's back — coding and file work waits for Claude.\n\n");
|
|
951
|
+
const model = cfg.ollamaModel || "qwen3.5:4b";
|
|
952
|
+
const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
|
|
953
|
+
// 폴백 경로에서만: Claude가 막혀 소형 로컬 모델로 대응 중임을 알리고, 코딩·도구 작업을 시도하는
|
|
954
|
+
// 대신 사용자가 Claude 복귀 후 이어갈 수 있도록 요청 정리·요약을 돕는 조수 역할을 지시한다.
|
|
955
|
+
const fallbackRole = opts.fallback
|
|
956
|
+
? "You are a small local model standing in because Claude is temporarily unavailable (rate-limited or out of credits). Do NOT attempt coding, file edits, or tool use — you can't do those reliably. Instead, act as a note-taker: help the user capture, organize, and draft what they'll ask Claude to do once it's back. Summaries, request drafts, and tidy notes are your job."
|
|
957
|
+
: "";
|
|
958
|
+
const appendSys = [cfg.persona, brevity, fallbackRole].filter(Boolean).join("\n\n");
|
|
959
|
+
// `--` 앞은 ollama launch 플래그, 뒤는 claude 플래그로 전달된다.
|
|
960
|
+
const claudeArgs = ["--output-format", "json"];
|
|
961
|
+
if (appendSys) claudeArgs.push("--append-system-prompt", appendSys);
|
|
962
|
+
if (opts.sessionId) claudeArgs.push("--resume", opts.sessionId);
|
|
963
|
+
claudeArgs.push("-p", "--", prompt);
|
|
964
|
+
const args = ["launch", "claude", "--model", model, "--yes", "--", ...claudeArgs];
|
|
965
|
+
|
|
966
|
+
const child = spawn(resolveOllamaBin(), args, {
|
|
967
|
+
cwd: cfg.projectDir,
|
|
968
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
969
|
+
});
|
|
970
|
+
// 로컬 4B 모델 콜드스타트는 첫 응답까지 수 분이 걸릴 수 있어 기본 타임아웃을 넉넉히 잡는다.
|
|
971
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), cfg.ollamaTimeout || 360_000);
|
|
972
|
+
let out = "", err = "";
|
|
973
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
974
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
975
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, text: `Failed to start ollama: ${e.message}` }); });
|
|
976
|
+
child.on("close", () => {
|
|
977
|
+
clearTimeout(timer);
|
|
978
|
+
try {
|
|
979
|
+
const j = JSON.parse(out);
|
|
980
|
+
const text = (j.result ?? "").trim();
|
|
981
|
+
if (j.is_error || !text) return resolve({ ok: false, text: text || "no response" });
|
|
982
|
+
resolve({ ok: true, text: header + text, sessionId: j.session_id });
|
|
983
|
+
} catch {
|
|
984
|
+
// JSON 파싱 실패 시 원시 출력으로 폴백 (구버전 ollama·비-JSON 출력 대비)
|
|
985
|
+
const text = (out || "").trim();
|
|
986
|
+
if (text) return resolve({ ok: true, text: header + text });
|
|
987
|
+
resolve({ ok: false, text: (err || "no output").slice(0, 500) });
|
|
988
|
+
}
|
|
989
|
+
});
|
|
718
990
|
});
|
|
719
|
-
if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
|
|
720
|
-
const j = await r.json();
|
|
721
|
-
const text = (j.message?.content || "").trim();
|
|
722
|
-
return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
|
|
723
991
|
}
|
|
724
992
|
|
|
725
993
|
// ── 크론 스케줄러 ─────────────────────────────────────────────────────────
|
|
@@ -793,7 +1061,7 @@ async function runScheduled(job) {
|
|
|
793
1061
|
busy = true;
|
|
794
1062
|
const started = Date.now();
|
|
795
1063
|
try {
|
|
796
|
-
const res = await
|
|
1064
|
+
const res = await runPrimary(job.prompt, { sessionId: null, lang: BOT_LANG, recordHandoff: false }); // 새 세션 (state 미저장)
|
|
797
1065
|
// 조용한 예약 작업: 출력이 비었거나 정확히 "SKIP"이면 전송하지 않는다.
|
|
798
1066
|
// (예: "조건 충족 시에만 알리고, 아니면 SKIP만 출력해" 식의 조건부 알림용)
|
|
799
1067
|
if (res.ok) {
|
|
@@ -850,7 +1118,7 @@ async function extractCron(input, l) {
|
|
|
850
1118
|
"Reply with ONLY one line of JSON, no prose or code block: " +
|
|
851
1119
|
'{"cron":"0 9 * * *","prompt":"the task","label":"short name","human":"every day at 09:00"}\n\n' +
|
|
852
1120
|
`request: ${input}`;
|
|
853
|
-
const res = await
|
|
1121
|
+
const res = await runPrimary(ask, { sessionId: null, lang: l, recordHandoff: false }); // 새 세션 (대화 맥락과 분리)
|
|
854
1122
|
const m = res.text && res.text.match(/\{[\s\S]*\}/);
|
|
855
1123
|
if (!res.ok || !m) return { error: res.text || t(l, "extractFail") };
|
|
856
1124
|
let obj;
|
|
@@ -1011,11 +1279,30 @@ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
|
|
|
1011
1279
|
async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
1012
1280
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
1013
1281
|
if (!res.ok) {
|
|
1014
|
-
//
|
|
1282
|
+
// Codex 폴백: 레이트리밋·크레딧 에러이고 codexFallback 켜져 있으면 reserve 전에 Codex로 재시도
|
|
1283
|
+
if (currentProvider() === "claude" && cfg.codexFallback && res.canFallback && !stopping) {
|
|
1284
|
+
try {
|
|
1285
|
+
const cRes = await runCodex(prompt, l, { trackChild: true });
|
|
1286
|
+
if (cRes.ok) {
|
|
1287
|
+
if (cRes.sessionId) { state.codexSessionId = cRes.sessionId; saveState(state); }
|
|
1288
|
+
await send(chatId, cRes.text); return;
|
|
1289
|
+
}
|
|
1290
|
+
console.error(cRes.text);
|
|
1291
|
+
} catch (e) {
|
|
1292
|
+
console.error("Codex fallback failed:", e.message);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// Ollama 폴백: Codex 미사용/실패 시, 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
|
|
1015
1296
|
if (cfg.ollamaFallback && res.canFallback && !stopping) {
|
|
1016
1297
|
try {
|
|
1017
|
-
const oRes = await runOllama(prompt, l
|
|
1018
|
-
|
|
1298
|
+
const oRes = await runOllama(prompt, l, {
|
|
1299
|
+
fallback: true,
|
|
1300
|
+
sessionId: currentProvider() === "claude" ? state.sessionId : undefined,
|
|
1301
|
+
});
|
|
1302
|
+
if (oRes.ok) {
|
|
1303
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1304
|
+
await send(chatId, oRes.text); return;
|
|
1305
|
+
}
|
|
1019
1306
|
} catch {}
|
|
1020
1307
|
}
|
|
1021
1308
|
// 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
|
|
@@ -1039,7 +1326,7 @@ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
|
1039
1326
|
if (!stopping) await send(chatId, res.text + footer);
|
|
1040
1327
|
// 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
|
|
1041
1328
|
const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
|
|
1042
|
-
if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1329
|
+
if (currentProvider() === "claude" && compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1043
1330
|
try {
|
|
1044
1331
|
const cr = await runClaude("/compact", state.sessionId);
|
|
1045
1332
|
if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
|
|
@@ -1153,7 +1440,7 @@ async function handle(msg) {
|
|
|
1153
1440
|
return;
|
|
1154
1441
|
}
|
|
1155
1442
|
if (text === "/status") {
|
|
1156
|
-
const latest = await fetchLatestVersion();
|
|
1443
|
+
const [latest, cliVersions] = await Promise.all([fetchLatestVersion(), getCliVersions()]);
|
|
1157
1444
|
const versionStr = !latest || !isNewer(latest, VERSION)
|
|
1158
1445
|
? VERSION
|
|
1159
1446
|
: `${VERSION} → ${latest} ✨`;
|
|
@@ -1161,9 +1448,19 @@ async function handle(msg) {
|
|
|
1161
1448
|
chatId,
|
|
1162
1449
|
t(l, "status", {
|
|
1163
1450
|
version: versionStr,
|
|
1451
|
+
provider: currentProvider(),
|
|
1452
|
+
cliVersions,
|
|
1164
1453
|
name: cfg.name || "claude-telegram-bot",
|
|
1165
|
-
model:
|
|
1166
|
-
|
|
1454
|
+
model: (currentProvider() === "codex" ? state.codexModel || cfg.codexModel : state.model || cfg.model)
|
|
1455
|
+
|| (l === "ko" ? "(기본값)" : "(default)"),
|
|
1456
|
+
fallback: currentProvider() === "codex"
|
|
1457
|
+
? (cfg.ollamaFallback ? "Ollama" : (l === "ko" ? "꺼짐" : "off"))
|
|
1458
|
+
: cfg.codexFallback
|
|
1459
|
+
? `Codex${state.codexSessionId ? (l === "ko" ? " (세션 있음)" : " (session active)") : ""}`
|
|
1460
|
+
: cfg.ollamaFallback
|
|
1461
|
+
? "Ollama"
|
|
1462
|
+
: (l === "ko" ? "꺼짐" : "off"),
|
|
1463
|
+
hasSession: Boolean(currentProvider() === "codex" ? state.codexSessionId : state.sessionId),
|
|
1167
1464
|
jobs: schedule.length,
|
|
1168
1465
|
projectDir: cfg.projectDir,
|
|
1169
1466
|
permissionMode: cfg.permissionMode || "acceptEdits",
|
|
@@ -1171,22 +1468,53 @@ async function handle(msg) {
|
|
|
1171
1468
|
);
|
|
1172
1469
|
return;
|
|
1173
1470
|
}
|
|
1471
|
+
if (text === "/provider" || text.startsWith("/provider ")) {
|
|
1472
|
+
if (busy) {
|
|
1473
|
+
await send(chatId, t(l, "busy"));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const arg = text.slice(9).trim().toLowerCase();
|
|
1477
|
+
if (!arg) {
|
|
1478
|
+
await send(chatId, t(l, "providerStatus", currentProvider(), DEFAULT_PROVIDER));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (arg === "default" || arg === "reset") {
|
|
1482
|
+
state.provider = undefined;
|
|
1483
|
+
saveState(state);
|
|
1484
|
+
await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (!["claude", "codex"].includes(arg)) {
|
|
1488
|
+
await send(chatId, t(l, "providerUsage"));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
state.provider = arg;
|
|
1492
|
+
state.ollamaMode = false;
|
|
1493
|
+
saveState(state);
|
|
1494
|
+
await send(chatId, t(l, "providerSet", arg));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1174
1497
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1175
1498
|
const arg = text.slice(6).trim();
|
|
1499
|
+
const provider = currentProvider();
|
|
1500
|
+
const modelStateKey = provider === "codex" ? "codexModel" : "model";
|
|
1501
|
+
const configuredModel = provider === "codex" ? cfg.codexModel : cfg.model;
|
|
1176
1502
|
if (!arg) {
|
|
1177
|
-
const cur = state
|
|
1178
|
-
await send(chatId,
|
|
1503
|
+
const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
|
|
1504
|
+
await send(chatId, provider === "codex"
|
|
1505
|
+
? t(l, "codexModelStatus", cur)
|
|
1506
|
+
: t(l, "claudeModelStatus", cur, CLAUDE_MODEL_SUGGESTIONS));
|
|
1179
1507
|
return;
|
|
1180
1508
|
}
|
|
1181
1509
|
if (arg === "default" || arg === "reset") {
|
|
1182
|
-
state
|
|
1510
|
+
state[modelStateKey] = undefined;
|
|
1183
1511
|
saveState(state);
|
|
1184
|
-
await send(chatId, t(l, "modelReset",
|
|
1512
|
+
await send(chatId, t(l, "modelReset", provider, configuredModel || (l === "ko" ? "기본값" : "default")));
|
|
1185
1513
|
return;
|
|
1186
1514
|
}
|
|
1187
|
-
state
|
|
1515
|
+
state[modelStateKey] = arg;
|
|
1188
1516
|
saveState(state);
|
|
1189
|
-
await send(chatId, t(l, "modelSet", arg));
|
|
1517
|
+
await send(chatId, t(l, "modelSet", provider, arg));
|
|
1190
1518
|
return;
|
|
1191
1519
|
}
|
|
1192
1520
|
if (text === "/autocompact" || text.startsWith("/autocompact ")) {
|
|
@@ -1243,6 +1571,7 @@ async function handle(msg) {
|
|
|
1243
1571
|
return;
|
|
1244
1572
|
}
|
|
1245
1573
|
if (text === "/compact") {
|
|
1574
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "compactProviderUnsupported")); return; }
|
|
1246
1575
|
if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
|
|
1247
1576
|
try {
|
|
1248
1577
|
const res = await runClaude("/compact", state.sessionId);
|
|
@@ -1257,18 +1586,26 @@ async function handle(msg) {
|
|
|
1257
1586
|
return;
|
|
1258
1587
|
}
|
|
1259
1588
|
if (text === "/ollama") {
|
|
1260
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "
|
|
1589
|
+
if (!cfg.ollamaFallback) { await send(chatId, t(l, "ollamaDisabled")); return; }
|
|
1261
1590
|
state.ollamaMode = !state.ollamaMode;
|
|
1262
1591
|
saveState(state);
|
|
1263
1592
|
await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
|
|
1264
1593
|
return;
|
|
1265
1594
|
}
|
|
1266
1595
|
if (text === "/testfallback") {
|
|
1267
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1268
|
-
await send(chatId, "🧪 Ollama 연결 테스트 중…");
|
|
1596
|
+
if (!cfg.codexFallback && !cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1597
|
+
await send(chatId, cfg.codexFallback ? "🧪 Codex fallback 연결 테스트 중…" : "🧪 Ollama fallback 연결 테스트 중…");
|
|
1269
1598
|
try {
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1599
|
+
const prompt = cfg.codexFallback
|
|
1600
|
+
? "Reply with exactly one sentence: Codex fallback is working."
|
|
1601
|
+
: "Reply with exactly one sentence: Ollama fallback is working.";
|
|
1602
|
+
const res = cfg.codexFallback
|
|
1603
|
+
? await runCodex(prompt, l, { trackChild: true, recordHandoff: false })
|
|
1604
|
+
: await runOllama(prompt, l);
|
|
1605
|
+
if (res.ok) {
|
|
1606
|
+
if (cfg.codexFallback && res.sessionId) { state.codexSessionId = res.sessionId; saveState(state); }
|
|
1607
|
+
await send(chatId, res.text);
|
|
1608
|
+
}
|
|
1272
1609
|
else await send(chatId, t(l, "testFallbackFail", res.text));
|
|
1273
1610
|
} catch (e) {
|
|
1274
1611
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
@@ -1276,7 +1613,8 @@ async function handle(msg) {
|
|
|
1276
1613
|
return;
|
|
1277
1614
|
}
|
|
1278
1615
|
if (text === "/new") {
|
|
1279
|
-
state.
|
|
1616
|
+
if (currentProvider() === "codex") state.codexSessionId = undefined;
|
|
1617
|
+
else state.sessionId = undefined;
|
|
1280
1618
|
saveState(state);
|
|
1281
1619
|
await send(chatId, t(l, "newSession"));
|
|
1282
1620
|
return;
|
|
@@ -1291,7 +1629,8 @@ async function handle(msg) {
|
|
|
1291
1629
|
msgQueue.length = 0; // 대기 메시지도 취소
|
|
1292
1630
|
currentChild.kill();
|
|
1293
1631
|
if (reset) {
|
|
1294
|
-
|
|
1632
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1633
|
+
state[primarySessionKey] = prevSessionId;
|
|
1295
1634
|
saveState(state);
|
|
1296
1635
|
}
|
|
1297
1636
|
await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
|
|
@@ -1373,6 +1712,7 @@ async function handle(msg) {
|
|
|
1373
1712
|
// /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
|
|
1374
1713
|
// 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
|
|
1375
1714
|
if (text === "/plan" || text.startsWith("/plan ")) {
|
|
1715
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "planProviderUnsupported")); return; }
|
|
1376
1716
|
const planReq = text.slice(5).trim();
|
|
1377
1717
|
if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
|
|
1378
1718
|
prevSessionId = state.sessionId;
|
|
@@ -1421,18 +1761,28 @@ async function handle(msg) {
|
|
|
1421
1761
|
if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
|
|
1422
1762
|
if (state.ollamaMode) {
|
|
1423
1763
|
try {
|
|
1424
|
-
const oRes = await runOllama(prompt, l, { noHeader: true });
|
|
1425
|
-
if (oRes.ok)
|
|
1764
|
+
const oRes = await runOllama(prompt, l, { noHeader: true, sessionId: state.sessionId });
|
|
1765
|
+
if (oRes.ok) {
|
|
1766
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1767
|
+
await send(chatId, oRes.text);
|
|
1768
|
+
}
|
|
1426
1769
|
else await send(chatId, t(l, "testFallbackFail", oRes.text));
|
|
1427
1770
|
} catch (e) {
|
|
1428
1771
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
1429
1772
|
}
|
|
1430
1773
|
return;
|
|
1431
1774
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1775
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1776
|
+
prevSessionId = state[primarySessionKey]; // /stop --reset 복원 대상 저장
|
|
1777
|
+
const res = await runPrimary(prompt, {
|
|
1778
|
+
sessionId: state[primarySessionKey],
|
|
1779
|
+
lang: l,
|
|
1780
|
+
modelHint: true,
|
|
1781
|
+
trackChild: true,
|
|
1782
|
+
injectMemory: true,
|
|
1783
|
+
});
|
|
1434
1784
|
if (res.sessionId) {
|
|
1435
|
-
state
|
|
1785
|
+
state[primarySessionKey] = res.sessionId;
|
|
1436
1786
|
saveState(state);
|
|
1437
1787
|
}
|
|
1438
1788
|
await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
|