arisa 2.3.50 → 2.3.52
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/package.json +1 -1
- package/src/core/index.ts +8 -28
- package/src/core/processor.ts +2 -5
- package/src/daemon/setup.ts +89 -142
- package/src/core/history.ts +0 -193
package/package.json
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
import { transcribeAudio, describeImage, generateSpeech, isMediaConfigured, isSpeechConfigured } from "./media";
|
|
28
28
|
import { detectFiles } from "./file-detector";
|
|
29
29
|
|
|
30
|
-
import { addExchange, getForeignContext, clearHistory, getLastBackend } from "./history";
|
|
31
30
|
import { getOnboarding, checkDeps } from "./onboarding";
|
|
32
31
|
import { initScheduler, addTask, cancelAllChatTasks } from "./scheduler";
|
|
33
32
|
import { detectScheduleIntent } from "./intent";
|
|
@@ -60,13 +59,6 @@ function getBackend(chatId: string): "claude" | "codex" {
|
|
|
60
59
|
const current = backendState.get(chatId);
|
|
61
60
|
if (current) return preferInstalled(current);
|
|
62
61
|
|
|
63
|
-
const fromHistory = getLastBackend(chatId);
|
|
64
|
-
if (fromHistory) {
|
|
65
|
-
const resolved = preferInstalled(fromHistory);
|
|
66
|
-
backendState.set(chatId, resolved);
|
|
67
|
-
return resolved;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
62
|
return preferInstalled(defaultBackend());
|
|
71
63
|
}
|
|
72
64
|
|
|
@@ -164,7 +156,6 @@ ${messageText}`;
|
|
|
164
156
|
if (msg.command === "/reset") {
|
|
165
157
|
const { writeFileSync } = await import("fs");
|
|
166
158
|
writeFileSync(config.resetFlagPath, "reset");
|
|
167
|
-
clearHistory(msg.chatId);
|
|
168
159
|
const { resetRouterState } = await import("./router");
|
|
169
160
|
resetRouterState();
|
|
170
161
|
const response: CoreResponse = { text: "Conversation reset! Next message will start a fresh conversation." };
|
|
@@ -361,27 +352,22 @@ ${messageText}`;
|
|
|
361
352
|
const backend = getBackend(msg.chatId);
|
|
362
353
|
const canFallback = backend === "codex" ? deps.claude : deps.codex;
|
|
363
354
|
let agentResponse: string;
|
|
364
|
-
let historyResponse: string | null = null;
|
|
365
355
|
let usedBackend: "claude" | "codex" = backend;
|
|
366
356
|
|
|
367
|
-
|
|
368
|
-
const foreignCtx = getForeignContext(msg.chatId, backend);
|
|
369
|
-
const enrichedMessage = foreignCtx ? foreignCtx + messageText : messageText;
|
|
370
|
-
|
|
371
|
-
log.info(`Routing | backend: ${backend} | foreignCtx: ${!!foreignCtx} | enrichedChars: ${enrichedMessage.length}`);
|
|
357
|
+
log.info(`Routing | backend: ${backend} | messageChars: ${messageText.length}`);
|
|
372
358
|
|
|
373
359
|
if (backend === "codex") {
|
|
374
360
|
try {
|
|
375
|
-
agentResponse = await processWithCodex(
|
|
361
|
+
agentResponse = await processWithCodex(messageText);
|
|
376
362
|
if (agentResponse.startsWith("Error processing with Codex") && canFallback) {
|
|
377
363
|
log.warn("Codex failed, falling back to Claude");
|
|
378
|
-
agentResponse = await processWithClaude(
|
|
364
|
+
agentResponse = await processWithClaude(messageText, msg.chatId);
|
|
379
365
|
usedBackend = "claude";
|
|
380
366
|
}
|
|
381
367
|
} catch (error) {
|
|
382
368
|
if (canFallback) {
|
|
383
369
|
log.warn(`Codex threw, falling back to Claude: ${error}`);
|
|
384
|
-
agentResponse = await processWithClaude(
|
|
370
|
+
agentResponse = await processWithClaude(messageText, msg.chatId);
|
|
385
371
|
usedBackend = "claude";
|
|
386
372
|
} else {
|
|
387
373
|
agentResponse = "Error processing with Codex. Please try again.";
|
|
@@ -389,23 +375,20 @@ ${messageText}`;
|
|
|
389
375
|
}
|
|
390
376
|
} else {
|
|
391
377
|
try {
|
|
392
|
-
agentResponse = await processWithClaude(
|
|
378
|
+
agentResponse = await processWithClaude(messageText, msg.chatId);
|
|
393
379
|
if (agentResponse.startsWith("Error:") && canFallback) {
|
|
394
380
|
log.warn("Claude failed, falling back to Codex");
|
|
395
|
-
agentResponse = await processWithCodex(
|
|
381
|
+
agentResponse = await processWithCodex(messageText);
|
|
396
382
|
usedBackend = "codex";
|
|
397
383
|
}
|
|
398
384
|
if (isClaudeRateLimitResponse(agentResponse) && canFallback) {
|
|
399
385
|
log.warn("Claude credits exhausted, falling back to Codex");
|
|
400
|
-
const codexResponse = await processWithCodex(
|
|
386
|
+
const codexResponse = await processWithCodex(messageText);
|
|
401
387
|
if (isCodexAuthRequiredResponse(codexResponse)) {
|
|
402
388
|
agentResponse = `${agentResponse}\n---CHUNK---\n${codexResponse}`;
|
|
403
389
|
} else {
|
|
404
390
|
agentResponse = `Claude is out of credits right now, so I switched this reply to Codex.\n---CHUNK---\n${codexResponse}`;
|
|
405
|
-
historyResponse = codexResponse;
|
|
406
391
|
usedBackend = "codex";
|
|
407
|
-
// Persist the switch so subsequent messages don't keep re-injecting
|
|
408
|
-
// cross-backend context while Claude has no credits.
|
|
409
392
|
backendState.set(msg.chatId, "codex");
|
|
410
393
|
}
|
|
411
394
|
}
|
|
@@ -413,7 +396,7 @@ ${messageText}`;
|
|
|
413
396
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
414
397
|
if (canFallback) {
|
|
415
398
|
log.warn(`Claude threw, falling back to Codex: ${errMsg}`);
|
|
416
|
-
agentResponse = await processWithCodex(
|
|
399
|
+
agentResponse = await processWithCodex(messageText);
|
|
417
400
|
usedBackend = "codex";
|
|
418
401
|
} else {
|
|
419
402
|
agentResponse = `Claude error: ${errMsg.slice(0, 200)}`;
|
|
@@ -421,9 +404,6 @@ ${messageText}`;
|
|
|
421
404
|
}
|
|
422
405
|
}
|
|
423
406
|
|
|
424
|
-
// Log exchange for shared history
|
|
425
|
-
addExchange(msg.chatId, messageText, historyResponse ?? agentResponse, usedBackend);
|
|
426
|
-
|
|
427
407
|
log.info(`Response | backend: ${usedBackend} | responseChars: ${agentResponse.length}`);
|
|
428
408
|
log.debug(`Response raw >>>>\n${agentResponse}\n<<<<`);
|
|
429
409
|
|
package/src/core/processor.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { selectModel } from "./router";
|
|
15
|
-
import { getRecentHistory } from "./history";
|
|
16
15
|
import { shouldContinue } from "./context";
|
|
17
16
|
import { config } from "../shared/config";
|
|
18
17
|
import { createLogger } from "../shared/logger";
|
|
@@ -127,12 +126,10 @@ async function processNext() {
|
|
|
127
126
|
|
|
128
127
|
async function runClaude(message: string, chatId: string): Promise<string> {
|
|
129
128
|
const model = selectModel(message);
|
|
130
|
-
const historyContext = getRecentHistory(chatId);
|
|
131
129
|
const start = Date.now();
|
|
132
|
-
const prompt = withSoul(
|
|
130
|
+
const prompt = withSoul(message);
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
log.info(`Model: ${model.model} (${model.reason}) | History: ${historyCount} exchanges`);
|
|
132
|
+
log.info(`Model: ${model.model} (${model.reason})`);
|
|
136
133
|
|
|
137
134
|
const args = ["--dangerously-skip-permissions", "--output-format", "text"];
|
|
138
135
|
|
package/src/daemon/setup.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module daemon/setup
|
|
3
|
-
* @role
|
|
3
|
+
* @role Idempotent startup setup — runs every boot, checks real state.
|
|
4
4
|
* @responsibilities
|
|
5
5
|
* - Check required config (TELEGRAM_BOT_TOKEN)
|
|
6
6
|
* - Check optional config (OPENAI_API_KEY)
|
|
7
7
|
* - Detect / install missing CLIs (Claude, Codex)
|
|
8
|
-
* -
|
|
8
|
+
* - Check CLI auth and offer login if needed
|
|
9
9
|
* - Persist tokens to both .env and encrypted DB
|
|
10
10
|
* @dependencies shared/paths, shared/secrets, shared/ai-cli
|
|
11
11
|
* @effects Reads stdin, writes runtime .env, spawns install/login processes
|
|
@@ -18,7 +18,6 @@ import { secrets, setSecret } from "../shared/secrets";
|
|
|
18
18
|
import { isAgentCliInstalled, buildBunWrappedAgentCliCommand, type AgentCliName } from "../shared/ai-cli";
|
|
19
19
|
|
|
20
20
|
const ENV_PATH = join(dataDir, ".env");
|
|
21
|
-
const SETUP_DONE_KEY = "ARISA_SETUP_COMPLETE";
|
|
22
21
|
|
|
23
22
|
const CLI_PACKAGES: Record<AgentCliName, string> = {
|
|
24
23
|
claude: "@anthropic-ai/claude-code",
|
|
@@ -32,6 +31,8 @@ function loadExistingEnv(): Record<string, string> {
|
|
|
32
31
|
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.+)$/);
|
|
33
32
|
if (match) vars[match[1]] = match[2].trim();
|
|
34
33
|
}
|
|
34
|
+
// Clean up legacy flag — state is now derived from actual config
|
|
35
|
+
delete vars["ARISA_SETUP_COMPLETE"];
|
|
35
36
|
return vars;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -61,8 +62,6 @@ export async function runSetup(): Promise<boolean> {
|
|
|
61
62
|
const telegramSecret = await secrets.telegram();
|
|
62
63
|
const openaiSecret = await secrets.openai();
|
|
63
64
|
let changed = false;
|
|
64
|
-
const setupDone = vars[SETUP_DONE_KEY] === "1" || process.env[SETUP_DONE_KEY] === "1";
|
|
65
|
-
const isFirstRun = !setupDone;
|
|
66
65
|
|
|
67
66
|
// Try to load inquirer for interactive mode
|
|
68
67
|
let inq: typeof import("@inquirer/prompts") | null = null;
|
|
@@ -80,7 +79,7 @@ export async function runSetup(): Promise<boolean> {
|
|
|
80
79
|
const hasOpenAI = !!(vars.OPENAI_API_KEY || process.env.OPENAI_API_KEY || openaiSecret);
|
|
81
80
|
|
|
82
81
|
if (!hasTelegram) {
|
|
83
|
-
|
|
82
|
+
console.log("\n🔧 Arisa Setup\n");
|
|
84
83
|
|
|
85
84
|
let token: string;
|
|
86
85
|
if (inq) {
|
|
@@ -109,170 +108,119 @@ export async function runSetup(): Promise<boolean> {
|
|
|
109
108
|
console.log(`[setup] TELEGRAM_BOT_TOKEN found in ${src}`);
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
if (!hasOpenAI
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
if (!hasOpenAI) {
|
|
112
|
+
if (process.stdin.isTTY) {
|
|
113
|
+
let key: string;
|
|
114
|
+
if (inq) {
|
|
115
|
+
key = await inq.input({
|
|
116
|
+
message: "OpenAI API Key (optional — voice + image, enter to skip):",
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
console.log("\nOpenAI API Key (optional — enables voice transcription + image analysis).");
|
|
120
|
+
key = await readLine("OPENAI_API_KEY (enter to skip): ");
|
|
121
|
+
}
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
if (key.trim()) {
|
|
124
|
+
vars.OPENAI_API_KEY = key.trim();
|
|
125
|
+
await setSecret("OPENAI_API_KEY", key.trim()).catch((e) =>
|
|
126
|
+
console.warn(`[setup] Could not persist OPENAI_API_KEY to encrypted DB: ${e}`)
|
|
127
|
+
);
|
|
128
|
+
console.log("[setup] OPENAI_API_KEY saved to .env + encrypted DB");
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
130
131
|
}
|
|
131
|
-
} else
|
|
132
|
+
} else {
|
|
132
133
|
const src = openaiSecret ? "encrypted DB" : vars.OPENAI_API_KEY ? ".env" : "env var";
|
|
133
134
|
console.log(`[setup] OPENAI_API_KEY found in ${src}`);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
// Save tokens
|
|
137
|
-
if (!setupDone) {
|
|
138
|
-
vars[SETUP_DONE_KEY] = "1";
|
|
139
|
-
changed = true;
|
|
140
|
-
}
|
|
141
137
|
if (changed) {
|
|
142
138
|
saveEnv(vars);
|
|
143
139
|
console.log(`\nConfig saved to ${ENV_PATH}`);
|
|
144
140
|
}
|
|
145
141
|
|
|
146
|
-
// ─── Phase 2: CLI Installation
|
|
142
|
+
// ─── Phase 2: CLI Installation ──────────────────────────────────
|
|
147
143
|
|
|
148
144
|
if (process.stdin.isTTY) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
145
|
+
let claudeInstalled = isAgentCliInstalled("claude");
|
|
146
|
+
let codexInstalled = isAgentCliInstalled("codex");
|
|
147
|
+
|
|
148
|
+
console.log("\nCLI Status:");
|
|
149
|
+
console.log(` ${claudeInstalled ? "✓" : "✗"} Claude${claudeInstalled ? "" : " — not installed"}`);
|
|
150
|
+
console.log(` ${codexInstalled ? "✓" : "✗"} Codex${codexInstalled ? "" : " — not installed"}`);
|
|
151
|
+
|
|
152
|
+
const missing: AgentCliName[] = [];
|
|
153
|
+
if (!claudeInstalled) missing.push("claude");
|
|
154
|
+
if (!codexInstalled) missing.push("codex");
|
|
155
|
+
|
|
156
|
+
if (missing.length > 0) {
|
|
157
|
+
let toInstall: AgentCliName[] = [];
|
|
158
|
+
|
|
159
|
+
if (inq) {
|
|
160
|
+
toInstall = await inq.checkbox({
|
|
161
|
+
message: "Install missing CLIs? (space to select, enter to confirm)",
|
|
162
|
+
choices: missing.map((cli) => ({
|
|
163
|
+
name: `${cli === "claude" ? "Claude" : "Codex"} (${CLI_PACKAGES[cli]})`,
|
|
164
|
+
value: cli as AgentCliName,
|
|
165
|
+
checked: true,
|
|
166
|
+
})),
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
const answer = await readLine("\nInstall missing CLIs? (Y/n): ");
|
|
170
|
+
if (answer.toLowerCase() !== "n") toInstall = missing;
|
|
171
|
+
}
|
|
176
172
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
value: cli as AgentCliName,
|
|
183
|
-
checked: true,
|
|
184
|
-
})),
|
|
185
|
-
});
|
|
186
|
-
} else {
|
|
187
|
-
// Non-inquirer fallback: install all
|
|
188
|
-
const answer = await readLine("\nInstall missing CLIs? (Y/n): ");
|
|
189
|
-
if (answer.toLowerCase() !== "n") toInstall = missing;
|
|
190
|
-
}
|
|
173
|
+
for (const cli of toInstall) {
|
|
174
|
+
console.log(`\nInstalling ${cli}...`);
|
|
175
|
+
const ok = await installCli(cli);
|
|
176
|
+
console.log(ok ? ` ✓ ${cli} installed` : ` ✗ ${cli} install failed`);
|
|
177
|
+
}
|
|
191
178
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
console.log(ok ? ` ✓ ${cli} installed` : ` ✗ ${cli} install failed`);
|
|
179
|
+
// Refresh status
|
|
180
|
+
claudeInstalled = isAgentCliInstalled("claude");
|
|
181
|
+
codexInstalled = isAgentCliInstalled("codex");
|
|
196
182
|
}
|
|
197
183
|
|
|
198
|
-
//
|
|
199
|
-
claudeInstalled = isAgentCliInstalled("claude");
|
|
200
|
-
codexInstalled = isAgentCliInstalled("codex");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Login CLIs
|
|
204
|
-
if (claudeInstalled) {
|
|
205
|
-
let doLogin = true;
|
|
206
|
-
if (inq) {
|
|
207
|
-
doLogin = await inq.confirm({ message: "Log in to Claude?", default: true });
|
|
208
|
-
} else {
|
|
209
|
-
const answer = await readLine("\nLog in to Claude? (Y/n): ");
|
|
210
|
-
doLogin = answer.toLowerCase() !== "n";
|
|
211
|
-
}
|
|
212
|
-
if (doLogin) {
|
|
213
|
-
console.log();
|
|
214
|
-
await runInteractiveLogin("claude", vars);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
184
|
+
// ─── Phase 3: CLI Authentication ────────────────────────────────
|
|
217
185
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
doLogin = await inq.confirm({ message: "Log in to Codex?", default: true });
|
|
222
|
-
} else {
|
|
223
|
-
const answer = await readLine("\nLog in to Codex? (Y/n): ");
|
|
224
|
-
doLogin = answer.toLowerCase() !== "n";
|
|
225
|
-
}
|
|
226
|
-
if (doLogin) {
|
|
227
|
-
console.log();
|
|
228
|
-
await runInteractiveLogin("codex", vars);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
186
|
+
const installed: AgentCliName[] = [];
|
|
187
|
+
if (claudeInstalled) installed.push("claude");
|
|
188
|
+
if (codexInstalled) installed.push("codex");
|
|
231
189
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
190
|
+
for (const cli of installed) {
|
|
191
|
+
const authed = await isCliAuthenticated(cli);
|
|
192
|
+
if (authed) {
|
|
193
|
+
console.log(`[setup] ${cli} ✓ authenticated`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
239
196
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (authed) {
|
|
253
|
-
console.log(`[setup] ${cli} ✓ authenticated`);
|
|
254
|
-
continue;
|
|
197
|
+
console.log(`[setup] ${cli} ✗ not authenticated`);
|
|
198
|
+
let doLogin = true;
|
|
199
|
+
if (inq) {
|
|
200
|
+
doLogin = await inq.confirm({ message: `Log in to ${cli === "claude" ? "Claude" : "Codex"}?`, default: true });
|
|
201
|
+
} else {
|
|
202
|
+
const answer = await readLine(`\nLog in to ${cli === "claude" ? "Claude" : "Codex"}? (Y/n): `);
|
|
203
|
+
doLogin = answer.toLowerCase() !== "n";
|
|
204
|
+
}
|
|
205
|
+
if (doLogin) {
|
|
206
|
+
console.log();
|
|
207
|
+
await runInteractiveLogin(cli, vars);
|
|
208
|
+
}
|
|
255
209
|
}
|
|
256
210
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
doLogin = await inq.confirm({ message: `Log in to ${cli === "claude" ? "Claude" : "Codex"}?`, default: true });
|
|
261
|
-
} else {
|
|
262
|
-
const answer = await readLine(`\nLog in to ${cli === "claude" ? "Claude" : "Codex"}? (Y/n): `);
|
|
263
|
-
doLogin = answer.toLowerCase() !== "n";
|
|
264
|
-
}
|
|
265
|
-
if (doLogin) {
|
|
266
|
-
console.log();
|
|
267
|
-
await runInteractiveLogin(cli, vars);
|
|
211
|
+
if (installed.length === 0) {
|
|
212
|
+
console.log("\n⚠ No CLIs installed. Arisa needs at least one to work.");
|
|
213
|
+
console.log(" The daemon will auto-install them in the background.\n");
|
|
268
214
|
}
|
|
269
215
|
}
|
|
216
|
+
|
|
217
|
+
return true;
|
|
270
218
|
}
|
|
271
219
|
|
|
272
220
|
/**
|
|
273
221
|
* Quick probe: is this CLI authenticated?
|
|
274
222
|
* Claude: check CLAUDE_CODE_OAUTH_TOKEN env/.env, or `claude auth status`
|
|
275
|
-
* Codex:
|
|
223
|
+
* Codex: check OPENAI_API_KEY
|
|
276
224
|
*/
|
|
277
225
|
async function isCliAuthenticated(cli: AgentCliName): Promise<boolean> {
|
|
278
226
|
try {
|
|
@@ -317,7 +265,6 @@ async function installCli(cli: AgentCliName): Promise<boolean> {
|
|
|
317
265
|
}
|
|
318
266
|
}
|
|
319
267
|
|
|
320
|
-
|
|
321
268
|
async function runInteractiveLogin(cli: AgentCliName, vars: Record<string, string>): Promise<boolean> {
|
|
322
269
|
const args = cli === "claude"
|
|
323
270
|
? ["setup-token"]
|
package/src/core/history.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module core/history
|
|
3
|
-
* @role Shared conversation history across backends (Claude/Codex).
|
|
4
|
-
* @responsibilities
|
|
5
|
-
* - Log each user↔backend exchange with backend tag
|
|
6
|
-
* - Provide "foreign" context: exchanges from the OTHER backend
|
|
7
|
-
* that the current backend hasn't seen
|
|
8
|
-
* - Persist to disk, load on startup
|
|
9
|
-
* @dependencies shared/config
|
|
10
|
-
* @effects Reads/writes runtime history.jsonl
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from "fs";
|
|
14
|
-
import { join, dirname } from "path";
|
|
15
|
-
import { config } from "../shared/config";
|
|
16
|
-
import { createLogger } from "../shared/logger";
|
|
17
|
-
|
|
18
|
-
const log = createLogger("core");
|
|
19
|
-
|
|
20
|
-
const HISTORY_PATH = join(config.arisaDir, "history.jsonl");
|
|
21
|
-
const MAX_ENTRIES_PER_CHAT = 50;
|
|
22
|
-
const FOREIGN_CONTEXT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
23
|
-
const CODEX_SWITCH_NOTICE = "Claude is out of credits right now, so I switched this reply to Codex.";
|
|
24
|
-
|
|
25
|
-
interface Exchange {
|
|
26
|
-
ts: number;
|
|
27
|
-
chatId: string;
|
|
28
|
-
user: string;
|
|
29
|
-
response: string;
|
|
30
|
-
backend: "claude" | "codex";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let history: Exchange[] = [];
|
|
34
|
-
|
|
35
|
-
// Load persisted history on import
|
|
36
|
-
try {
|
|
37
|
-
if (existsSync(HISTORY_PATH)) {
|
|
38
|
-
const lines = readFileSync(HISTORY_PATH, "utf8").split("\n").filter(Boolean);
|
|
39
|
-
history = lines.map((l) => {
|
|
40
|
-
const entry = JSON.parse(l) as Exchange;
|
|
41
|
-
return { ...entry, response: normalizeResponse(entry.response) };
|
|
42
|
-
});
|
|
43
|
-
log.info(`Loaded ${history.length} history entries`);
|
|
44
|
-
}
|
|
45
|
-
} catch (e) {
|
|
46
|
-
log.warn(`Failed to load history: ${e}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function addExchange(
|
|
50
|
-
chatId: string,
|
|
51
|
-
user: string,
|
|
52
|
-
response: string,
|
|
53
|
-
backend: "claude" | "codex",
|
|
54
|
-
) {
|
|
55
|
-
const normalizedResponse = normalizeResponse(response);
|
|
56
|
-
const entry: Exchange = { ts: Date.now(), chatId, user, response: normalizedResponse, backend };
|
|
57
|
-
history.push(entry);
|
|
58
|
-
|
|
59
|
-
// Prune old entries per chat
|
|
60
|
-
const chatEntries = history.filter((e) => e.chatId === chatId);
|
|
61
|
-
if (chatEntries.length > MAX_ENTRIES_PER_CHAT) {
|
|
62
|
-
const toRemove = chatEntries.length - MAX_ENTRIES_PER_CHAT;
|
|
63
|
-
let removed = 0;
|
|
64
|
-
history = history.filter((e) => {
|
|
65
|
-
if (e.chatId === chatId && removed < toRemove) {
|
|
66
|
-
removed++;
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
return true;
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Persist
|
|
74
|
-
try {
|
|
75
|
-
const dir = dirname(HISTORY_PATH);
|
|
76
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
77
|
-
// Rewrite full file after prune to keep it clean
|
|
78
|
-
writeFileSync(HISTORY_PATH, history.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
79
|
-
} catch (e) {
|
|
80
|
-
log.warn(`Failed to persist history: ${e}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Returns context string with exchanges from the OTHER backend
|
|
86
|
-
* that happened since the current backend was last used.
|
|
87
|
-
* Returns empty string if there's nothing to inject.
|
|
88
|
-
*/
|
|
89
|
-
export function getForeignContext(
|
|
90
|
-
chatId: string,
|
|
91
|
-
currentBackend: "claude" | "codex",
|
|
92
|
-
limit = 10,
|
|
93
|
-
): string {
|
|
94
|
-
const chatHistory = history.filter((e) => e.chatId === chatId);
|
|
95
|
-
if (chatHistory.length === 0) return "";
|
|
96
|
-
|
|
97
|
-
// Find last exchange handled by current backend
|
|
98
|
-
let lastOwnIdx = -1;
|
|
99
|
-
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
|
100
|
-
if (chatHistory[i].backend === currentBackend) {
|
|
101
|
-
lastOwnIdx = i;
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const cutoff = Date.now() - FOREIGN_CONTEXT_MAX_AGE_MS;
|
|
107
|
-
|
|
108
|
-
// Get foreign exchanges since then
|
|
109
|
-
const foreign = chatHistory
|
|
110
|
-
.slice(lastOwnIdx + 1)
|
|
111
|
-
.filter((e) => e.backend !== currentBackend && e.ts >= cutoff);
|
|
112
|
-
|
|
113
|
-
if (foreign.length === 0) return "";
|
|
114
|
-
|
|
115
|
-
const otherName = currentBackend === "claude" ? "Codex" : "Claude";
|
|
116
|
-
const lines = foreign
|
|
117
|
-
.slice(-limit)
|
|
118
|
-
.map((e) => `User: ${e.user}\n${otherName}: ${normalizeResponse(e.response)}`)
|
|
119
|
-
.join("\n\n");
|
|
120
|
-
|
|
121
|
-
return `[Contexto previo con ${otherName}]\n${lines}\n[Fin del contexto previo]\n\n`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Returns recent conversation history for this chat, formatted as User/Assistant pairs.
|
|
126
|
-
* Trims oldest entries first if total exceeds maxChars.
|
|
127
|
-
* Returns "" for new conversations.
|
|
128
|
-
*/
|
|
129
|
-
export function getRecentHistory(
|
|
130
|
-
chatId: string,
|
|
131
|
-
limit = 10,
|
|
132
|
-
maxChars = 8000,
|
|
133
|
-
): string {
|
|
134
|
-
const chatHistory = history.filter((e) => e.chatId === chatId);
|
|
135
|
-
if (chatHistory.length === 0) return "";
|
|
136
|
-
|
|
137
|
-
const recent = chatHistory.slice(-limit);
|
|
138
|
-
|
|
139
|
-
// Format exchanges
|
|
140
|
-
const formatted = recent.map(
|
|
141
|
-
(e) => `User: ${e.user}\nAssistant: ${normalizeResponse(e.response)}`,
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// Trim oldest entries if total exceeds maxChars
|
|
145
|
-
let total = formatted.join("\n\n").length;
|
|
146
|
-
while (formatted.length > 1 && total > maxChars) {
|
|
147
|
-
formatted.shift();
|
|
148
|
-
total = formatted.join("\n\n").length;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (formatted.length === 0) return "";
|
|
152
|
-
|
|
153
|
-
return `[Conversation history]\n${formatted.join("\n\n")}\n[End of conversation history]\n\n`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Removes all history entries for this chat from memory and disk.
|
|
158
|
-
*/
|
|
159
|
-
export function clearHistory(chatId: string): void {
|
|
160
|
-
const before = history.length;
|
|
161
|
-
history = history.filter((e) => e.chatId !== chatId);
|
|
162
|
-
const removed = before - history.length;
|
|
163
|
-
|
|
164
|
-
if (removed > 0) {
|
|
165
|
-
log.info(`Cleared ${removed} history entries for chat ${chatId}`);
|
|
166
|
-
try {
|
|
167
|
-
writeFileSync(HISTORY_PATH, history.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
168
|
-
} catch (e) {
|
|
169
|
-
log.warn(`Failed to persist history after clear: ${e}`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function getLastBackend(chatId: string): "claude" | "codex" | null {
|
|
175
|
-
for (let i = history.length - 1; i >= 0; i--) {
|
|
176
|
-
if (history[i].chatId === chatId) {
|
|
177
|
-
return history[i].backend;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function normalizeResponse(response: string): string {
|
|
184
|
-
const cleaned = response
|
|
185
|
-
.replace(/\n---CHUNK---\n/g, "\n")
|
|
186
|
-
.replace(new RegExp(`^${escapeRegExp(CODEX_SWITCH_NOTICE)}\\s*`, "m"), "")
|
|
187
|
-
.trim();
|
|
188
|
-
return cleaned;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function escapeRegExp(s: string): string {
|
|
192
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
193
|
-
}
|