返回 CodeWhale
lib.mjs
1 import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2 import path from "node:path";
3
4 const DEFAULT_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
5
6 function normalizeCursorValue(value, fallback = 0) {
7 const number = Number(value);
8 if (Number.isFinite(number) && number >= 0) return Math.floor(number);
9 const fallbackNumber = Number(fallback);
10 if (Number.isFinite(fallbackNumber) && fallbackNumber >= 0) return Math.floor(fallbackNumber);
11 return 0;
12 }
13
14 async function chmodBestEffort(filePath, mode) {
15 try {
16 await chmod(filePath, mode);
17 } catch (error) {
18 if (process.platform !== "win32") throw error;
19 }
20 }
21
22 export class ThreadStore {
23 static async open(filePath, options = {}) {
24 const store = new ThreadStore(filePath, options);
25 await store.load();
26 return store;
27 }
28
29 constructor(filePath, options = {}) {
30 this.filePath = filePath;
31 this.options = {
32 messageLimit: options.messageLimit || 0,
33 actions: options.actions === true,
34 actionLimit: options.actionLimit || 200,
35 actionTtlMs: options.actionTtlMs || DEFAULT_ACTION_TTL_MS,
36 privateMode: options.privateMode === true
37 };
38 this.data = { chats: {} };
39 this.saveDirty = false;
40 this.savePending = null;
41 this.ensureShape();
42 }
43
44 ensureShape() {
45 if (!this.data || typeof this.data !== "object") this.data = {};
46 if (!this.data.chats || typeof this.data.chats !== "object") this.data.chats = {};
47 if (this.options.messageLimit > 0 && !Array.isArray(this.data.messages)) {
48 this.data.messages = [];
49 }
50 if (this.options.actions && (!this.data.actions || typeof this.data.actions !== "object")) {
51 this.data.actions = {};
52 }
53 if (this.data.cursors && typeof this.data.cursors !== "object") {
54 this.data.cursors = {};
55 }
56 }
57
58 async load() {
59 try {
60 const raw = await readFile(this.filePath, "utf8");
61 this.data = JSON.parse(raw);
62 this.ensureShape();
63 } catch (error) {
64 if (error.code !== "ENOENT") throw error;
65 }
66 }
67
68 async recordMessage(messageKey) {
69 if (!messageKey || this.options.messageLimit <= 0) return false;
70 this.ensureShape();
71 if (this.data.messages.includes(messageKey)) return true;
72 this.data.messages.push(messageKey);
73 this.data.messages = this.data.messages.slice(-this.options.messageLimit);
74 await this.save();
75 return false;
76 }
77
78 getCursor(name, fallback = 0) {
79 if (!name) return normalizeCursorValue(fallback);
80 this.ensureShape();
81 return normalizeCursorValue(this.data.cursors?.[name], fallback);
82 }
83
84 async setCursor(name, value) {
85 if (!name) return normalizeCursorValue(value);
86 this.ensureShape();
87 if (!this.data.cursors || typeof this.data.cursors !== "object") {
88 this.data.cursors = {};
89 }
90 const cursor = normalizeCursorValue(value);
91 if (this.data.cursors[name] === cursor) return cursor;
92 this.data.cursors[name] = cursor;
93 await this.save();
94 return cursor;
95 }
96
97 async getChat(chatId) {
98 return this.data.chats[chatId] || null;
99 }
100
101 listChats() {
102 return Object.entries(this.data.chats || {});
103 }
104
105 async setChat(chatId, state) {
106 this.data.chats[chatId] = state;
107 await this.save();
108 return state;
109 }
110
111 async patchChat(chatId, patch) {
112 const current = this.data.chats[chatId] || {};
113 this.data.chats[chatId] = { ...current, ...patch };
114 await this.save();
115 return this.data.chats[chatId];
116 }
117
118 async putAction(action) {
119 if (!this.options.actions) return "";
120 this.ensureShape();
121 const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
122 this.data.actions[token] = {
123 ...action,
124 createdAt: new Date().toISOString()
125 };
126 this.pruneActions();
127 await this.save();
128 return token;
129 }
130
131 async getAction(token) {
132 if (!token || !this.options.actions) return null;
133 this.ensureShape();
134 return this.data.actions[token] || null;
135 }
136
137 async takeAction(token) {
138 const action = await this.getAction(token);
139 if (action) {
140 delete this.data.actions[token];
141 await this.save();
142 }
143 return action;
144 }
145
146 pruneActions() {
147 if (!this.options.actions) return;
148 const cutoff = Date.now() - this.options.actionTtlMs;
149 const fresh = Object.entries(this.data.actions || {}).filter(([, action]) => {
150 const time = Date.parse(action.createdAt || "");
151 return Number.isFinite(time) && time >= cutoff;
152 });
153 this.data.actions = Object.fromEntries(fresh.slice(-this.options.actionLimit));
154 }
155
156 async save() {
157 // Batch bursts of small updates: saves issued while a write is in flight
158 // coalesce into a single follow-up write. The returned promise resolves
159 // only after this mutation is durable on disk (temp file + rename).
160 this.saveDirty = true;
161 if (!this.savePending) {
162 this.savePending = this.flushSaves();
163 }
164 return this.savePending;
165 }
166
167 async flushSaves() {
168 try {
169 while (this.saveDirty) {
170 this.saveDirty = false;
171 await this.writeSnapshot();
172 }
173 } finally {
174 this.savePending = null;
175 }
176 }
177
178 async writeSnapshot() {
179 const dir = path.dirname(this.filePath);
180 await mkdir(dir, { recursive: true, mode: 0o700 });
181 if (this.options.privateMode) await chmodBestEffort(dir, 0o700);
182 const tmp = `${this.filePath}.tmp`;
183 await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
184 if (this.options.privateMode) await chmodBestEffort(tmp, 0o600);
185 await rename(tmp, this.filePath);
186 if (this.options.privateMode) await chmodBestEffort(this.filePath, 0o600);
187 }
188 }
189
190 export function envFirst(env, ...names) {
191 for (const name of names) {
192 const value = env?.[name];
193 if (value != null && String(value).trim()) return String(value).trim();
194 }
195 return "";
196 }
197
198 export function parseList(raw) {
199 return String(raw || "")
200 .split(",")
201 .map((item) => item.trim())
202 .filter(Boolean);
203 }
204
205 export function parseBool(raw, fallback = false) {
206 if (raw == null || raw === "") return fallback;
207 return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase());
208 }
209
210 export function parseEnvText(raw) {
211 const env = {};
212 for (const line of String(raw || "").split(/\r?\n/)) {
213 const trimmed = line.trim();
214 if (!trimmed || trimmed.startsWith("#")) continue;
215 const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
216 const index = normalized.indexOf("=");
217 if (index <= 0) continue;
218 const key = normalized.slice(0, index).trim();
219 let value = normalized.slice(index + 1).trim();
220 if (
221 value.length >= 2 &&
222 ((value.startsWith('"') && value.endsWith('"')) ||
223 (value.startsWith("'") && value.endsWith("'")))
224 ) {
225 value = value.slice(1, -1);
226 }
227 env[key] = value;
228 }
229 return env;
230 }
231
232 export function cleanEnvValue(value) {
233 return String(value ?? "").trim();
234 }
235
236 export function isPlaceholderValue(value) {
237 const normalized = cleanEnvValue(value).toLowerCase();
238 return (
239 !normalized ||
240 normalized.includes("replace-with") ||
241 normalized.includes("xxxxxxxx") ||
242 normalized === "changeme"
243 );
244 }
245
246 export function parseTextContent(content, keys = ["text", "content"]) {
247 if (typeof content !== "string") return "";
248 try {
249 const parsed = JSON.parse(content);
250 for (const key of keys) {
251 if (typeof parsed?.[key] === "string") return parsed[key];
252 }
253 } catch {
254 return content;
255 }
256 return content;
257 }
258
259 export function stripGroupPrefix(text, { chatType, requirePrefix, prefix, directChatTypes = [] }) {
260 const trimmed = String(text || "").trim();
261 if (!trimmed) return { accepted: false, text: "" };
262 if (!requirePrefix || directChatTypes.includes(chatType)) {
263 return { accepted: true, text: trimmed };
264 }
265 const marker = prefix || "/ds";
266 if (trimmed === marker) return { accepted: true, text: "/help" };
267 if (trimmed.startsWith(`${marker} `)) {
268 return { accepted: true, text: trimmed.slice(marker.length).trim() };
269 }
270 return { accepted: false, text: "" };
271 }
272
273 export function parseCommand(text, options = {}) {
274 const trimmed = String(text || "").trim();
275 if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed };
276 const [head, ...rest] = trimmed.split(/\s+/);
277 const rawName = head.slice(1);
278 const name = (options.stripBotMention ? rawName.split("@")[0] : rawName).toLowerCase();
279 return {
280 name,
281 args: rest.join(" ").trim()
282 };
283 }
284
285 export function parseApprovalDecisionArgs(args) {
286 const parts = String(args || "")
287 .split(/\s+/)
288 .filter(Boolean);
289 return {
290 approvalId: parts[0] || "",
291 remember: parts.slice(1).includes("remember")
292 };
293 }
294
295 export function commandAction(command, options = {}) {
296 const allowMenu = options.allowMenu === true;
297 const allowStart = options.allowStart === true;
298 switch (command.name) {
299 case "start":
300 if (allowStart) return { kind: "help" };
301 break;
302 case "help":
303 return { kind: "help" };
304 case "menu":
305 if (allowMenu) return { kind: "menu" };
306 break;
307 case "status":
308 return { kind: "status" };
309 case "threads":
310 return { kind: "threads" };
311 case "new":
312 return { kind: "new_thread" };
313 case "resume":
314 return { kind: "resume", threadId: command.args };
315 case "interrupt":
316 return { kind: "interrupt" };
317 case "compact":
318 return { kind: "compact" };
319 case "model":
320 return { kind: "set_model", modelName: command.args };
321 case "allow":
322 return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) };
323 case "deny":
324 return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) };
325 case "prompt":
326 return { kind: "prompt", prompt: command.args };
327 default:
328 break;
329 }
330 return {
331 kind: "prompt",
332 prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}`
333 };
334 }
335
336 export function preservedChatStateFields(state = {}, fields = ["model"]) {
337 const preserved = {};
338 for (const field of fields) {
339 if (Object.prototype.hasOwnProperty.call(state || {}, field)) {
340 preserved[field] = state[field] || null;
341 }
342 }
343 return preserved;
344 }
345
346 export function splitMessage(text, maxChars = 3500) {
347 const value = String(text || "");
348 const limit = Math.max(1, Math.floor(Number(maxChars) || 3500));
349 // Materialize code points once; all chunking below works on index ranges
350 // instead of re-running Array.from over the shrinking remainder.
351 const chars = Array.from(value);
352 if (chars.length <= limit) return value ? [value] : [];
353 const chunks = [];
354 let offset = 0;
355 let openFence = null;
356 while (offset < chars.length) {
357 const next = takeRenderedSplitMessageChunk(chars, offset, limit, openFence);
358 chunks.push(next.chunk);
359 offset = next.offset;
360 openFence = next.openFence;
361 }
362 return chunks;
363 }
364
365 function takeRenderedSplitMessageChunk(chars, offset, maxChars, openFence) {
366 const prefix = openFence !== null ? `\`\`\`${openFence}\n` : "";
367 const prefixLength = charLength(prefix);
368 let payloadLimit = Math.max(1, maxChars - prefixLength);
369
370 while (true) {
371 const splitAt = splitMessageChunkEnd(chars, offset, payloadLimit);
372 const payload = chars.slice(offset, splitAt).join("");
373 const body = `${prefix}${payload}`;
374 const nextOpenFence = updateCodeFenceState(openFence, payload);
375 const suffix =
376 nextOpenFence !== null && splitAt < chars.length ? (body.endsWith("\n") ? "```" : "\n```") : "";
377 // prefix/suffix are ASCII fence markup, so string length == code points.
378 const overflow = prefixLength + (splitAt - offset) + suffix.length - maxChars;
379 if (overflow <= 0 || payloadLimit === 1) {
380 return { chunk: `${body}${suffix}`, offset: splitAt, openFence: nextOpenFence };
381 }
382 payloadLimit = Math.max(1, payloadLimit - overflow);
383 }
384 }
385
386 function splitMessageChunkEnd(chars, offset, maxChars) {
387 if (chars.length - offset <= maxChars) return chars.length;
388 return offset + preferredSplitIndex(chars, offset, maxChars);
389 }
390
391 function preferredSplitIndex(chars, offset, maxChars) {
392 const limit = Math.min(chars.length - offset, maxChars);
393 for (let i = limit - 1; i > 0; i -= 1) {
394 if (chars[offset + i] === "\n") return i + 1;
395 }
396 for (let i = limit - 1; i > 0; i -= 1) {
397 if (/\s/u.test(chars[offset + i])) return i + 1;
398 }
399 return limit;
400 }
401
402 function charLength(text) {
403 let length = 0;
404 for (const _ of text) length += 1;
405 return length;
406 }
407
408 function updateCodeFenceState(openFence, text) {
409 let current = openFence;
410 for (const match of text.matchAll(/^```([^\n`]*)\s*$/gm)) {
411 if (current === null) {
412 current = match[1]?.trim() || "";
413 } else {
414 current = null;
415 }
416 }
417 return current;
418 }
419
420 export async function readJsonSafe(response) {
421 const text = await response.text();
422 if (!text) return {};
423 try {
424 return JSON.parse(text);
425 } catch {
426 return text;
427 }
428 }
429
430 export async function* readSse(response) {
431 const decoder = new TextDecoder();
432 let buffer = "";
433 for await (const chunk of response.body) {
434 buffer += decoder.decode(chunk, { stream: true });
435 let boundary;
436 while ((boundary = buffer.indexOf("\n\n")) >= 0) {
437 const raw = buffer.slice(0, boundary).replace(/\r/g, "");
438 buffer = buffer.slice(boundary + 2);
439 const event = { event: "", data: "" };
440 for (const line of raw.split("\n")) {
441 if (line.startsWith("event:")) event.event = line.slice(6).trim();
442 if (line.startsWith("data:")) event.data += line.slice(5).trim();
443 }
444 yield event;
445 }
446 }
447 }
448
449 export function createRuntimeClient({ runtimeUrl, runtimeToken }) {
450 function authHeaders() {
451 return { authorization: `Bearer ${runtimeToken}` };
452 }
453
454 async function runtimeJson(route, options = {}) {
455 const response = await fetch(`${runtimeUrl}${route}`, {
456 method: options.method || "GET",
457 headers: {
458 ...(options.auth === false ? {} : authHeaders()),
459 ...(options.body ? { "content-type": "application/json" } : {})
460 },
461 body: options.body ? JSON.stringify(options.body) : undefined
462 });
463 const body = await readJsonSafe(response);
464 if (!response.ok) {
465 throw new Error(compactRuntimeError(response.status, body));
466 }
467 return body;
468 }
469
470 return { runtimeJson, authHeaders };
471 }
472
473 export function compactRuntimeError(status, body) {
474 const message =
475 body?.error?.message ||
476 body?.message ||
477 (typeof body === "string" ? body : JSON.stringify(body));
478 return `Runtime API request failed (${status}): ${message}`;
479 }
480
481 export function latestRunningTurn(detail) {
482 const turns = Array.isArray(detail?.turns) ? detail.turns : [];
483 for (let index = turns.length - 1; index >= 0; index -= 1) {
484 const turn = turns[index];
485 if (["queued", "in_progress"].includes(turn?.status)) return turn;
486 }
487 return null;
488 }
489
490 export function activeTurnBlock(detail, state = {}) {
491 const runningTurn = latestRunningTurn(detail);
492 if (!runningTurn) return null;
493 const activeTurnId = state?.activeTurnId || "";
494 return {
495 turnId: runningTurn.id || activeTurnId,
496 message: `Thread already has active turn ${
497 runningTurn.id || activeTurnId || "(unknown)"
498 }. Wait for it to finish or send /interrupt.`
499 };
500 }
501
501 lines Plain Text