claude-telegram-bot 0.3.40 → 0.4.0
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 +326 -0
- package/README.ko.md +93 -23
- package/README.md +106 -29
- package/bot.mjs +391 -52
- package/config.example.json +11 -1
- package/ctb.mjs +84 -29
- 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,7 +209,10 @@ 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` +
|
|
@@ -209,6 +223,10 @@ const STR = {
|
|
|
209
223
|
`/model default — clear the override`,
|
|
210
224
|
modelSet: (m) => `🧠 Model set to: ${m}`,
|
|
211
225
|
modelReset: (def) => `🧠 Model reset to default (${def}).`,
|
|
226
|
+
providerStatus: (cur, def) => `🤖 Provider: ${cur}${cur === def ? " (config default)" : ` (config default: ${def})`}\nSwitch: /provider claude · /provider codex · /provider default`,
|
|
227
|
+
providerSet: (provider) => `🤖 Default provider set to ${provider}. Existing Claude and Codex sessions are preserved separately.`,
|
|
228
|
+
providerReset: (provider) => `🤖 Provider reset to the config default (${provider}).`,
|
|
229
|
+
providerUsage: "Usage: /provider claude · /provider codex · /provider default",
|
|
212
230
|
autoCompactStatus: (cur, def) =>
|
|
213
231
|
`🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
|
|
214
232
|
"Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
|
|
@@ -236,6 +254,7 @@ const STR = {
|
|
|
236
254
|
"• /new — 대화 맥락 초기화 (새 세션)\n" +
|
|
237
255
|
"• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
|
|
238
256
|
"• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
|
|
257
|
+
"• Codex 폴백 활성화 시 Claude 한도 도달 때 자동으로 대신 실행\n" +
|
|
239
258
|
"• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
|
|
240
259
|
"• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
|
|
241
260
|
"• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
|
|
@@ -244,6 +263,7 @@ const STR = {
|
|
|
244
263
|
"• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
|
|
245
264
|
"• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
|
|
246
265
|
"• /status — 봇 상태·버전 보기\n" +
|
|
266
|
+
"• /provider — 기본 provider 보기·전환\n" +
|
|
247
267
|
"• /model — 모델 보기·전환\n" +
|
|
248
268
|
"• /autocompact — 자동 압축 임계값 보기·설정\n" +
|
|
249
269
|
"• /id — 이 채팅 ID 확인\n" +
|
|
@@ -254,7 +274,7 @@ const STR = {
|
|
|
254
274
|
stopOk: "🛑 작업을 중단했습니다.",
|
|
255
275
|
stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
|
|
256
276
|
stopNoop: "실행 중인 작업이 없습니다.",
|
|
257
|
-
localBusy: "💻 로컬 `ctb
|
|
277
|
+
localBusy: "💻 로컬 `ctb` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
|
|
258
278
|
needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
|
|
259
279
|
cronEmpty:
|
|
260
280
|
"등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
|
|
@@ -284,7 +304,10 @@ const STR = {
|
|
|
284
304
|
status: (i) =>
|
|
285
305
|
`🤖 ${i.name}\n` +
|
|
286
306
|
`• 버전: ${i.version}\n` +
|
|
307
|
+
`• 메인 provider: ${i.provider}\n` +
|
|
308
|
+
`• CLI: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
|
|
287
309
|
`• 모델: ${i.model}\n` +
|
|
310
|
+
`• 폴백: ${i.fallback}\n` +
|
|
288
311
|
`• 세션: ${i.hasSession ? "이어가는 중" : "없음 (새 세션)"}\n` +
|
|
289
312
|
`• 예약 작업: ${i.jobs}개\n` +
|
|
290
313
|
`• 작업 폴더: ${i.projectDir}\n` +
|
|
@@ -295,6 +318,10 @@ const STR = {
|
|
|
295
318
|
`/model default — 오버라이드 해제`,
|
|
296
319
|
modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
|
|
297
320
|
modelReset: (def) => `🧠 모델을 기본값(${def})으로 되돌렸습니다.`,
|
|
321
|
+
providerStatus: (cur, def) => `🤖 현재 provider: ${cur}${cur === def ? " (config 기본값)" : ` (config 기본값: ${def})`}\n전환: /provider claude · /provider codex · /provider default`,
|
|
322
|
+
providerSet: (provider) => `🤖 기본 provider를 ${provider}(으)로 변경했습니다. Claude와 Codex의 기존 세션은 각각 유지됩니다.`,
|
|
323
|
+
providerReset: (provider) => `🤖 provider를 config 기본값(${provider})으로 되돌렸습니다.`,
|
|
324
|
+
providerUsage: "사용법: /provider claude · /provider codex · /provider default",
|
|
298
325
|
autoCompactStatus: (cur, def) =>
|
|
299
326
|
`🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
|
|
300
327
|
"설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
|
|
@@ -316,15 +343,18 @@ const STR = {
|
|
|
316
343
|
compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
|
|
317
344
|
compactFail: (m) => `⚠️ compact 실패: ${m}`,
|
|
318
345
|
compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
|
|
346
|
+
compactProviderUnsupported: "/compact는 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
319
347
|
autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
|
|
320
348
|
planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
|
|
321
349
|
planApprove: "✅ 진행",
|
|
322
350
|
planCancel: "❌ 취소",
|
|
323
351
|
planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
|
|
324
352
|
planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
|
|
353
|
+
planProviderUnsupported: "/plan 승인 흐름은 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
325
354
|
contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
|
|
326
|
-
testFallbackDisabled: "⚠️
|
|
327
|
-
testFallbackFail: (m) => `⚠️
|
|
355
|
+
testFallbackDisabled: "⚠️ 폴백이 비활성화 상태입니다. config.json에 `\"codexFallback\": true`(권장) 또는 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
356
|
+
testFallbackFail: (m) => `⚠️ 폴백 테스트 실패: ${m}`,
|
|
357
|
+
ollamaDisabled: "⚠️ Ollama 모드가 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
328
358
|
ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
|
|
329
359
|
ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
|
|
330
360
|
},
|
|
@@ -350,6 +380,7 @@ const COMMANDS = {
|
|
|
350
380
|
{ command: "cron", description: "List / add / remove scheduled tasks" },
|
|
351
381
|
{ command: "restart", description: "Restart the bot (after syntax check)" },
|
|
352
382
|
{ command: "status", description: "Bot status / version" },
|
|
383
|
+
{ command: "provider", description: "View / switch the default provider" },
|
|
353
384
|
{ command: "model", description: "View / switch the model" },
|
|
354
385
|
{ command: "autocompact", description: "View / set the auto-compact token threshold" },
|
|
355
386
|
{ command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
|
|
@@ -367,6 +398,7 @@ const COMMANDS = {
|
|
|
367
398
|
{ command: "cron", description: "예약 작업 보기·추가·삭제" },
|
|
368
399
|
{ command: "restart", description: "봇 재시작 (문법 검사 후)" },
|
|
369
400
|
{ command: "status", description: "봇 상태·버전 보기" },
|
|
401
|
+
{ command: "provider", description: "기본 provider 보기·전환" },
|
|
370
402
|
{ command: "model", description: "모델 보기·전환" },
|
|
371
403
|
{ command: "autocompact", description: "자동 압축 임계값 보기·설정" },
|
|
372
404
|
{ command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
|
|
@@ -442,6 +474,28 @@ function saveMemory(content) {
|
|
|
442
474
|
writeFileSync(MEMORY_PATH, content);
|
|
443
475
|
}
|
|
444
476
|
|
|
477
|
+
function loadCodexHandoff(maxChars = 12000) {
|
|
478
|
+
try {
|
|
479
|
+
const text = readFileSync(CODEX_HANDOFF_PATH, "utf8").trim();
|
|
480
|
+
return text.length > maxChars ? text.slice(-maxChars) : text;
|
|
481
|
+
} catch {
|
|
482
|
+
return "";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function appendCodexHandoff({ prompt, response, sessionId }) {
|
|
487
|
+
const entry =
|
|
488
|
+
`\n\n## ${new Date().toISOString()}\n` +
|
|
489
|
+
`Codex session: ${sessionId || "(unknown)"}\n\n` +
|
|
490
|
+
`### Telegram prompt\n${String(prompt || "").trim() || "(empty)"}\n\n` +
|
|
491
|
+
`### Codex response\n${String(response || "").trim() || "(empty)"}\n`;
|
|
492
|
+
try {
|
|
493
|
+
writeFileSync(CODEX_HANDOFF_PATH, loadCodexHandoff(80000) + entry);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
console.error("Failed to append Codex handoff", e.message);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
445
499
|
// ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
|
|
446
500
|
function loadState() {
|
|
447
501
|
// 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
|
|
@@ -460,7 +514,12 @@ function saveState(s) {
|
|
|
460
514
|
}
|
|
461
515
|
}
|
|
462
516
|
migrateData(); // 루트 직하 → .claude-bot/ 1회 이주(있으면) 후 state 로드
|
|
463
|
-
let state = loadState(); // { sessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
517
|
+
let state = loadState(); // { sessionId?, codexSessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
518
|
+
if (state.provider && !["claude", "codex"].includes(state.provider)) {
|
|
519
|
+
console.warn(`Ignoring invalid provider override in state: ${state.provider}`);
|
|
520
|
+
state.provider = undefined;
|
|
521
|
+
}
|
|
522
|
+
const currentProvider = () => state.provider || DEFAULT_PROVIDER;
|
|
464
523
|
|
|
465
524
|
// ── 텔레그램 헬퍼 ─────────────────────────────────────────────────────────
|
|
466
525
|
async function tg(method, body) {
|
|
@@ -655,7 +714,11 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
655
714
|
const mem = opts.injectMemory ? loadMemory() : "";
|
|
656
715
|
// 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
|
|
657
716
|
const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
|
|
658
|
-
const
|
|
717
|
+
const handoff = opts.injectHandoff !== false ? loadCodexHandoff() : "";
|
|
718
|
+
const handoffBlock = handoff
|
|
719
|
+
? `## 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}`
|
|
720
|
+
: null;
|
|
721
|
+
const appendSys = [memoryBlock, handoffBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
|
|
659
722
|
if (appendSys) args.push("--append-system-prompt", appendSys);
|
|
660
723
|
if (model) args.push("--model", model);
|
|
661
724
|
if (sessionId) args.push("--resume", sessionId);
|
|
@@ -699,27 +762,224 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
699
762
|
});
|
|
700
763
|
}
|
|
701
764
|
|
|
765
|
+
function extractCodexTextFromJsonl(out) {
|
|
766
|
+
let sessionId;
|
|
767
|
+
let text = "";
|
|
768
|
+
for (const line of String(out || "").split("\n")) {
|
|
769
|
+
if (!line.trim()) continue;
|
|
770
|
+
try {
|
|
771
|
+
const j = JSON.parse(line);
|
|
772
|
+
// Current Codex CLI JSONL format.
|
|
773
|
+
if (j.type === "thread.started" && j.thread_id) sessionId = j.thread_id;
|
|
774
|
+
if (j.type === "item.completed" && j.item?.type === "agent_message" && j.item.text) {
|
|
775
|
+
text = j.item.text;
|
|
776
|
+
}
|
|
777
|
+
// Older app-server event format, kept for compatibility.
|
|
778
|
+
if (j.type === "session_meta" && j.payload?.id) sessionId = j.payload.id;
|
|
779
|
+
const p = j.payload;
|
|
780
|
+
if (p?.type === "message" && p.role === "assistant" && Array.isArray(p.content)) {
|
|
781
|
+
const parts = p.content
|
|
782
|
+
.filter((c) => c.type === "output_text" && c.text)
|
|
783
|
+
.map((c) => c.text);
|
|
784
|
+
if (parts.length) text = parts.join("\n");
|
|
785
|
+
}
|
|
786
|
+
} catch {}
|
|
787
|
+
}
|
|
788
|
+
return { sessionId, text: text.trim() };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function resolveCodexBin() {
|
|
792
|
+
if (cfg.codexBin) return cfg.codexBin;
|
|
793
|
+
const candidates = [
|
|
794
|
+
join(dirname(process.execPath), "codex"),
|
|
795
|
+
join(process.env.HOME || "", ".local", "bin", "codex"),
|
|
796
|
+
"/opt/homebrew/bin/codex",
|
|
797
|
+
"/usr/local/bin/codex",
|
|
798
|
+
];
|
|
799
|
+
return candidates.find((p) => p && existsSync(p)) || "codex";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── Codex 폴백 실행 ──────────────────────────────────────────────────────
|
|
803
|
+
// Claude와 Codex 세션은 호환되지 않는다. Codex는 별도 session id를 state.codexSessionId에
|
|
804
|
+
// 저장하고, Claude 복귀 시 codex-handoff.md 요약을 시스템 프롬프트로 넘겨 맥락을 연결한다.
|
|
805
|
+
function runCodex(prompt, lang = "en", opts = {}) {
|
|
806
|
+
return new Promise((resolve) => {
|
|
807
|
+
const header = opts.noHeader ? "" : (lang === "ko" ? "🤖 Codex 폴백\n\n" : "🤖 Codex fallback\n\n");
|
|
808
|
+
const lastPath = join(BOT_DIR, `codex-last-message-${process.pid}.txt`);
|
|
809
|
+
const model = state.codexModel || cfg.codexModel;
|
|
810
|
+
const timeoutMs = cfg.codexTimeout || 600_000;
|
|
811
|
+
const args = ["exec"];
|
|
812
|
+
const resumeSessionId = Object.prototype.hasOwnProperty.call(opts, "sessionId")
|
|
813
|
+
? opts.sessionId
|
|
814
|
+
: state.codexSessionId;
|
|
815
|
+
let codexPrompt = prompt;
|
|
816
|
+
if (!resumeSessionId && opts.injectMemory) {
|
|
817
|
+
const context = [loadMemory(), cfg.persona, cfg.appendSystemPrompt].filter(Boolean).join("\n\n");
|
|
818
|
+
if (context) codexPrompt = `Project instructions and persistent context:\n${context}\n\nUser request:\n${prompt}`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (resumeSessionId) {
|
|
822
|
+
args.push("resume", "--json", "-o", lastPath);
|
|
823
|
+
if (model) args.push("--model", model);
|
|
824
|
+
args.push(resumeSessionId, "-");
|
|
825
|
+
} else {
|
|
826
|
+
args.push("--json", "-o", lastPath, "-C", cfg.projectDir, "--sandbox", cfg.codexSandbox || "workspace-write");
|
|
827
|
+
if (model) args.push("--model", model);
|
|
828
|
+
args.push("-");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const child = spawn(resolveCodexBin(), args, {
|
|
832
|
+
cwd: cfg.projectDir,
|
|
833
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
834
|
+
});
|
|
835
|
+
if (opts.trackChild) currentChild = child;
|
|
836
|
+
|
|
837
|
+
let settled = false;
|
|
838
|
+
const finish = (value) => {
|
|
839
|
+
if (settled) return;
|
|
840
|
+
settled = true;
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
currentChild = null;
|
|
843
|
+
resolve(value);
|
|
844
|
+
};
|
|
845
|
+
const timer = setTimeout(() => {
|
|
846
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
847
|
+
finish({ ok: false, text: `Codex timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
848
|
+
}, timeoutMs);
|
|
849
|
+
|
|
850
|
+
let out = "", err = "";
|
|
851
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
852
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
853
|
+
child.stdin.on("error", () => {});
|
|
854
|
+
child.on("error", (e) => finish({ ok: false, text: `Failed to start codex: ${e.message}` }));
|
|
855
|
+
child.on("close", (code) => {
|
|
856
|
+
const parsed = extractCodexTextFromJsonl(out);
|
|
857
|
+
let finalText = parsed.text;
|
|
858
|
+
if (!finalText) {
|
|
859
|
+
try { finalText = readFileSync(lastPath, "utf8").trim(); } catch {}
|
|
860
|
+
}
|
|
861
|
+
const sessionId = parsed.sessionId || resumeSessionId;
|
|
862
|
+
if (code === 0 && finalText) {
|
|
863
|
+
if (opts.recordHandoff !== false) appendCodexHandoff({ prompt, response: finalText, sessionId });
|
|
864
|
+
return finish({ ok: true, text: header + finalText, sessionId });
|
|
865
|
+
}
|
|
866
|
+
const raw = (err || out || finalText || "no output").slice(0, 1000);
|
|
867
|
+
finish({ ok: false, text: `Codex failed (exit ${code}):\n${raw}`, canFallback: isFallbackError(raw, code) });
|
|
868
|
+
});
|
|
869
|
+
child.stdin.end(codexPrompt);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function runPrimary(prompt, opts = {}) {
|
|
874
|
+
if (currentProvider() === "codex") {
|
|
875
|
+
return runCodex(prompt, opts.lang || BOT_LANG, {
|
|
876
|
+
noHeader: true,
|
|
877
|
+
trackChild: opts.trackChild,
|
|
878
|
+
injectMemory: opts.injectMemory,
|
|
879
|
+
recordHandoff: opts.recordHandoff,
|
|
880
|
+
...(Object.prototype.hasOwnProperty.call(opts, "sessionId") ? { sessionId: opts.sessionId } : {}),
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return runClaude(prompt, opts.sessionId, opts);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// launchd 데몬은 로그인 셸(zsh)을 거치지 않아 .zshrc/brew shellenv의 PATH를 상속받지 못한다
|
|
887
|
+
// (claudeBin과 동일한 이유). ollamaBin 미지정 시 흔한 설치 경로를 순서대로 탐색한다.
|
|
888
|
+
function resolveOllamaBin() {
|
|
889
|
+
if (cfg.ollamaBin) return cfg.ollamaBin;
|
|
890
|
+
const candidates = ["/opt/homebrew/bin/ollama", "/usr/local/bin/ollama", "/usr/bin/ollama"];
|
|
891
|
+
return candidates.find(existsSync) || "ollama";
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function getCliVersion(bin, kind) {
|
|
895
|
+
return new Promise((resolve) => {
|
|
896
|
+
const child = spawn(bin, ["--version"], {
|
|
897
|
+
cwd: cfg.projectDir,
|
|
898
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
899
|
+
});
|
|
900
|
+
let output = "", settled = false;
|
|
901
|
+
const finish = (value) => {
|
|
902
|
+
if (settled) return;
|
|
903
|
+
settled = true;
|
|
904
|
+
clearTimeout(timer);
|
|
905
|
+
resolve(value);
|
|
906
|
+
};
|
|
907
|
+
child.stdout.on("data", (d) => { output += d; });
|
|
908
|
+
child.stderr.on("data", (d) => { output += d; });
|
|
909
|
+
child.on("error", () => finish("unavailable"));
|
|
910
|
+
child.on("close", () => {
|
|
911
|
+
const patterns = {
|
|
912
|
+
claude: /([0-9]+\.[0-9]+\.[0-9]+)/,
|
|
913
|
+
codex: /codex(?:-cli)?\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
914
|
+
ollama: /(?:client\s+version\s+is|ollama\s+version\s+is)\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
915
|
+
};
|
|
916
|
+
finish(output.match(patterns[kind])?.[1] || "unknown");
|
|
917
|
+
});
|
|
918
|
+
const timer = setTimeout(() => {
|
|
919
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
920
|
+
finish("unavailable");
|
|
921
|
+
}, 3000);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function getCliVersions() {
|
|
926
|
+
const [claude, codex, ollama] = await Promise.all([
|
|
927
|
+
getCliVersion(cfg.claudeBin || "claude", "claude"),
|
|
928
|
+
getCliVersion(resolveCodexBin(), "codex"),
|
|
929
|
+
getCliVersion(resolveOllamaBin(), "ollama"),
|
|
930
|
+
]);
|
|
931
|
+
return { claude, codex, ollama };
|
|
932
|
+
}
|
|
933
|
+
|
|
702
934
|
// ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
935
|
+
// `ollama launch claude` 로 Claude Code CLI를 로컬 모델로 구동한다. opts.sessionId가
|
|
936
|
+
// 있으면 `--resume`으로 기존 대화 맥락을 그대로 이어받는다(HTTP api/chat 방식과 달리 세션 유지).
|
|
937
|
+
// 터미널 검증: ollama launch claude --model <m> --yes -- -p -- <prompt> --resume <id>
|
|
938
|
+
function runOllama(prompt, lang = "en", opts = {}) {
|
|
939
|
+
return new Promise((resolve) => {
|
|
940
|
+
const header = opts.noHeader ? "" : (lang === "ko"
|
|
941
|
+
? "🌙 Claude가 잠시 쉬고 있어요. 그동안 저(로컬 모델)는 복귀하면 Claude에게 넘길 내용을 정리하는 걸 도와드릴게요 — 코딩·파일 작업은 Claude가 돌아온 뒤에요.\n\n"
|
|
942
|
+
: "🌙 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");
|
|
943
|
+
const model = cfg.ollamaModel || "qwen3.5:4b";
|
|
944
|
+
const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
|
|
945
|
+
// 폴백 경로에서만: Claude가 막혀 소형 로컬 모델로 대응 중임을 알리고, 코딩·도구 작업을 시도하는
|
|
946
|
+
// 대신 사용자가 Claude 복귀 후 이어갈 수 있도록 요청 정리·요약을 돕는 조수 역할을 지시한다.
|
|
947
|
+
const fallbackRole = opts.fallback
|
|
948
|
+
? "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."
|
|
949
|
+
: "";
|
|
950
|
+
const appendSys = [cfg.persona, brevity, fallbackRole].filter(Boolean).join("\n\n");
|
|
951
|
+
// `--` 앞은 ollama launch 플래그, 뒤는 claude 플래그로 전달된다.
|
|
952
|
+
const claudeArgs = ["--output-format", "json"];
|
|
953
|
+
if (appendSys) claudeArgs.push("--append-system-prompt", appendSys);
|
|
954
|
+
if (opts.sessionId) claudeArgs.push("--resume", opts.sessionId);
|
|
955
|
+
claudeArgs.push("-p", "--", prompt);
|
|
956
|
+
const args = ["launch", "claude", "--model", model, "--yes", "--", ...claudeArgs];
|
|
957
|
+
|
|
958
|
+
const child = spawn(resolveOllamaBin(), args, {
|
|
959
|
+
cwd: cfg.projectDir,
|
|
960
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
961
|
+
});
|
|
962
|
+
// 로컬 4B 모델 콜드스타트는 첫 응답까지 수 분이 걸릴 수 있어 기본 타임아웃을 넉넉히 잡는다.
|
|
963
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), cfg.ollamaTimeout || 360_000);
|
|
964
|
+
let out = "", err = "";
|
|
965
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
966
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
967
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, text: `Failed to start ollama: ${e.message}` }); });
|
|
968
|
+
child.on("close", () => {
|
|
969
|
+
clearTimeout(timer);
|
|
970
|
+
try {
|
|
971
|
+
const j = JSON.parse(out);
|
|
972
|
+
const text = (j.result ?? "").trim();
|
|
973
|
+
if (j.is_error || !text) return resolve({ ok: false, text: text || "no response" });
|
|
974
|
+
resolve({ ok: true, text: header + text, sessionId: j.session_id });
|
|
975
|
+
} catch {
|
|
976
|
+
// JSON 파싱 실패 시 원시 출력으로 폴백 (구버전 ollama·비-JSON 출력 대비)
|
|
977
|
+
const text = (out || "").trim();
|
|
978
|
+
if (text) return resolve({ ok: true, text: header + text });
|
|
979
|
+
resolve({ ok: false, text: (err || "no output").slice(0, 500) });
|
|
980
|
+
}
|
|
981
|
+
});
|
|
718
982
|
});
|
|
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
983
|
}
|
|
724
984
|
|
|
725
985
|
// ── 크론 스케줄러 ─────────────────────────────────────────────────────────
|
|
@@ -793,7 +1053,7 @@ async function runScheduled(job) {
|
|
|
793
1053
|
busy = true;
|
|
794
1054
|
const started = Date.now();
|
|
795
1055
|
try {
|
|
796
|
-
const res = await
|
|
1056
|
+
const res = await runPrimary(job.prompt, { sessionId: null, lang: BOT_LANG, recordHandoff: false }); // 새 세션 (state 미저장)
|
|
797
1057
|
// 조용한 예약 작업: 출력이 비었거나 정확히 "SKIP"이면 전송하지 않는다.
|
|
798
1058
|
// (예: "조건 충족 시에만 알리고, 아니면 SKIP만 출력해" 식의 조건부 알림용)
|
|
799
1059
|
if (res.ok) {
|
|
@@ -850,7 +1110,7 @@ async function extractCron(input, l) {
|
|
|
850
1110
|
"Reply with ONLY one line of JSON, no prose or code block: " +
|
|
851
1111
|
'{"cron":"0 9 * * *","prompt":"the task","label":"short name","human":"every day at 09:00"}\n\n' +
|
|
852
1112
|
`request: ${input}`;
|
|
853
|
-
const res = await
|
|
1113
|
+
const res = await runPrimary(ask, { sessionId: null, lang: l, recordHandoff: false }); // 새 세션 (대화 맥락과 분리)
|
|
854
1114
|
const m = res.text && res.text.match(/\{[\s\S]*\}/);
|
|
855
1115
|
if (!res.ok || !m) return { error: res.text || t(l, "extractFail") };
|
|
856
1116
|
let obj;
|
|
@@ -1011,11 +1271,30 @@ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
|
|
|
1011
1271
|
async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
1012
1272
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
1013
1273
|
if (!res.ok) {
|
|
1014
|
-
//
|
|
1274
|
+
// Codex 폴백: 레이트리밋·크레딧 에러이고 codexFallback 켜져 있으면 reserve 전에 Codex로 재시도
|
|
1275
|
+
if (currentProvider() === "claude" && cfg.codexFallback && res.canFallback && !stopping) {
|
|
1276
|
+
try {
|
|
1277
|
+
const cRes = await runCodex(prompt, l, { trackChild: true });
|
|
1278
|
+
if (cRes.ok) {
|
|
1279
|
+
if (cRes.sessionId) { state.codexSessionId = cRes.sessionId; saveState(state); }
|
|
1280
|
+
await send(chatId, cRes.text); return;
|
|
1281
|
+
}
|
|
1282
|
+
console.error(cRes.text);
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
console.error("Codex fallback failed:", e.message);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// Ollama 폴백: Codex 미사용/실패 시, 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
|
|
1015
1288
|
if (cfg.ollamaFallback && res.canFallback && !stopping) {
|
|
1016
1289
|
try {
|
|
1017
|
-
const oRes = await runOllama(prompt, l
|
|
1018
|
-
|
|
1290
|
+
const oRes = await runOllama(prompt, l, {
|
|
1291
|
+
fallback: true,
|
|
1292
|
+
sessionId: currentProvider() === "claude" ? state.sessionId : undefined,
|
|
1293
|
+
});
|
|
1294
|
+
if (oRes.ok) {
|
|
1295
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1296
|
+
await send(chatId, oRes.text); return;
|
|
1297
|
+
}
|
|
1019
1298
|
} catch {}
|
|
1020
1299
|
}
|
|
1021
1300
|
// 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
|
|
@@ -1039,7 +1318,7 @@ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
|
1039
1318
|
if (!stopping) await send(chatId, res.text + footer);
|
|
1040
1319
|
// 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
|
|
1041
1320
|
const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
|
|
1042
|
-
if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1321
|
+
if (currentProvider() === "claude" && compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1043
1322
|
try {
|
|
1044
1323
|
const cr = await runClaude("/compact", state.sessionId);
|
|
1045
1324
|
if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
|
|
@@ -1153,7 +1432,7 @@ async function handle(msg) {
|
|
|
1153
1432
|
return;
|
|
1154
1433
|
}
|
|
1155
1434
|
if (text === "/status") {
|
|
1156
|
-
const latest = await fetchLatestVersion();
|
|
1435
|
+
const [latest, cliVersions] = await Promise.all([fetchLatestVersion(), getCliVersions()]);
|
|
1157
1436
|
const versionStr = !latest || !isNewer(latest, VERSION)
|
|
1158
1437
|
? VERSION
|
|
1159
1438
|
: `${VERSION} → ${latest} ✨`;
|
|
@@ -1161,9 +1440,19 @@ async function handle(msg) {
|
|
|
1161
1440
|
chatId,
|
|
1162
1441
|
t(l, "status", {
|
|
1163
1442
|
version: versionStr,
|
|
1443
|
+
provider: currentProvider(),
|
|
1444
|
+
cliVersions,
|
|
1164
1445
|
name: cfg.name || "claude-telegram-bot",
|
|
1165
|
-
model:
|
|
1166
|
-
|
|
1446
|
+
model: (currentProvider() === "codex" ? state.codexModel || cfg.codexModel : state.model || cfg.model)
|
|
1447
|
+
|| (l === "ko" ? "(기본값)" : "(default)"),
|
|
1448
|
+
fallback: currentProvider() === "codex"
|
|
1449
|
+
? (cfg.ollamaFallback ? "Ollama" : (l === "ko" ? "꺼짐" : "off"))
|
|
1450
|
+
: cfg.codexFallback
|
|
1451
|
+
? `Codex${state.codexSessionId ? (l === "ko" ? " (세션 있음)" : " (session active)") : ""}`
|
|
1452
|
+
: cfg.ollamaFallback
|
|
1453
|
+
? "Ollama"
|
|
1454
|
+
: (l === "ko" ? "꺼짐" : "off"),
|
|
1455
|
+
hasSession: Boolean(currentProvider() === "codex" ? state.codexSessionId : state.sessionId),
|
|
1167
1456
|
jobs: schedule.length,
|
|
1168
1457
|
projectDir: cfg.projectDir,
|
|
1169
1458
|
permissionMode: cfg.permissionMode || "acceptEdits",
|
|
@@ -1171,20 +1460,48 @@ async function handle(msg) {
|
|
|
1171
1460
|
);
|
|
1172
1461
|
return;
|
|
1173
1462
|
}
|
|
1463
|
+
if (text === "/provider" || text.startsWith("/provider ")) {
|
|
1464
|
+
if (busy) {
|
|
1465
|
+
await send(chatId, t(l, "busy"));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const arg = text.slice(9).trim().toLowerCase();
|
|
1469
|
+
if (!arg) {
|
|
1470
|
+
await send(chatId, t(l, "providerStatus", currentProvider(), DEFAULT_PROVIDER));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (arg === "default" || arg === "reset") {
|
|
1474
|
+
state.provider = undefined;
|
|
1475
|
+
saveState(state);
|
|
1476
|
+
await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (!["claude", "codex"].includes(arg)) {
|
|
1480
|
+
await send(chatId, t(l, "providerUsage"));
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
state.provider = arg;
|
|
1484
|
+
state.ollamaMode = false;
|
|
1485
|
+
saveState(state);
|
|
1486
|
+
await send(chatId, t(l, "providerSet", arg));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1174
1489
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1175
1490
|
const arg = text.slice(6).trim();
|
|
1491
|
+
const modelStateKey = currentProvider() === "codex" ? "codexModel" : "model";
|
|
1492
|
+
const configuredModel = currentProvider() === "codex" ? cfg.codexModel : cfg.model;
|
|
1176
1493
|
if (!arg) {
|
|
1177
|
-
const cur = state
|
|
1494
|
+
const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
|
|
1178
1495
|
await send(chatId, t(l, "modelStatus", cur, MODEL_SUGGESTIONS));
|
|
1179
1496
|
return;
|
|
1180
1497
|
}
|
|
1181
1498
|
if (arg === "default" || arg === "reset") {
|
|
1182
|
-
state
|
|
1499
|
+
state[modelStateKey] = undefined;
|
|
1183
1500
|
saveState(state);
|
|
1184
|
-
await send(chatId, t(l, "modelReset",
|
|
1501
|
+
await send(chatId, t(l, "modelReset", configuredModel || (l === "ko" ? "기본값" : "default")));
|
|
1185
1502
|
return;
|
|
1186
1503
|
}
|
|
1187
|
-
state
|
|
1504
|
+
state[modelStateKey] = arg;
|
|
1188
1505
|
saveState(state);
|
|
1189
1506
|
await send(chatId, t(l, "modelSet", arg));
|
|
1190
1507
|
return;
|
|
@@ -1243,6 +1560,7 @@ async function handle(msg) {
|
|
|
1243
1560
|
return;
|
|
1244
1561
|
}
|
|
1245
1562
|
if (text === "/compact") {
|
|
1563
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "compactProviderUnsupported")); return; }
|
|
1246
1564
|
if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
|
|
1247
1565
|
try {
|
|
1248
1566
|
const res = await runClaude("/compact", state.sessionId);
|
|
@@ -1257,18 +1575,26 @@ async function handle(msg) {
|
|
|
1257
1575
|
return;
|
|
1258
1576
|
}
|
|
1259
1577
|
if (text === "/ollama") {
|
|
1260
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "
|
|
1578
|
+
if (!cfg.ollamaFallback) { await send(chatId, t(l, "ollamaDisabled")); return; }
|
|
1261
1579
|
state.ollamaMode = !state.ollamaMode;
|
|
1262
1580
|
saveState(state);
|
|
1263
1581
|
await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
|
|
1264
1582
|
return;
|
|
1265
1583
|
}
|
|
1266
1584
|
if (text === "/testfallback") {
|
|
1267
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1268
|
-
await send(chatId, "🧪 Ollama 연결 테스트 중…");
|
|
1585
|
+
if (!cfg.codexFallback && !cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1586
|
+
await send(chatId, cfg.codexFallback ? "🧪 Codex fallback 연결 테스트 중…" : "🧪 Ollama fallback 연결 테스트 중…");
|
|
1269
1587
|
try {
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1588
|
+
const prompt = cfg.codexFallback
|
|
1589
|
+
? "Reply with exactly one sentence: Codex fallback is working."
|
|
1590
|
+
: "Reply with exactly one sentence: Ollama fallback is working.";
|
|
1591
|
+
const res = cfg.codexFallback
|
|
1592
|
+
? await runCodex(prompt, l, { trackChild: true, recordHandoff: false })
|
|
1593
|
+
: await runOllama(prompt, l);
|
|
1594
|
+
if (res.ok) {
|
|
1595
|
+
if (cfg.codexFallback && res.sessionId) { state.codexSessionId = res.sessionId; saveState(state); }
|
|
1596
|
+
await send(chatId, res.text);
|
|
1597
|
+
}
|
|
1272
1598
|
else await send(chatId, t(l, "testFallbackFail", res.text));
|
|
1273
1599
|
} catch (e) {
|
|
1274
1600
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
@@ -1276,7 +1602,8 @@ async function handle(msg) {
|
|
|
1276
1602
|
return;
|
|
1277
1603
|
}
|
|
1278
1604
|
if (text === "/new") {
|
|
1279
|
-
state.
|
|
1605
|
+
if (currentProvider() === "codex") state.codexSessionId = undefined;
|
|
1606
|
+
else state.sessionId = undefined;
|
|
1280
1607
|
saveState(state);
|
|
1281
1608
|
await send(chatId, t(l, "newSession"));
|
|
1282
1609
|
return;
|
|
@@ -1291,7 +1618,8 @@ async function handle(msg) {
|
|
|
1291
1618
|
msgQueue.length = 0; // 대기 메시지도 취소
|
|
1292
1619
|
currentChild.kill();
|
|
1293
1620
|
if (reset) {
|
|
1294
|
-
|
|
1621
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1622
|
+
state[primarySessionKey] = prevSessionId;
|
|
1295
1623
|
saveState(state);
|
|
1296
1624
|
}
|
|
1297
1625
|
await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
|
|
@@ -1373,6 +1701,7 @@ async function handle(msg) {
|
|
|
1373
1701
|
// /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
|
|
1374
1702
|
// 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
|
|
1375
1703
|
if (text === "/plan" || text.startsWith("/plan ")) {
|
|
1704
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "planProviderUnsupported")); return; }
|
|
1376
1705
|
const planReq = text.slice(5).trim();
|
|
1377
1706
|
if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
|
|
1378
1707
|
prevSessionId = state.sessionId;
|
|
@@ -1421,18 +1750,28 @@ async function handle(msg) {
|
|
|
1421
1750
|
if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
|
|
1422
1751
|
if (state.ollamaMode) {
|
|
1423
1752
|
try {
|
|
1424
|
-
const oRes = await runOllama(prompt, l, { noHeader: true });
|
|
1425
|
-
if (oRes.ok)
|
|
1753
|
+
const oRes = await runOllama(prompt, l, { noHeader: true, sessionId: state.sessionId });
|
|
1754
|
+
if (oRes.ok) {
|
|
1755
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1756
|
+
await send(chatId, oRes.text);
|
|
1757
|
+
}
|
|
1426
1758
|
else await send(chatId, t(l, "testFallbackFail", oRes.text));
|
|
1427
1759
|
} catch (e) {
|
|
1428
1760
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
1429
1761
|
}
|
|
1430
1762
|
return;
|
|
1431
1763
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1764
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1765
|
+
prevSessionId = state[primarySessionKey]; // /stop --reset 복원 대상 저장
|
|
1766
|
+
const res = await runPrimary(prompt, {
|
|
1767
|
+
sessionId: state[primarySessionKey],
|
|
1768
|
+
lang: l,
|
|
1769
|
+
modelHint: true,
|
|
1770
|
+
trackChild: true,
|
|
1771
|
+
injectMemory: true,
|
|
1772
|
+
});
|
|
1434
1773
|
if (res.sessionId) {
|
|
1435
|
-
state
|
|
1774
|
+
state[primarySessionKey] = res.sessionId;
|
|
1436
1775
|
saveState(state);
|
|
1437
1776
|
}
|
|
1438
1777
|
await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
|