arisa 2.3.15 → 2.3.16

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/README.md CHANGED
@@ -14,11 +14,9 @@ Arisa can execute actions with operational control over the system where it runs
14
14
 
15
15
  ```bash
16
16
  curl -fsSL https://bun.sh/install | bash # Install Bun https://bun.sh
17
-
18
- bun add -g @anthropic-ai/claude-code # Install Claude CLI (both or one is required)
19
- bun add -g @openai/codex # Install Codex CLI (both or one is required)
20
-
21
17
  bun add -g arisa # Install Arisa CLI
18
+
19
+ # @anthropic-ai/claude-code and @openai/codex (auto-installed if missing)
22
20
  ```
23
21
 
24
22
  ## Commands
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "2.3.15",
3
+ "version": "2.3.16",
4
4
  "description": "Arisa - dynamic agent runtime with daemon/core architecture that evolves through user interaction",
5
5
  "preferGlobal": true,
6
6
  "bin": {
7
7
  "arisa": "./bin/arisa.js"
8
8
  },
9
9
  "files": [
10
- "bin",
11
- "src",
12
- "scripts",
10
+ "tinyclaw",
11
+ "clawbot",
12
+ "moltbot",
13
+ "openclaw",
14
+ "agent",
15
+ "telegram",
16
+ "jarvis",
13
17
  "CLAUDE.md",
14
18
  "SOUL.md",
15
- "README.md",
16
- "tsconfig.json"
19
+ "clasen"
17
20
  ],
18
21
  "engines": {
19
22
  "node": ">=18",
@@ -1,22 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Test encrypted secrets loading
4
- */
5
-
6
- import { secrets } from "../src/shared/secrets";
7
-
8
- async function main() {
9
- console.log("šŸ” Testing encrypted secrets...\n");
10
-
11
- const telegram = await secrets.telegram();
12
- const openai = await secrets.openai();
13
- const elevenlabs = await secrets.elevenlabs();
14
-
15
- console.log("āœ“ TELEGRAM_BOT_TOKEN:", telegram ? `${telegram.slice(0, 10)}...${telegram.slice(-10)}` : "NOT FOUND");
16
- console.log("āœ“ OPENAI_API_KEY:", openai ? `${openai.slice(0, 10)}...${openai.slice(-10)}` : "NOT FOUND");
17
- console.log("āœ“ ELEVENLABS_API_KEY:", elevenlabs ? `${elevenlabs.slice(0, 10)}...${elevenlabs.slice(-10)}` : "NOT FOUND");
18
-
19
- console.log("\nāœ… Secrets loaded successfully from encrypted DB");
20
- }
21
-
22
- main();
@@ -1,104 +0,0 @@
1
- /**
2
- * @module core/attachments
3
- * @role Persist media attachments so the model can access them later.
4
- * @responsibilities
5
- * - Save base64 attachments to runtime attachments/{chatId}/
6
- * - Track metadata in deepbase (collection: "attachments")
7
- * - Clean up files older than configured max age
8
- * @dependencies shared/config, shared/db
9
- * @effects Disk I/O in runtime attachments dir, deepbase writes
10
- */
11
-
12
- import { mkdirSync, existsSync, unlinkSync, rmdirSync, readdirSync } from "fs";
13
- import { join, dirname, relative } from "path";
14
- import { config } from "../shared/config";
15
- import { createLogger } from "../shared/logger";
16
- import { addAttachment, getExpiredAttachments, deleteAttachment, cleanupOldMessages } from "../shared/db";
17
- import type { AttachmentRecord } from "../shared/types";
18
-
19
- const log = createLogger("core");
20
-
21
- const EXT_MAP: Record<string, string> = {
22
- image: "jpg",
23
- audio: "ogg",
24
- document: "bin",
25
- };
26
-
27
- export async function initAttachments(): Promise<void> {
28
- if (!existsSync(config.attachmentsDir)) {
29
- mkdirSync(config.attachmentsDir, { recursive: true });
30
- }
31
- await cleanupOldAttachments();
32
- const msgsCleaned = await cleanupOldMessages(config.attachmentMaxAgeDays);
33
- if (msgsCleaned > 0) {
34
- log.info(`Cleaned up ${msgsCleaned} expired message record(s)`);
35
- }
36
- }
37
-
38
- export async function saveAttachment(
39
- chatId: string,
40
- type: "image" | "audio" | "document",
41
- base64: string,
42
- filename?: string,
43
- mimeType?: string,
44
- ): Promise<string> {
45
- const chatDir = join(config.attachmentsDir, chatId);
46
- if (!existsSync(chatDir)) {
47
- mkdirSync(chatDir, { recursive: true });
48
- }
49
-
50
- const ext = filename ? filename.split(".").pop() || EXT_MAP[type] : EXT_MAP[type];
51
- const hex = Math.random().toString(16).slice(2, 6);
52
- const prefix = type === "image" ? "img" : type === "audio" ? "aud" : "doc";
53
- const outName = `${prefix}_${Date.now()}_${hex}.${ext}`;
54
- const outPath = join(chatDir, outName);
55
-
56
- const buffer = Buffer.from(base64, "base64");
57
- await Bun.write(outPath, buffer);
58
-
59
- let relPath = relative(config.projectDir, outPath).replace(/\\/g, "/");
60
- if (relPath.startsWith("..")) {
61
- relPath = outPath;
62
- }
63
-
64
- const record: AttachmentRecord = {
65
- id: `${chatId}_${outName}`,
66
- chatId,
67
- type,
68
- filename: filename || outName,
69
- relPath,
70
- mimeType,
71
- sizeBytes: buffer.length,
72
- createdAt: Date.now(),
73
- };
74
- await addAttachment(record);
75
-
76
- log.info(`Saved ${type} attachment: ${relPath} (${buffer.length} bytes)`);
77
- return relPath;
78
- }
79
-
80
- async function cleanupOldAttachments(): Promise<void> {
81
- const expired = await getExpiredAttachments(config.attachmentMaxAgeDays);
82
- if (expired.length === 0) return;
83
-
84
- let cleaned = 0;
85
- for (const record of expired) {
86
- const absPath = join(config.projectDir, record.relPath);
87
- try {
88
- if (existsSync(absPath)) {
89
- unlinkSync(absPath);
90
- }
91
- // Remove empty chat dir
92
- const dir = dirname(absPath);
93
- if (existsSync(dir) && readdirSync(dir).length === 0) {
94
- rmdirSync(dir);
95
- }
96
- } catch {
97
- // File already gone — just clean the record
98
- }
99
- await deleteAttachment(record.id);
100
- cleaned++;
101
- }
102
-
103
- log.info(`Cleaned up ${cleaned} expired attachment(s)`);
104
- }
package/src/core/auth.ts DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * @module core/auth
3
- * @role Gate access to the bot via a one-time token shown in the console.
4
- * @responsibilities
5
- * - Generate and persist an auth token via deepbase (settings.auth_token)
6
- * - Track authorized chat IDs via deepbase (authorized collection)
7
- * - Validate tokens from new chats
8
- * @dependencies shared/db
9
- * @effects deepbase writes, console output
10
- */
11
-
12
- import { createLogger } from "../shared/logger";
13
- import { getAuthorizedUsers, addAuthorized, getSetting, setSetting } from "../shared/db";
14
-
15
- const log = createLogger("auth");
16
-
17
- let authToken = "";
18
- let authorizedChats: Set<string> = new Set();
19
-
20
- async function loadToken(): Promise<string> {
21
- const existing = await getSetting("auth_token");
22
- if (existing) return existing;
23
-
24
- const token = crypto.randomUUID().split("-")[0];
25
- await setSetting("auth_token", token);
26
- return token;
27
- }
28
-
29
- async function loadAuthorized(): Promise<Set<string>> {
30
- try {
31
- const users = await getAuthorizedUsers();
32
- return new Set(users);
33
- } catch (error) {
34
- log.error(`Failed to load authorized users: ${error}`);
35
- return new Set();
36
- }
37
- }
38
-
39
- export async function initAuth() {
40
- authToken = await loadToken();
41
- authorizedChats = await loadAuthorized();
42
- log.info(`Auth token: ${authToken}`);
43
- console.log(`\nšŸ”‘ Auth token: ${authToken}\n Send this token to the bot on Telegram to authorize a chat.\n`);
44
- }
45
-
46
- export function isAuthorized(chatId: string): boolean {
47
- return authorizedChats.has(chatId);
48
- }
49
-
50
- export async function tryAuthorize(chatId: string, message: string): Promise<boolean> {
51
- if (message.trim() === authToken) {
52
- authorizedChats.add(chatId);
53
- await addAuthorized(chatId);
54
- log.info(`Chat ${chatId} authorized`);
55
- return true;
56
- }
57
- return false;
58
- }
@@ -1,30 +0,0 @@
1
- /**
2
- * @module core/context
3
- * @role Manage Claude conversation continuity via the -c flag and reset_flag.
4
- * @responsibilities
5
- * - Check if reset_flag exists (user sent /reset)
6
- * - Return whether to use -c (continue) flag
7
- * - Clear reset_flag after consuming it
8
- * @dependencies shared/config
9
- * @effects Reads/deletes reset_flag from runtime data dir
10
- * @contract shouldContinue() => boolean
11
- */
12
-
13
- import { existsSync, unlinkSync } from "fs";
14
- import { config } from "../shared/config";
15
- import { createLogger } from "../shared/logger";
16
-
17
- const log = createLogger("core");
18
-
19
- export function shouldContinue(): boolean {
20
- if (existsSync(config.resetFlagPath)) {
21
- log.info("Reset flag found — starting fresh conversation");
22
- try {
23
- unlinkSync(config.resetFlagPath);
24
- } catch {
25
- // Already deleted, race condition
26
- }
27
- return false;
28
- }
29
- return true;
30
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * @module core/file-detector
3
- * @role Detect file paths mentioned in Claude responses that exist on disk.
4
- * @responsibilities
5
- * - Scan response text for absolute file paths
6
- * - Verify they exist and are sendable (< 50MB, not directories)
7
- * - Return list of unique valid file paths
8
- * @dependencies None
9
- * @effects Reads file system to check existence/size
10
- * @contract detectFiles(text) => string[]
11
- */
12
-
13
- import { statSync } from "fs";
14
-
15
- const FILE_PATH_REGEX = /(\/[\w./-]+\.\w{1,10})/gm;
16
- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
17
-
18
- export function detectFiles(text: string): string[] {
19
- const matches = [...text.matchAll(FILE_PATH_REGEX)];
20
- const seen = new Set<string>();
21
- const files: string[] = [];
22
-
23
- for (const match of matches) {
24
- const filePath = match[1];
25
- if (seen.has(filePath)) continue;
26
- seen.add(filePath);
27
-
28
- try {
29
- const stat = statSync(filePath);
30
- if (stat.isFile() && stat.size < MAX_FILE_SIZE) {
31
- files.push(filePath);
32
- }
33
- } catch {
34
- // File doesn't exist or can't be read
35
- }
36
- }
37
-
38
- return files;
39
- }
@@ -1,159 +0,0 @@
1
- /**
2
- * @module core/format
3
- * @role Format responses for Telegram (HTML) and chunk long messages.
4
- * @responsibilities
5
- * - Split text into chunks respecting Telegram's 4096 char limit
6
- * - Safe HTML sending with plain-text fallback marker
7
- * @dependencies None
8
- * @effects None (pure functions)
9
- * @contract chunkMessage(text) => string[]
10
- */
11
-
12
- const MAX_TELEGRAM_LENGTH = 4096;
13
-
14
- function escapeHtml(s: string): string {
15
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16
- }
17
-
18
- /** Decode HTML entities so we don't double-encode them when escapeHtml runs. */
19
- function unescapeHtml(s: string): string {
20
- return s
21
- .replace(/&amp;/g, "&")
22
- .replace(/&lt;/g, "<")
23
- .replace(/&gt;/g, ">")
24
- .replace(/&quot;/g, '"')
25
- .replace(/&#39;/g, "'");
26
- }
27
-
28
- function protectTelegramHtmlTags(text: string): { text: string; tags: string[] } {
29
- const tags: string[] = [];
30
- const tagPattern = /<\/?(?:b|i|u|s|code|pre|blockquote)>|<a\s+href="[^"]+">|<\/a>/gi;
31
- const protectedText = text.replace(tagPattern, (tag) => {
32
- tags.push(tag);
33
- return `\x00HTMLTAG${tags.length - 1}\x00`;
34
- });
35
- return { text: protectedText, tags };
36
- }
37
-
38
- /**
39
- * Convert Markdown (from Claude CLI) to Telegram-safe HTML.
40
- * Telegram supports: <b>, <i>, <code>, <pre>, <a>, <s>, <u>, <blockquote>
41
- * Must also escape HTML entities in non-tag content.
42
- */
43
- export function markdownToTelegramHtml(text: string): string {
44
- // Step 0: Decode any pre-escaped HTML entities to avoid double-encoding
45
- // (e.g. &lt;code&gt; → <code> so protectTelegramHtmlTags can detect them)
46
- text = unescapeHtml(text);
47
-
48
- // Step 1: Extract code blocks and links before escaping to protect them
49
- const codeBlocks: string[] = [];
50
- const inlineCodes: string[] = [];
51
- const links: string[] = [];
52
-
53
- // Preserve already-valid Telegram HTML tags instead of escaping them.
54
- const protectedHtml = protectTelegramHtmlTags(text);
55
-
56
- // Protect fenced code blocks: ```lang\n...\n``` or ```...```
57
- let result = protectedHtml.text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
58
- codeBlocks.push(code.trimEnd());
59
- return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
60
- });
61
-
62
- // Protect inline code: `...`
63
- result = result.replace(/`([^`\n]+)`/g, (_match, code) => {
64
- inlineCodes.push(code);
65
- return `\x00INLINE${inlineCodes.length - 1}\x00`;
66
- });
67
-
68
- // Protect links: [text](url)
69
- result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, linkText, url) => {
70
- links.push(`<a href="${url}">${escapeHtml(linkText)}</a>`);
71
- return `\x00LINK${links.length - 1}\x00`;
72
- });
73
-
74
- // Step 2: Now escape HTML entities in the remaining text
75
- result = escapeHtml(result);
76
-
77
- // Step 3: Convert markdown patterns to HTML tags
78
-
79
- // Bold+Italic: ***text*** or ___text___
80
- result = result.replace(/\*{3}(.+?)\*{3}/g, "<b><i>$1</i></b>");
81
- result = result.replace(/_{3}(.+?)_{3}/g, "<b><i>$1</i></b>");
82
-
83
- // Bold: **text** or __text__
84
- result = result.replace(/\*{2}(.+?)\*{2}/g, "<b>$1</b>");
85
- result = result.replace(/_{2}(.+?)_{2}/g, "<b>$1</b>");
86
-
87
- // Italic: *text* or _text_ (not inside words for underscore)
88
- result = result.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "<i>$1</i>");
89
- result = result.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "<i>$1</i>");
90
-
91
- // Strikethrough: ~~text~~
92
- result = result.replace(/~~(.+?)~~/g, "<s>$1</s>");
93
-
94
- // Step 4: Restore protected elements
95
- result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, i) => `<pre>${escapeHtml(codeBlocks[+i])}</pre>`);
96
- result = result.replace(/\x00INLINE(\d+)\x00/g, (_m, i) => `<code>${escapeHtml(inlineCodes[+i])}</code>`);
97
- result = result.replace(/\x00LINK(\d+)\x00/g, (_m, i) => links[+i]);
98
- result = result.replace(/\x00HTMLTAG(\d+)\x00/g, (_m, i) => protectedHtml.tags[+i]);
99
-
100
- return result;
101
- }
102
-
103
- export function chunkMessage(text: string): string[] {
104
- if (text.length <= MAX_TELEGRAM_LENGTH) {
105
- return [text];
106
- }
107
-
108
- const chunks: string[] = [];
109
- let remaining = text;
110
-
111
- // Tags that Telegram supports and we use
112
- const tagNames = ["pre", "code", "b", "i", "s", "a"];
113
-
114
- while (remaining.length > 0) {
115
- if (remaining.length <= MAX_TELEGRAM_LENGTH) {
116
- chunks.push(remaining);
117
- break;
118
- }
119
-
120
- let splitAt = remaining.lastIndexOf("\n", MAX_TELEGRAM_LENGTH);
121
- if (splitAt === -1 || splitAt < MAX_TELEGRAM_LENGTH * 0.5) {
122
- splitAt = MAX_TELEGRAM_LENGTH;
123
- }
124
-
125
- // Check if splitting here would break an HTML tag
126
- let candidate = remaining.substring(0, splitAt);
127
-
128
- // Find unclosed tags in the candidate chunk
129
- const openTags: string[] = [];
130
- const tagRegex = /<\/?([a-z]+)(?:\s[^>]*)?\/?>/gi;
131
- let match: RegExpExecArray | null;
132
- while ((match = tagRegex.exec(candidate)) !== null) {
133
- const fullTag = match[0];
134
- const tagName = match[1].toLowerCase();
135
- if (!tagNames.includes(tagName)) continue;
136
- if (fullTag.startsWith("</")) {
137
- // Closing tag - pop if matching
138
- const idx = openTags.lastIndexOf(tagName);
139
- if (idx !== -1) openTags.splice(idx, 1);
140
- } else if (!fullTag.endsWith("/>")) {
141
- openTags.push(tagName);
142
- }
143
- }
144
-
145
- // If there are unclosed tags, close them at end of this chunk and reopen in next
146
- if (openTags.length > 0) {
147
- const closingTags = [...openTags].reverse().map(t => `</${t}>`).join("");
148
- const openingTags = openTags.map(t => `<${t}>`).join("");
149
- candidate = candidate + closingTags;
150
- remaining = openingTags + remaining.substring(splitAt).trimStart();
151
- } else {
152
- remaining = remaining.substring(splitAt).trimStart();
153
- }
154
-
155
- chunks.push(candidate);
156
- }
157
-
158
- return chunks;
159
- }
@@ -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
- }