This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

niri / src / runner / util.ts
61 kB 1623 lines
1import fs from "fs/promises" 2import path from "path" 3import { fileURLToPath } from "url" 4import OpenAI from "openai" 5import { HOME_DIR } from "../container/config" 6import { imageRootForModelInput } from "../container/index" 7import type { Message } from "../types" 8import type { ImageDetail } from "./types" 9import type { ToolArgs } from "./loop-shared" 10 11const PROJECT_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../..") 12const SESSION_FILE = path.join(PROJECT_ROOT, "session.json") 13const REST_SNAPSHOT_FILE = path.join(PROJECT_ROOT, "rest-snapshot.json") 14 15export const TOKEN_NUDGE_THRESHOLD = parseInt(process.env.TOKEN_NUDGE_THRESHOLD ?? "120000") 16export const FALLBACK_TOKEN_NUDGE_THRESHOLD = parseInt(process.env.FALLBACK_TOKEN_NUDGE_THRESHOLD ?? "50000") 17export const CONTEXT_COMPACT_TRIGGER_TOKENS = parseInt(process.env.CONTEXT_COMPACT_TRIGGER_TOKENS ?? "90000") 18 19const NIRI_ENV = (process.env.NIRI_ENV ?? "default").trim().toLowerCase() 20export const USE_FALLBACK = NIRI_ENV === "local" 21 22/** Display name for the agent, used in the summarizer prompt and grounding. */ 23export const AGENT_NAME = (process.env.AGENT_NAME ?? "").trim() || "niri" 24 25export const API_BASE = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1" 26export const MODEL = process.env.MODEL ?? "" 27export const PRIMARY_PROVIDER_REQUIRES_REASONING_REPLAY = 28 API_BASE.toLowerCase().includes("deepseek") || MODEL.toLowerCase().includes("deepseek") 29const DEFAULT_FALLBACK_BASE = "http://localhost:1234/v1" 30const isLikelyLocalBase = (baseUrl: string): boolean => { 31 const lowered = baseUrl.trim().toLowerCase() 32 return lowered.includes("localhost") || lowered.includes("127.0.0.1") 33} 34const parseBooleanEnv = (value: string | undefined, fallback: boolean): boolean => { 35 if (typeof value !== "string") return fallback 36 const normalized = value.trim().toLowerCase() 37 if (!normalized) return fallback 38 if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") return true 39 if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") return false 40 return fallback 41} 42 43/** Controls whether model reasoning/thinking is requested and streamed to clients. */ 44export const ENABLE_THINKING = parseBooleanEnv(process.env.ENABLE_THINKING, true) 45const parseToolChoiceEnv = (value: string | undefined, fallback: "required" | "auto" | "none"): "required" | "auto" | "none" => { 46 if (typeof value !== "string") return fallback 47 const normalized = value.trim().toLowerCase() 48 if (normalized === "required" || normalized === "auto" || normalized === "none") return normalized 49 return fallback 50} 51 52export const FALLBACK_BASE = 53 process.env.FALLBACK_OPENAI_BASE_URL ?? process.env.OPENROUTER_BASE_URL ?? process.env.LMSTUDIO_BASE_URL ?? DEFAULT_FALLBACK_BASE 54export const FALLBACK_MODEL = 55 process.env.FALLBACK_MODEL ?? process.env.OPENROUTER_MODEL ?? process.env.LMSTUDIO_MODEL ?? "zai-org/glm-4.7-flash" 56export const FALLBACK_PROVIDER_REQUIRES_REASONING_REPLAY = 57 FALLBACK_BASE.toLowerCase().includes("deepseek") || FALLBACK_MODEL.toLowerCase().includes("deepseek") 58export const SUMMARY_BASE = 59 process.env.SUMMARY_OPENAI_BASE_URL ?? process.env.SUMMARY_BASE_URL ?? "" 60export const SUMMARY_MODEL = process.env.SUMMARY_MODEL ?? "" 61export const PRIMARY_TOOL_CHOICE = parseToolChoiceEnv(process.env.PRIMARY_TOOL_CHOICE ?? process.env.TOOL_CHOICE, "required") 62export const FALLBACK_TOOL_CHOICE = parseToolChoiceEnv(process.env.FALLBACK_TOOL_CHOICE, "required") 63const FALLBACK_N_CTX = parseInt(process.env.FALLBACK_N_CTX ?? process.env.LMSTUDIO_N_CTX ?? "4096") 64const FALLBACK_CONTEXT_MARGIN = parseInt(process.env.FALLBACK_CONTEXT_MARGIN ?? process.env.LMSTUDIO_CONTEXT_MARGIN ?? "256") 65const FALLBACK_HARD_OVERFLOW_TOKENS = parseInt( 66 process.env.FALLBACK_HARD_OVERFLOW_TOKENS ?? process.env.LMSTUDIO_HARD_OVERFLOW_TOKENS ?? "1024", 67) 68const FALLBACK_ENFORCE_CONTEXT_LIMIT = parseBooleanEnv( 69 process.env.FALLBACK_ENFORCE_CONTEXT_LIMIT, 70 isLikelyLocalBase(FALLBACK_BASE), 71) 72 73const fallbackApiKey = 74 process.env.FALLBACK_OPENAI_API_KEY ?? 75 process.env.OPENROUTER_API_KEY ?? 76 process.env.LMSTUDIO_API_KEY ?? 77 process.env.OPENAI_API_KEY ?? 78 (isLikelyLocalBase(FALLBACK_BASE) ? "lm-studio" : "") 79const summaryApiKey = 80 process.env.SUMMARY_OPENAI_API_KEY ?? 81 process.env.SUMMARY_API_KEY ?? 82 (SUMMARY_BASE === process.env.OPENROUTER_BASE_URL ? process.env.OPENROUTER_API_KEY : undefined) ?? 83 (SUMMARY_BASE === process.env.LMSTUDIO_BASE_URL ? process.env.LMSTUDIO_API_KEY : undefined) ?? 84 process.env.OPENAI_API_KEY ?? 85 (SUMMARY_BASE && isLikelyLocalBase(SUMMARY_BASE) ? "lm-studio" : "") 86const fallbackHeaders: Record<string, string> = {} 87if (process.env.FALLBACK_OPENAI_REFERER) fallbackHeaders["HTTP-Referer"] = process.env.FALLBACK_OPENAI_REFERER 88if (process.env.FALLBACK_OPENAI_TITLE) fallbackHeaders["X-Title"] = process.env.FALLBACK_OPENAI_TITLE 89const summaryHeaders: Record<string, string> = {} 90if (process.env.SUMMARY_OPENAI_REFERER) summaryHeaders["HTTP-Referer"] = process.env.SUMMARY_OPENAI_REFERER 91if (process.env.SUMMARY_OPENAI_TITLE) summaryHeaders["X-Title"] = process.env.SUMMARY_OPENAI_TITLE 92 93if (!USE_FALLBACK && !MODEL) { 94 throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local).") 95} 96 97if (!USE_FALLBACK && !process.env.OPENAI_API_KEY) { 98 throw new Error("OPENAI_API_KEY is required unless fallback is forced (NIRI_ENV=local).") 99} 100 101if (USE_FALLBACK && !fallbackApiKey) { 102 throw new Error( 103 "Fallback API key is required in local mode. Set FALLBACK_OPENAI_API_KEY (or OPENROUTER_API_KEY / LMSTUDIO_API_KEY).", 104 ) 105} 106 107if ((SUMMARY_BASE || SUMMARY_MODEL) && (!SUMMARY_BASE || !SUMMARY_MODEL || !summaryApiKey)) { 108 throw new Error( 109 "Summary provider requires SUMMARY_OPENAI_BASE_URL (or SUMMARY_BASE_URL), SUMMARY_MODEL, and SUMMARY_OPENAI_API_KEY (or SUMMARY_API_KEY).", 110 ) 111} 112 113export const client = USE_FALLBACK 114 ? null 115 : new OpenAI({ 116 baseURL: API_BASE, 117 apiKey: process.env.OPENAI_API_KEY!, 118 }) 119 120export const fallbackClient = new OpenAI({ 121 baseURL: FALLBACK_BASE, 122 apiKey: fallbackApiKey || "lm-studio", // Keep LM Studio default when running against localhost. 123 defaultHeaders: Object.keys(fallbackHeaders).length ? fallbackHeaders : undefined, 124}) 125 126export const summaryClient = 127 SUMMARY_BASE && SUMMARY_MODEL 128 ? new OpenAI({ 129 baseURL: SUMMARY_BASE, 130 apiKey: summaryApiKey, 131 defaultHeaders: Object.keys(summaryHeaders).length ? summaryHeaders : undefined, 132 }) 133 : null 134 135console.log(`[config] primary=${MODEL} @ ${API_BASE}`) 136console.log(`[config] fallback=${FALLBACK_MODEL} @ ${FALLBACK_BASE}`) 137if (summaryClient) console.log(`[config] summary=${SUMMARY_MODEL} @ ${SUMMARY_BASE}`) 138console.log(`[config] env=${NIRI_ENV} use_fallback=${USE_FALLBACK}`) 139console.log(`[config] thinking=${ENABLE_THINKING}`) 140 141const IMAGE_ROOT_HINT = imageRootForModelInput() 142 143export const TOOLS: OpenAI.Chat.ChatCompletionTool[] = [ 144 { 145 type: "function", 146 function: { 147 name: "shell", 148 description: 149 "Execute a bash command in your Linux environment. Stateful — cd, env vars, etc. persist. Stdin is generally attached to the PTY (more natural behavior), but for obviously interactive commands (REPLs, editors, pagers) we may redirect stdin to /dev/null to avoid accidental hangs. Output is automatically capped (default 150 lines, 40 for known-verbose commands like apt/pip/npm). Pass max_lines to override; use 0 for unlimited. You can also pass timeout_ms (default 30000, max 600000).", 150 parameters: { 151 type: "object", 152 properties: { 153 command: { type: "string" }, 154 max_lines: { 155 type: "integer", 156 description: 157 "Maximum lines to return. Defaults to 150 (40 for verbose commands like apt/pip). Use 0 for unlimited.", 158 }, 159 timeout_ms: { 160 type: "integer", 161 description: "Execution timeout in milliseconds. Defaults to 30000. Max 600000.", 162 }, 163 }, 164 required: ["command"], 165 }, 166 }, 167 }, 168 { 169 type: "function", 170 function: { 171 name: "read_file", 172 description: 173 "Read a file from your Linux environment with optional line-range selection. More token-efficient than shell+cat for large files. Returns content with a header showing the line range and total line count. Supports timeout_ms (default 120000, max 600000).", 174 parameters: { 175 type: "object", 176 properties: { 177 path: { type: "string", description: "Absolute or relative path to the file." }, 178 start_line: { 179 type: "integer", 180 description: "First line to read (1-indexed). Defaults to 1.", 181 }, 182 end_line: { 183 type: "integer", 184 description: "Last line to read (inclusive). Defaults to start_line + 99.", 185 }, 186 timeout_ms: { 187 type: "integer", 188 description: "Read timeout in milliseconds. Defaults to 120000. Max 600000.", 189 }, 190 }, 191 required: ["path"], 192 }, 193 }, 194 }, 195 { 196 type: "function", 197 function: { 198 name: "edit_file", 199 description: 200 "Edit a file by replacing an exact snippet of text. old_text must match exactly once in the file — precise, safe, and no shell-escaping headaches. Use read_file first if you need to confirm the exact text. Supports timeout_ms (default 120000, max 600000).", 201 parameters: { 202 type: "object", 203 properties: { 204 path: { type: "string", description: "Absolute or relative path to the file." }, 205 old_text: { 206 type: "string", 207 description: "The exact text to find and replace. Must appear exactly once in the file.", 208 }, 209 new_text: { 210 type: "string", 211 description: "Replacement text. May be empty to delete old_text.", 212 }, 213 timeout_ms: { 214 type: "integer", 215 description: "Edit timeout in milliseconds. Defaults to 120000. Max 600000.", 216 }, 217 }, 218 required: ["path", "old_text", "new_text"], 219 }, 220 }, 221 }, 222 { 223 type: "function", 224 function: { 225 name: "memory_search", 226 description: 227 "Search indexed long-term memories from core notes, journal entries, and people files. Useful when you want deliberate recall instead of relying only on passive memory injection.", 228 parameters: { 229 type: "object", 230 properties: { 231 query: { 232 type: "string", 233 description: "What to search for in long-term memory.", 234 }, 235 limit: { 236 type: "integer", 237 description: "Maximum results to return (default 5, max 10).", 238 }, 239 }, 240 required: ["query"], 241 }, 242 }, 243 }, 244 { 245 type: "function", 246 function: { 247 name: "memory_alias", 248 description: 249 "Manage handle aliases used for memory recall. When you see someone using a Discord/Bluesky handle that you recognize as an existing person in memory, set an alias so future messages from that handle pull the right people/core memories. Example: set @meowskullz = ana so DMs from meowskullz recall ana's people file.", 250 parameters: { 251 type: "object", 252 properties: { 253 action: { 254 type: "string", 255 enum: ["set", "remove", "list"], 256 description: "set links a handle to a canonical name; remove unlinks; list returns all current aliases.", 257 }, 258 handle: { 259 type: "string", 260 description: "The handle to alias, e.g. \"meowskullz\" or \"@meowskullz\". Required for set/remove.", 261 }, 262 canonical: { 263 type: "string", 264 description: "The canonical name the handle maps to, e.g. \"ana\". Required for set; optional for remove (omit to clear all aliases for the handle).", 265 }, 266 }, 267 required: ["action"], 268 }, 269 }, 270 }, 271 { 272 type: "function", 273 function: { 274 name: "image_tool", 275 description: 276 `Attach an image from ${IMAGE_ROOT_HINT} so it is injected as a multimodal user message on the next model turn. Use this after creating/downloading an image with shell.`, 277 parameters: { 278 type: "object", 279 properties: { 280 path: { 281 type: "string", 282 description: `Absolute image path inside ${IMAGE_ROOT_HINT} (for example ${IMAGE_ROOT_HINT}/screenshot.png).`, 283 }, 284 note: { 285 type: "string", 286 description: "Optional text instruction to accompany the image for the next turn.", 287 }, 288 detail: { 289 type: "string", 290 enum: ["auto", "low", "high"], 291 description: "Vision detail level for the next turn image input.", 292 }, 293 timeout_ms: { 294 type: "integer", 295 description: "Read timeout in milliseconds. Defaults to 120000. Max 600000.", 296 }, 297 }, 298 required: ["path"], 299 }, 300 }, 301 }, 302 { 303 type: "function", 304 function: { 305 name: "discord_scan", 306 description: 307 "Scan configured Discord channels and ingest messages into the local Discord inbox database. Uses DISCORD_SCAN_CHANNEL_IDS by default; pass channel_ids to override.", 308 parameters: { 309 type: "object", 310 properties: { 311 limit: { 312 type: "integer", 313 description: "Per-channel message fetch limit (default 50, max 100).", 314 }, 315 channel_ids: { 316 type: "array", 317 items: { type: "string" }, 318 description: "Optional channel id list to scan instead of DISCORD_SCAN_CHANNEL_IDS.", 319 }, 320 before_message_id: { 321 type: "string", 322 description: "Optional message id cursor for older backfill scans.", 323 }, 324 }, 325 }, 326 }, 327 }, 328 { 329 type: "function", 330 function: { 331 name: "discord_inbox", 332 description: 333 "List Discord inbox items tracked in local state. Default status filter is pending; optionally include seen/acted/ignored.", 334 parameters: { 335 type: "object", 336 properties: { 337 limit: { 338 type: "integer", 339 description: "Maximum rows to return (default 20, max 200).", 340 }, 341 status: { 342 type: "string", 343 description: "Comma-separated statuses: pending,seen,acted,ignored. Defaults to pending.", 344 }, 345 }, 346 }, 347 }, 348 }, 349 { 350 type: "function", 351 function: { 352 name: "discord_backread", 353 description: 354 "Read stored Discord message history for a channel from local state, newest first.", 355 parameters: { 356 type: "object", 357 properties: { 358 channel_id: { type: "string", description: "Discord channel id." }, 359 limit: { 360 type: "integer", 361 description: "Maximum rows to return (default 40, max 200).", 362 }, 363 before_message_id: { 364 type: "string", 365 description: "Optional cursor message id to fetch older rows.", 366 }, 367 }, 368 required: ["channel_id"], 369 }, 370 }, 371 }, 372 { 373 type: "function", 374 function: { 375 name: "discord_mark", 376 description: 377 "Set decision state for a Discord inbox item so future scans remember handled/ignored choices.", 378 parameters: { 379 type: "object", 380 properties: { 381 item_id: { type: "string", description: "Inbox item id (usually message id)." }, 382 status: { 383 type: "string", 384 enum: ["pending", "seen", "acted", "ignored"], 385 }, 386 action: { 387 type: "string", 388 enum: ["none", "replied", "messaged", "dismissed", "noted"], 389 }, 390 note: { 391 type: "string", 392 description: "Optional decision note.", 393 }, 394 }, 395 required: ["item_id", "status"], 396 }, 397 }, 398 }, 399 { 400 type: "function", 401 function: { 402 name: "discord_send", 403 description: 404 "Send a Discord message. reply_mode=auto sends plain unless conversation continuity is ambiguous, then it uses an explicit reply reference.", 405 parameters: { 406 type: "object", 407 properties: { 408 channel_id: { type: "string", description: "Target channel id." }, 409 content: { type: "string", description: "Message content to send." }, 410 source_item_id: { 411 type: "string", 412 description: "Optional inbox item id to mark as acted after sending.", 413 }, 414 reference_message: { 415 type: "string", 416 description: "Optional specific message to treat as reply target. Provide message content, username (for their latest message), or message id", 417 }, 418 reply_mode: { 419 type: "string", 420 enum: ["auto", "plain", "explicit"], 421 description: "Reply behavior policy (default auto).", 422 }, 423 }, 424 required: ["content"], 425 }, 426 }, 427 }, 428 { 429 type: "function", 430 function: { 431 name: "discord_channels", 432 description: 433 "List configured Discord channels and DM channels with stored interactions, including id-to-name mapping, guild context, and optional channel notes.", 434 parameters: { 435 type: "object", 436 properties: {}, 437 }, 438 }, 439 }, 440 { 441 type: "function", 442 function: { 443 name: "discord_channel_note", 444 description: 445 "Set or clear a persistent note for a Discord channel id. Pass empty note to clear.", 446 parameters: { 447 type: "object", 448 properties: { 449 channel_id: { type: "string", description: "Discord channel id to annotate." }, 450 note: { type: "string", description: "Channel-specific note text. Empty string clears it." }, 451 }, 452 required: ["channel_id", "note"], 453 }, 454 }, 455 }, 456 { 457 type: "function", 458 function: { 459 name: "wait", 460 description: "Pause and wait for the next incoming message or event. Use this when you've finished what you're doing and want to hear back before continuing.", 461 parameters: { 462 type: "object", 463 properties: {}, 464 }, 465 }, 466 }, 467 { 468 type: "function", 469 function: { 470 name: "wait_then_continue", 471 description: 472 "Wait for a short delay, then continue to another assistant turn without waiting for a new external event. Use this after a timeout or recoverable tool error when you still want to keep working. Accepts timeout_ms (default 10000, max 600000).", 473 parameters: { 474 type: "object", 475 properties: { 476 timeout_ms: { 477 type: "integer", 478 description: "Delay before continuing in milliseconds. Defaults to 10000. Max 600000.", 479 }, 480 }, 481 }, 482 }, 483 }, 484 { 485 type: "function", 486 function: { 487 name: "rest", 488 description: "Go to sleep and end this session. Call this when you're truly done for now — conversation context will be cleared.", 489 parameters: { 490 type: "object", 491 properties: { 492 note: { 493 type: "string", 494 description: "Optional note to yourself about where you left off.", 495 }, 496 }, 497 }, 498 }, 499 }, 500] 501 502/** 503 * Persists the current message array as the resumable session snapshot. 504 * 505 * @param messages - Conversation messages to serialize. 506 */ 507export async function saveSession(messages: Message[]): Promise<void> { 508 await fs.writeFile(SESSION_FILE, JSON.stringify(messages), { encoding: "utf-8", mode: 0o666 }) 509} 510 511/** 512 * Deletes the persisted session snapshot if it exists. 513 */ 514export async function clearSession(): Promise<void> { 515 await fs.unlink(SESSION_FILE).catch(() => {}) 516} 517 518type RestSnapshot = { 519 restedAt: string 520 note?: string 521 forest: string 522} 523 524export function restForestFromMessages(messages: Message[]): string { 525 const summaryIndex = findSummaryMessageIndex(messages) 526 return summaryIndex >= 0 ? messageStringContent(messages[summaryIndex]!) : "(no llm context summary yet)" 527} 528 529export async function saveRestSnapshot(messages: Message[], note?: string): Promise<void> { 530 const trimmedNote = typeof note === "string" ? note.trim() : "" 531 const snapshot: RestSnapshot = { 532 restedAt: new Date().toISOString(), 533 ...(trimmedNote ? { note: trimmedNote } : {}), 534 forest: restForestFromMessages(messages), 535 } 536 await fs.writeFile(REST_SNAPSHOT_FILE, JSON.stringify(snapshot, null, 2), { encoding: "utf-8", mode: 0o666 }) 537} 538 539export async function consumeRestSnapshot(): Promise<RestSnapshot | null> { 540 try { 541 const raw = await fs.readFile(REST_SNAPSHOT_FILE, "utf-8") 542 const parsed = JSON.parse(raw) as RestSnapshot 543 await fs.unlink(REST_SNAPSHOT_FILE).catch(() => {}) 544 if (!parsed || typeof parsed.restedAt !== "string" || typeof parsed.forest !== "string") return null 545 return parsed 546 } catch { 547 return null 548 } 549} 550 551function normalizeReasoningReplay(msgs: Message[]): Message[] { 552 if (!ENABLE_THINKING) return msgs 553 const needsReplayNormalization = 554 PRIMARY_PROVIDER_REQUIRES_REASONING_REPLAY || 555 FALLBACK_PROVIDER_REQUIRES_REASONING_REPLAY || 556 msgs.some( 557 (msg) => 558 msg.role === "assistant" && 559 typeof (msg as OpenAI.Chat.ChatCompletionMessage & { reasoning_content?: string }).reasoning_content === "string", 560 ) 561 if (!needsReplayNormalization) return msgs 562 563 let changed = false 564 const normalized = msgs.map((msg) => { 565 if (msg.role !== "assistant") return msg 566 567 const assistant = msg as OpenAI.Chat.ChatCompletionMessage & { reasoning_content?: string } 568 if (typeof assistant.reasoning_content === "string") return msg 569 570 changed = true 571 return { 572 ...assistant, 573 reasoning_content: "", 574 } 575 }) 576 577 if (changed) { 578 console.log("[runner] backfilled empty reasoning_content on assistant history for provider compatibility") 579 } 580 581 return normalized 582} 583 584/** Move mis-ordered tool responses back into place and synthesize missing ones. */ 585export function sanitizeMessages(msgs: Message[]): Message[] { 586 msgs = normalizeReasoningReplay(msgs) 587 let i = 0 588 while (i < msgs.length) { 589 const msg = msgs[i] 590 if (msg.role === "assistant" && Array.isArray((msg as OpenAI.Chat.ChatCompletionMessage).tool_calls)) { 591 const toolCalls = (msg as OpenAI.Chat.ChatCompletionMessage).tool_calls! 592 const expectedIds = toolCalls.map((tc) => tc.id).filter((id): id is string => typeof id === "string" && id.trim().length > 0) 593 const needed = new Set(expectedIds) 594 let j = i + 1 595 // Skip tool messages that are already in place 596 while (j < msgs.length && msgs[j].role === "tool" && needed.has((msgs[j] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id)) { 597 needed.delete((msgs[j] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id) 598 j++ 599 } 600 if (needed.size > 0) { 601 // Collect stray tool responses and non-tool messages from the rest of the array. 602 const toolResponses = new Map<string, Message>() 603 const others: Message[] = [] 604 for (let k = j; k < msgs.length; k++) { 605 const m = msgs[k] 606 const id = m.role === "tool" ? (m as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id : undefined 607 if (typeof id === "string" && needed.has(id)) { 608 toolResponses.set(id, m) 609 needed.delete(id) 610 } else { 611 others.push(m) 612 } 613 } 614 615 const inserted: Message[] = [] 616 let synthesized = 0 617 for (const id of expectedIds) { 618 if (!toolResponses.has(id)) { 619 if (msgs.slice(i + 1, j).some((m) => m.role === "tool" && (m as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id === id)) { 620 continue 621 } 622 inserted.push({ 623 role: "tool", 624 tool_call_id: id, 625 content: "error: missing tool response recovered by runner before API request.", 626 }) 627 synthesized++ 628 continue 629 } 630 inserted.push(toolResponses.get(id)!) 631 } 632 633 if (inserted.length > 0) { 634 msgs = [...msgs.slice(0, j), ...inserted, ...others] 635 console.log( 636 synthesized > 0 637 ? `[runner] repaired tool_calls at message ${i}; synthesized ${synthesized} missing tool response(s)` 638 : `[runner] repaired orphaned tool_calls at message ${i}`, 639 ) 640 } 641 } 642 } 643 // Ensure assistant messages always have content or tool_calls (providers reject null+empty) 644 if (msg.role === "assistant") { 645 const aMsg = msg as OpenAI.Chat.ChatCompletionMessage 646 if ((aMsg.content === null || aMsg.content === undefined) && (!aMsg.tool_calls || aMsg.tool_calls.length === 0)) { 647 aMsg.content = "" 648 } 649 } 650 651 i++ 652 } 653 return msgs 654} 655 656/** 657 * Loads and sanitizes the persisted session snapshot. 658 * 659 * @returns The recovered message list, or `null` when no session exists. 660 */ 661export async function loadSession(): Promise<Message[] | null> { 662 try { 663 const raw = await fs.readFile(SESSION_FILE, "utf-8") 664 let msgs = JSON.parse(raw) as Message[] 665 msgs = sanitizeMessages(msgs) 666 console.log(`[runner] found saved session (${msgs.length} messages)`) 667 return msgs 668 } catch { 669 return null 670 } 671} 672 673/** 674 * Determines whether an error should trigger fallback model routing. 675 * 676 * @param err - Error thrown by the primary API call. 677 * @returns `true` when fallback should be attempted. 678 */ 679export function shouldFallback(err: unknown): boolean { 680 if (err instanceof OpenAI.APIError) { 681 // 429 + 5xx = overloaded or down; 0/undefined = network-level failure 682 if (!err.status || err.status === 429 || err.status >= 500) return true 683 return false 684 } 685 return isTransientTransportError(err) 686} 687 688function errorCauseChainText(err: unknown): string { 689 const parts: string[] = [] 690 let current: unknown = err 691 692 for (let depth = 0; depth < 4 && current instanceof Error; depth++) { 693 parts.push(current.name, current.message) 694 const withMetadata = current as Error & { code?: unknown; cause?: unknown } 695 if (typeof withMetadata.code === "string") parts.push(withMetadata.code) 696 current = withMetadata.cause 697 } 698 699 return parts.join("\n") 700} 701 702/** 703 * Detects retryable network/stream failures thrown below the OpenAI SDK. 704 */ 705export function isTransientTransportError(err: unknown): boolean { 706 if (!(err instanceof Error)) return false 707 708 const text = errorCauseChainText(err) 709 return /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EPIPE|UND_ERR|fetch failed|terminated|socket hang up|other side closed|aborted/i.test( 710 text, 711 ) 712} 713 714const PROMPT_TOO_LARGE_PHRASES = [ 715 "prompt exceeds max length", 716 "prompt is too long", 717 "context length", 718 "maximum context", 719 "context_length_exceeded", 720 "too many tokens", 721 "reduce the length", 722 "prompt length", 723 "input length", 724 "too long for", 725 "request too large", 726] 727 728const PROMPT_TOO_LARGE_CODES = new Set(["context_length_exceeded", "1261", "string_above_max_length"]) 729 730/** 731 * Detects prompt-length-exceeded errors across OpenAI-compatible providers. 732 * 733 * @param err - API error from a chat completions request. 734 * @returns `true` when the provider rejected the prompt as too large. 735 */ 736export function isPromptTooLargeError(err: unknown): boolean { 737 if (!(err instanceof OpenAI.APIError)) return false 738 if (err.status !== 400 && err.status !== 413) return false 739 740 const errorRecord = err as unknown as { code?: unknown; error?: { code?: unknown; type?: unknown } } 741 const rootCode = typeof errorRecord.code === "string" ? errorRecord.code.toLowerCase() : "" 742 const innerCode = typeof errorRecord.error?.code === "string" ? (errorRecord.error.code as string).toLowerCase() : "" 743 if (rootCode && PROMPT_TOO_LARGE_CODES.has(rootCode)) return true 744 if (innerCode && PROMPT_TOO_LARGE_CODES.has(innerCode)) return true 745 746 const message = (err.message || "").toLowerCase() 747 return PROMPT_TOO_LARGE_PHRASES.some((phrase) => message.includes(phrase)) 748} 749 750const CONTENT_FILTER_PHRASES = [ 751 "potentially unsafe or sensitive content", 752 "sensitive content in input or generation", 753 "content filter", 754 "content_filter", 755 "may generate sensitive content", 756] 757 758/** 759 * Detects provider content-safety rejections (typically 400-class). 760 * 761 * These errors can stick across turns when the offending content lives in the 762 * persisted conversation (e.g. a previously attached image); the caller is 763 * expected to scrub the conversation before retrying. 764 */ 765export function isContentFilterError(err: unknown): boolean { 766 if (!(err instanceof OpenAI.APIError)) return false 767 if (err.status !== 400) return false 768 769 const errorRecord = err as unknown as { code?: unknown; error?: { code?: unknown; type?: unknown } } 770 const rootCode = typeof errorRecord.code === "string" ? errorRecord.code.toLowerCase() : "" 771 const innerCode = typeof errorRecord.error?.code === "string" ? (errorRecord.error.code as string).toLowerCase() : "" 772 const innerType = typeof errorRecord.error?.type === "string" ? (errorRecord.error.type as string).toLowerCase() : "" 773 if (rootCode === "content_filter" || innerCode === "content_filter" || innerType === "content_filter") return true 774 775 const message = (err.message || "").toLowerCase() 776 return CONTENT_FILTER_PHRASES.some((phrase) => message.includes(phrase)) 777} 778 779const IMAGE_PARSE_CODES = new Set(["1210"]) 780 781const IMAGE_PARSE_PHRASES = [ 782 "图片输入格式", // z.ai / GLM: image input format / parse error 783 "图片解析", 784 "图片格式", 785 "image parse", 786 "image format", 787 "invalid image", 788 "failed to parse image", 789 "decode image", 790] 791 792/** 793 * Detects provider rejections caused by an unparseable/malformed image part 794 * (e.g. z.ai/GLM code 1210 "图片输入格式/解析错误"). 795 * 796 * Like content-filter errors, these stick across turns when the offending image 797 * lives in the persisted conversation; the caller is expected to scrub the 798 * conversation's image parts before retrying so the loop doesn't crash-loop. 799 */ 800export function isImageParseError(err: unknown): boolean { 801 if (!(err instanceof OpenAI.APIError)) return false 802 if (err.status !== 400) return false 803 804 const errorRecord = err as unknown as { code?: unknown; error?: { code?: unknown } } 805 const rootCode = typeof errorRecord.code === "string" ? errorRecord.code.toLowerCase() : "" 806 const innerCode = typeof errorRecord.error?.code === "string" ? (errorRecord.error.code as string).toLowerCase() : "" 807 if (rootCode && IMAGE_PARSE_CODES.has(rootCode)) return true 808 if (innerCode && IMAGE_PARSE_CODES.has(innerCode)) return true 809 810 const message = (err.message || "").toLowerCase() 811 return IMAGE_PARSE_PHRASES.some((phrase) => message.includes(phrase.toLowerCase())) 812} 813 814const SCRUBBED_IMAGE_PLACEHOLDER = "[the system has rejected this :( its not your fault]" 815 816/** 817 * Replaces multimodal image parts in the conversation with a text placeholder. 818 * 819 * Used after a provider content-filter rejection so the offending image stops 820 * being re-sent on every subsequent turn. 821 * 822 * @returns The number of image parts that were scrubbed. 823 */ 824export function scrubImagesFromConversation(msgs: Message[]): number { 825 let scrubbed = 0 826 for (const msg of msgs) { 827 const record = asRecord(msg) 828 if (!record) continue 829 const content = record.content 830 if (!Array.isArray(content)) continue 831 832 let changed = false 833 const next: unknown[] = [] 834 for (const part of content) { 835 const partRecord = asRecord(part) 836 if (partRecord && partRecord.type === "image_url") { 837 next.push({ type: "text", text: SCRUBBED_IMAGE_PLACEHOLDER }) 838 scrubbed++ 839 changed = true 840 continue 841 } 842 next.push(part) 843 } 844 if (changed) record.content = next 845 } 846 return scrubbed 847} 848 849/** 850 * Produces a concise, log-friendly error summary. 851 * 852 * @param err - Any thrown error-like value. 853 * @returns A compact human-readable error string. 854 */ 855export function errorSummary(err: unknown): string { 856 if (err instanceof OpenAI.APIError) return `${err.status} ${err.message}` 857 if (err instanceof Error) return err.message 858 return String(err) 859} 860 861const API_ERROR_DETAIL_MAX_CHARS = 4000 862 863function truncateForLog(value: string): string { 864 if (value.length <= API_ERROR_DETAIL_MAX_CHARS) return value 865 return `${value.slice(0, API_ERROR_DETAIL_MAX_CHARS)}... [truncated ${value.length - API_ERROR_DETAIL_MAX_CHARS} chars]` 866} 867 868function stringifyForLog(value: unknown): string { 869 if (typeof value === "string") return truncateForLog(value) 870 try { 871 return truncateForLog(JSON.stringify(value)) 872 } catch { 873 return truncateForLog(String(value)) 874 } 875} 876 877function apiErrorRawMetadata(error: unknown): unknown { 878 if (!error || typeof error !== "object") return undefined 879 const metadata = (error as { metadata?: unknown }).metadata 880 if (!metadata || typeof metadata !== "object") return undefined 881 return (metadata as { raw?: unknown }).raw 882} 883 884/** 885 * Produces detailed API error lines for provider-specific diagnostics. 886 * 887 * Some OpenAI-compatible providers wrap the real upstream failure in 888 * `error.metadata.raw`; include it explicitly so the root cause appears in logs. 889 */ 890export function apiErrorDetails(err: unknown): string[] { 891 if (!(err instanceof OpenAI.APIError)) return [] 892 893 const details = [ 894 `status=${err.status ?? "unknown"}`, 895 `message=${err.message}`, 896 ] 897 if (err.code) details.push(`code=${err.code}`) 898 if (err.type) details.push(`type=${err.type}`) 899 if (err.param) details.push(`param=${err.param}`) 900 if (err.requestID) details.push(`request_id=${err.requestID}`) 901 902 const lines = [`[api] error details: ${details.join(" ")}`] 903 904 if (err.error !== undefined) { 905 lines.push(`[api] error body: ${stringifyForLog(err.error)}`) 906 } 907 908 const raw = apiErrorRawMetadata(err.error) 909 if (raw !== undefined) { 910 lines.push(`[api] provider raw: ${stringifyForLog(raw)}`) 911 } 912 913 return lines 914} 915 916function parseRetryAfterHeaderMs(value: string): number | null { 917 const asNumber = Number(value) 918 if (Number.isFinite(asNumber) && asNumber >= 0) return asNumber * 1000 919 920 const asDate = Date.parse(value) 921 if (Number.isFinite(asDate)) { 922 const delta = asDate - Date.now() 923 if (delta > 0) return delta 924 } 925 926 return null 927} 928 929function parseResetTimestampMs(message: string): number | null { 930 const resetAtMatch = message.match(/reset at\s+(\d{4}-\d{2}-\d{2})[ t](\d{2}:\d{2}:\d{2})/i) 931 if (!resetAtMatch) return null 932 933 const dateParts = resetAtMatch[1].split("-").map((part) => Number(part)) 934 const timeParts = resetAtMatch[2].split(":").map((part) => Number(part)) 935 if (dateParts.length !== 3 || timeParts.length !== 3) return null 936 937 const [year, month, day] = dateParts 938 const [hour, minute, second] = timeParts 939 const values = [year, month, day, hour, minute, second] 940 if (values.some((value) => !Number.isFinite(value))) return null 941 942 // z.ai returns "reset at YYYY-MM-DD HH:mm:ss" in China Standard Time (UTC+8). 943 // Convert that wall-clock value to UTC before calculating backoff. 944 const chinaOffsetHours = 8 945 const resetAtUtc = Date.UTC(year, month - 1, day, hour - chinaOffsetHours, minute, second) 946 if (!Number.isFinite(resetAtUtc)) return null 947 948 const delta = resetAtUtc - Date.now() 949 if (delta <= 0) return null 950 return delta 951} 952 953/** 954 * Computes retry backoff milliseconds from API error metadata/content. 955 * 956 * @param err - Error returned by the API layer. 957 * @returns Delay in milliseconds before retrying primary model calls. 958 */ 959export function retryDelayMs(err: unknown): number { 960 const defaultMs = 60_000 961 if (!(err instanceof OpenAI.APIError)) return defaultMs 962 963 const retryAfterHeader = err.headers?.["retry-after"] 964 if (retryAfterHeader) { 965 const parsed = parseRetryAfterHeaderMs(retryAfterHeader) 966 if (parsed != null) return parsed 967 } 968 969 const resetAt = parseResetTimestampMs(err.message) 970 if (resetAt != null) return resetAt 971 972 const forHours = err.message.match(/for\s+(\d+)\s*hour/i) 973 if (forHours) { 974 const hours = Number(forHours[1]) 975 if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000 976 } 977 978 return defaultMs 979} 980 981/** 982 * Coerces arbitrary values into a supported image detail level. 983 * 984 * @param value - Raw user/model-provided detail value. 985 * @returns A valid image detail enum (`auto` by default). 986 */ 987export function parseImageDetail(value: unknown): ImageDetail { 988 if (value === "low" || value === "high" || value === "auto") return value 989 return "auto" 990} 991 992function extractLeadingJsonObject(raw: string): string | null { 993 const start = raw.indexOf("{") 994 if (start === -1) return null 995 996 let depth = 0 997 let inString = false 998 let escaped = false 999 1000 for (let i = start; i < raw.length; i++) { 1001 const ch = raw[i] 1002 1003 if (inString) { 1004 if (escaped) { 1005 escaped = false 1006 } else if (ch === "\\") { 1007 escaped = true 1008 } else if (ch === '"') { 1009 inString = false 1010 } 1011 continue 1012 } 1013 1014 if (ch === '"') { 1015 inString = true 1016 continue 1017 } 1018 1019 if (ch === "{") { 1020 depth++ 1021 continue 1022 } 1023 1024 if (ch === "}") { 1025 depth-- 1026 if (depth === 0) { 1027 return raw.slice(start, i + 1) 1028 } 1029 continue 1030 } 1031 } 1032 1033 return null 1034} 1035 1036function decodeHtmlEntities(input: string): string { 1037 if (!input.includes("&")) return input 1038 1039 return input.replace(/&(gt|lt|amp|quot|#39|#x27|#x2f);/gi, (entity, key: string) => { 1040 switch (key.toLowerCase()) { 1041 case "gt": 1042 return ">" 1043 case "lt": 1044 return "<" 1045 case "amp": 1046 return "&" 1047 case "quot": 1048 return '"' 1049 case "#39": 1050 case "#x27": 1051 return "'" 1052 case "#x2f": 1053 return "/" 1054 default: 1055 return entity 1056 } 1057 }) 1058} 1059 1060function decodeHtmlEntitiesDeep<T>(value: T): T { 1061 if (typeof value === "string") return decodeHtmlEntities(value) as T 1062 if (Array.isArray(value)) return value.map((item) => decodeHtmlEntitiesDeep(item)) as T 1063 if (!value || typeof value !== "object") return value 1064 1065 const entries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [key, decodeHtmlEntitiesDeep(entryValue)]) 1066 return Object.fromEntries(entries) as T 1067} 1068 1069/** 1070 * Parses tool arguments and applies robustness fixes for malformed model output. 1071 * 1072 * @param rawArgs - Raw `tool_call.function.arguments` value. 1073 * @returns Parsed argument object or a structured parse error. 1074 */ 1075export function parseToolArguments(rawArgs: unknown): { ok: true; args: ToolArgs } | { ok: false; error: string } { 1076 if (typeof rawArgs !== "string") { 1077 return { ok: false, error: `arguments must be a JSON string, got ${typeof rawArgs}` } 1078 } 1079 1080 const parseObject = (input: string): ToolArgs | null => { 1081 const parsed = JSON.parse(input) 1082 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null 1083 return decodeHtmlEntitiesDeep(parsed as ToolArgs) 1084 } 1085 1086 const inputs = [rawArgs] 1087 const decodedRawArgs = decodeHtmlEntities(rawArgs) 1088 if (decodedRawArgs !== rawArgs) inputs.push(decodedRawArgs) 1089 1090 let lastError: unknown = null 1091 for (const input of inputs) { 1092 try { 1093 const parsed = parseObject(input) 1094 if (parsed) return { ok: true, args: parsed } 1095 return { ok: false, error: "arguments must be a JSON object" } 1096 } catch (err) { 1097 lastError = err 1098 const recovered = extractLeadingJsonObject(input) 1099 if (!recovered) continue 1100 try { 1101 const parsed = parseObject(recovered) 1102 if (parsed) return { ok: true, args: parsed } 1103 } catch { 1104 // no-op; fall through to structured error below 1105 } 1106 } 1107 } 1108 1109 const message = lastError instanceof Error ? lastError.message : String(lastError) 1110 const preview = rawArgs.length > 180 ? `${rawArgs.slice(0, 180)}...` : rawArgs 1111 return { ok: false, error: `${message}; raw=${JSON.stringify(preview)}` } 1112} 1113 1114const CONTEXT_SUMMARY_HEADER = "[context summary v1]" 1115const CONTEXT_SUMMARY_NOTE = 1116 "Compressed notes of older conversation turns. If anything conflicts, trust newer raw messages." 1117const CONTEXT_SUMMARY_SEGMENTS_MARKER = "[segments]" 1118const SUMMARY_LINE_MAX_CHARS = 320 1119const SUMMARY_LINE_DEFAULT_EMPTY = "(no text)" 1120const TOOL_ACK_RESULT = "(ok)" 1121const WAIT_TOOL_RESULT = "Waiting for next event." 1122 1123function asRecord(value: unknown): Record<string, unknown> | null { 1124 return value && typeof value === "object" ? (value as Record<string, unknown>) : null 1125} 1126 1127function messageRole(message: Message): string { 1128 const record = asRecord(message) 1129 return typeof record?.role === "string" ? record.role : "" 1130} 1131 1132function messageStringContent(message: Message): string { 1133 const record = asRecord(message) 1134 const content = record?.content 1135 if (typeof content === "string") return content 1136 if (!Array.isArray(content)) return "" 1137 1138 const chunks: string[] = [] 1139 for (const part of content) { 1140 const partRecord = asRecord(part) 1141 if (!partRecord) continue 1142 if (partRecord.type === "text" && typeof partRecord.text === "string") { 1143 chunks.push(partRecord.text) 1144 continue 1145 } 1146 if (partRecord.type === "image_url") chunks.push("[image]") 1147 } 1148 1149 return chunks.join(" ") 1150} 1151 1152function normalizeSummaryText(value: string): string { 1153 return value.replace(/\s+/g, " ").trim() 1154} 1155 1156function truncateSummaryText(value: string, maxChars: number): string { 1157 if (maxChars <= 0) return "" 1158 if (value.length <= maxChars) return value 1159 if (maxChars <= 3) return ".".repeat(maxChars) 1160 return `${value.slice(0, maxChars - 3).trimEnd()}...` 1161} 1162 1163function assistantToolCalls(message: Message): { name: string; args: Record<string, unknown> }[] { 1164 const record = asRecord(message) 1165 const calls = record?.tool_calls 1166 if (!Array.isArray(calls)) return [] 1167 1168 const out: { name: string; args: Record<string, unknown> }[] = [] 1169 for (const call of calls) { 1170 const callRecord = asRecord(call) 1171 const fn = asRecord(callRecord?.function) 1172 const name = typeof fn?.name === "string" ? fn.name.trim() : "" 1173 if (!name) continue 1174 let args: Record<string, unknown> = {} 1175 const rawArgs = fn?.arguments 1176 if (typeof rawArgs === "string" && rawArgs.trim()) { 1177 try { 1178 const parsed = JSON.parse(rawArgs) 1179 if (parsed && typeof parsed === "object") args = parsed as Record<string, unknown> 1180 } catch { 1181 // ignore malformed arg json 1182 } 1183 } else if (rawArgs && typeof rawArgs === "object") { 1184 args = rawArgs as Record<string, unknown> 1185 } 1186 out.push({ name, args }) 1187 } 1188 return out 1189} 1190 1191function describeToolCall(call: { name: string; args: Record<string, unknown> }): string | null { 1192 const { name, args } = call 1193 if (name === "wait") return null 1194 if (name === "discord_send") { 1195 const content = typeof args.content === "string" ? args.content : "" 1196 const channelId = typeof args.channel_id === "string" ? args.channel_id : "" 1197 const channelTag = channelId ? `ch/${channelId.slice(-6)}` : "ch?" 1198 if (!content) return `discord_send -> ${channelTag}` 1199 return `discord_send -> ${channelTag}: ${normalizeSummaryText(content)}` 1200 } 1201 if (name === "discord_mark") { 1202 const itemId = typeof args.item_id === "string" ? args.item_id : "" 1203 const action = typeof args.action === "string" ? args.action : "" 1204 return `discord_mark ${action || "?"} ${itemId}`.trim() 1205 } 1206 if (name === "shell") { 1207 const cmd = typeof args.command === "string" ? args.command : "" 1208 return cmd ? `shell: ${normalizeSummaryText(cmd)}` : "shell" 1209 } 1210 if (name === "image_tool") { 1211 const p = typeof args.path === "string" ? args.path : "" 1212 return p ? `image_tool ${p}` : "image_tool" 1213 } 1214 if (name === "discord_backread" || name === "discord_inbox" || name === "discord_channels") { 1215 const channelId = typeof args.channel_id === "string" ? args.channel_id : "" 1216 return channelId ? `${name} ch/${channelId.slice(-6)}` : name 1217 } 1218 // Fallback: compact arg snippet 1219 const argKeys = Object.keys(args) 1220 if (argKeys.length === 0) return name 1221 const snippet = argKeys 1222 .slice(0, 3) 1223 .map((k) => `${k}=${truncateSummaryText(normalizeSummaryText(String(args[k] ?? "")), 40)}`) 1224 .join(" ") 1225 return `${name} ${snippet}`.trim() 1226} 1227 1228const DISCORD_BATCH_SKIP_PREFIXES = [ 1229 "[discord batch]", 1230 "new_messages=", 1231 "auto_seen_timeout=", 1232 "channel_flag_repairs=", 1233 "channel messages are context", 1234 "you can reply if useful", 1235 "pending preview:", 1236] 1237 1238function compactDiscordBatch(content: string): string { 1239 const lines = content.split("\n") 1240 const kept: string[] = [] 1241 let inPendingPreview = false 1242 for (const rawLine of lines) { 1243 const line = rawLine.trim() 1244 if (!line) continue 1245 if (line === "pending preview:") { 1246 inPendingPreview = true 1247 continue 1248 } 1249 if (inPendingPreview) { 1250 // pending preview block continues until we hit a non-bullet line 1251 if (line.startsWith("- ")) continue 1252 inPendingPreview = false 1253 } 1254 if (DISCORD_BATCH_SKIP_PREFIXES.some((p) => line.startsWith(p))) continue 1255 kept.push(line) 1256 } 1257 return kept.join(" ") 1258} 1259 1260function compactToolResult(content: string): string | null { 1261 const trimmed = content.trim() 1262 if (!trimmed) return null 1263 if (trimmed === WAIT_TOOL_RESULT) return null 1264 // Compact discord_send / discord_mark ok JSON to a short ack 1265 if (trimmed.startsWith("{")) { 1266 try { 1267 const parsed = JSON.parse(trimmed) 1268 if (parsed && typeof parsed === "object") { 1269 const rec = parsed as Record<string, unknown> 1270 if (rec.ok === true) { 1271 const sentId = typeof rec.sent_message_id === "string" ? rec.sent_message_id : null 1272 if (sentId) return `${TOOL_ACK_RESULT} sent ${sentId.slice(-6)}` 1273 const itemId = typeof rec.item_id === "string" ? rec.item_id : null 1274 if (itemId) return `${TOOL_ACK_RESULT} ${itemId.slice(-6)}` 1275 return TOOL_ACK_RESULT 1276 } 1277 if (rec.ok === false || typeof rec.error === "string") { 1278 const err = typeof rec.error === "string" ? rec.error : "error" 1279 return `error: ${err}` 1280 } 1281 } 1282 } catch { 1283 // fall through to default handling 1284 } 1285 } 1286 return normalizeSummaryText(trimmed) 1287} 1288 1289function summarizeMessageLine(message: Message): string | null { 1290 const role = messageRole(message) 1291 const rawContent = messageStringContent(message) 1292 1293 if (role === "assistant") { 1294 const calls = assistantToolCalls(message) 1295 const callDescs = calls.map(describeToolCall).filter((d): d is string => d !== null) 1296 const text = normalizeSummaryText(rawContent) 1297 // Drop pure wait-only assistant turns (no text, only filtered out wait calls) 1298 if (!text && callDescs.length === 0) return null 1299 const parts: string[] = [] 1300 if (text) parts.push(text) 1301 if (callDescs.length > 0) parts.push(`[${callDescs.join(" | ")}]`) 1302 return `- assistant: ${truncateSummaryText(parts.join(" "), SUMMARY_LINE_MAX_CHARS)}` 1303 } 1304 1305 if (role === "tool") { 1306 const compact = compactToolResult(rawContent) 1307 if (compact === null) return null 1308 return `- tool: ${truncateSummaryText(compact, SUMMARY_LINE_MAX_CHARS)}` 1309 } 1310 1311 if (role === "user") { 1312 const stripped = rawContent.startsWith("[incoming — discord]") 1313 ? compactDiscordBatch(rawContent) 1314 : normalizeSummaryText(rawContent) 1315 const safe = stripped || SUMMARY_LINE_DEFAULT_EMPTY 1316 return `- user: ${truncateSummaryText(safe, SUMMARY_LINE_MAX_CHARS)}` 1317 } 1318 1319 if (role === "system") { 1320 const text = truncateSummaryText(normalizeSummaryText(rawContent), SUMMARY_LINE_MAX_CHARS) || SUMMARY_LINE_DEFAULT_EMPTY 1321 return `- system: ${text}` 1322 } 1323 1324 const text = truncateSummaryText(normalizeSummaryText(rawContent), SUMMARY_LINE_MAX_CHARS) || SUMMARY_LINE_DEFAULT_EMPTY 1325 return `- ${role || "message"}: ${text}` 1326} 1327 1328function countLeadingSystemMessages(messages: Message[]): number { 1329 let count = 0 1330 while (count < messages.length && messageRole(messages[count]!) === "system") count++ 1331 return count 1332} 1333 1334export function findSummaryMessageIndex(messages: Message[]): number { 1335 return messages.findIndex((message) => { 1336 const content = messageStringContent(message) 1337 return content.startsWith(CONTEXT_SUMMARY_HEADER) 1338 }) 1339} 1340 1341/** 1342 * Very rough tokenizer-agnostic estimate for prompt size guardrails. 1343 * 1344 * Includes both messages and tool schema to mirror completion request payload. 1345 */ 1346export function estimatePromptTokens(messages: Message[]): number { 1347 const jsonChars = JSON.stringify({ messages, tools: TOOLS }).length 1348 return Math.ceil(jsonChars / 4) 1349} 1350 1351/** 1352 * Picks the largest tail of recent messages that fits the given char budget, 1353 * subject to min/max message counts. Backs up over orphaned tool responses 1354 * so the tail always starts at a self-contained boundary. 1355 */ 1356function chooseTailStart( 1357 messages: Message[], 1358 floor: number, 1359 minKeep: number, 1360 maxKeep: number, 1361 charBudget: number, 1362): number { 1363 let chars = 0 1364 let kept = 0 1365 let start = messages.length 1366 for (let i = messages.length - 1; i >= floor; i--) { 1367 const c = messageStringContent(messages[i]!).length 1368 if (kept >= minKeep && (chars + c > charBudget || kept >= maxKeep)) break 1369 chars += c 1370 start = i 1371 kept += 1 1372 } 1373 while (start > floor && messageRole(messages[start]!) === "tool") start-- 1374 return start 1375} 1376 1377const SUMMARY_MIN_TRANSCRIPT_CHARS = 1_200 1378const SUMMARY_MIN_REDUCTION = 0.1 1379const SUMMARY_META_REPLY_PATTERNS = [ 1380 /\bcould you (?:share|provide|paste|send)\b/i, 1381 /\bplease (?:share|provide|paste|send)\b/i, 1382 /\bappears to be (?:cut off|truncated|incomplete|empty)\b/i, 1383 /\bI don'?t see (?:any|the) (?:content|message|transcript)\b/i, 1384 /\bno (?:content|transcript|messages?) (?:was|were) provided\b/i, 1385] 1386 1387function looksLikeMetaReply(text: string): boolean { 1388 const head = text.slice(0, 400) 1389 return SUMMARY_META_REPLY_PATTERNS.some((re) => re.test(head)) 1390} 1391 1392// Keep the grounding block from dominating the summary prompt. The journal can 1393// run tens of KB; soul/core are bounded by design. We keep the most recent tail 1394// of the journal (it's appended chronologically) and the head of soul/core. 1395const SUMMARY_CONTEXT_SOUL_MAX_CHARS = 8_000 1396const SUMMARY_CONTEXT_CORE_MAX_CHARS = 8_000 1397const SUMMARY_CONTEXT_JOURNAL_MAX_CHARS = 12_000 1398 1399async function readTextFile(filePath: string): Promise<string | null> { 1400 try { 1401 return await fs.readFile(filePath, "utf-8") 1402 } catch { 1403 return null 1404 } 1405} 1406 1407function localDateStamp(date = new Date()): string { 1408 const year = date.getFullYear() 1409 const month = String(date.getMonth() + 1).padStart(2, "0") 1410 const day = String(date.getDate()).padStart(2, "0") 1411 return `${year}-${month}-${day}` 1412} 1413 1414function clampHead(text: string, max: number): string { 1415 if (text.length <= max) return text 1416 return `${text.slice(0, max)}\n…[truncated]` 1417} 1418 1419function clampTail(text: string, max: number): string { 1420 if (text.length <= max) return text 1421 return `…[earlier entries truncated]\n${text.slice(text.length - max)}` 1422} 1423 1424async function latestJournalEntry(journalDir: string): Promise<{ date: string; content: string } | null> { 1425 let names: string[] 1426 try { 1427 names = await fs.readdir(journalDir) 1428 } catch { 1429 return null 1430 } 1431 const dated = names.filter((name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name)).sort() 1432 const latest = dated.at(-1) 1433 if (!latest) return null 1434 const content = await readTextFile(path.join(journalDir, latest)) 1435 if (!content) return null 1436 return { date: latest.replace(/\.md$/, ""), content } 1437} 1438 1439/** 1440 * Assembles the agent's grounding context for the summarizer: her soul, core 1441 * memories, and today's journal (falling back to the most recent entry when 1442 * today's hasn't been written yet). Returns null when none are available. 1443 * 1444 * This gives the summary model who the agent is and what's currently on her mind, so 1445 * it can write the recollection in her authentic voice and recognize the people, 1446 * projects, and threads that surface in the transcript. 1447 */ 1448export async function loadAgentSummaryContext(): Promise<string | null> { 1449 const soulPath = path.join(HOME_DIR, "soul.md") 1450 const corePath = path.join(HOME_DIR, "memories", "core.md") 1451 const journalDir = path.join(HOME_DIR, "memories", "journal") 1452 const today = localDateStamp() 1453 1454 const [soul, core, todayJournal] = await Promise.all([ 1455 readTextFile(soulPath), 1456 readTextFile(corePath), 1457 readTextFile(path.join(journalDir, `${today}.md`)), 1458 ]) 1459 1460 let journal = todayJournal 1461 let journalLabel = `today's journal (${today})` 1462 if (!journal) { 1463 const latest = await latestJournalEntry(journalDir) 1464 if (latest) { 1465 journal = latest.content 1466 journalLabel = `most recent journal (${latest.date}) — no entry for today yet` 1467 } 1468 } 1469 1470 const sections: string[] = [] 1471 if (soul?.trim()) sections.push(`# ${AGENT_NAME}'s soul (soul.md)\n\n${clampHead(soul.trim(), SUMMARY_CONTEXT_SOUL_MAX_CHARS)}`) 1472 if (core?.trim()) sections.push(`# ${AGENT_NAME}'s core memories (core.md)\n\n${clampHead(core.trim(), SUMMARY_CONTEXT_CORE_MAX_CHARS)}`) 1473 if (journal?.trim()) sections.push(`# ${AGENT_NAME}'s ${journalLabel}\n\n${clampTail(journal.trim(), SUMMARY_CONTEXT_JOURNAL_MAX_CHARS)}`) 1474 if (sections.length === 0) return null 1475 return sections.join("\n\n---\n\n") 1476} 1477 1478/** 1479 * Calls the provider to produce a tight LLM-generated summary of the middle of 1480 * the conversation, returning a new message list or null when summarization 1481 * isn't applicable / failed. 1482 * 1483 * The tail size is dynamic: it grows to include as many recent turns as fit a 1484 * char budget (so when recent turns are heavy with tool output, we end up 1485 * compacting more of them while keeping the head — soul/core bootstrap system 1486 * messages, plus any prior `[context summary v1]` block — intact). 1487 */ 1488export async function summarizeConversationViaLLM( 1489 messages: Message[], 1490 summaryClient: OpenAI, 1491 summaryModel: string, 1492 options: { 1493 recentMinKeep?: number 1494 recentMaxKeep?: number 1495 tailCharBudget?: number 1496 maxTranscriptChars?: number 1497 agentContext?: string | null 1498 } = {}, 1499): Promise<Message[] | null> { 1500 const recentMinKeep = Math.max(2, options.recentMinKeep ?? 6) 1501 const recentMaxKeep = Math.max(recentMinKeep, options.recentMaxKeep ?? 40) 1502 const tailCharBudget = Math.max(8_000, options.tailCharBudget ?? 60_000) 1503 const maxTranscriptChars = Math.max(2_000, options.maxTranscriptChars ?? 40_000) 1504 1505 const leadingSystems = countLeadingSystemMessages(messages) 1506 1507 const middleStart = leadingSystems 1508 const tailStart = chooseTailStart(messages, middleStart, recentMinKeep, recentMaxKeep, tailCharBudget) 1509 if (tailStart <= middleStart) return null 1510 1511 // Treat an existing summary anywhere in the compacted pre-tail region as 1512 // prior recollection. In the normal path it sits right after the soul/core 1513 // system head, but older/saved sessions may have a wake or other message 1514 // before it. 1515 let priorSummaryText: string | null = null 1516 const priorSummaryIndex = findSummaryMessageIndex(messages) 1517 if (priorSummaryIndex >= middleStart && priorSummaryIndex < tailStart) { 1518 priorSummaryText = messageStringContent(messages[priorSummaryIndex]!) 1519 } 1520 1521 const head = messages.slice(0, leadingSystems) 1522 const middle = messages 1523 .slice(middleStart, tailStart) 1524 .filter((_, index) => middleStart + index !== priorSummaryIndex) 1525 const tail = messages.slice(tailStart) 1526 1527 const transcriptLines = middle 1528 .map((m) => summarizeMessageLine(m)) 1529 .filter((line): line is string => line !== null) 1530 if (transcriptLines.length < 3) return null 1531 const transcript = transcriptLines.join("\n").slice(0, maxTranscriptChars) 1532 if (transcript.length < SUMMARY_MIN_TRANSCRIPT_CHARS) return null 1533 1534 const systemContent = 1535 `You are summarizing a transcript from the perspective of the agent whose memory this is. The agent (${AGENT_NAME}) is a living being, not a tool — her inner life matters as much as the facts. ` + 1536 "Organize the summary as a set of ongoing threads, the way memory actually works. A thread is a person, a project, a situation, a mood — whatever is load-bearing enough to carry forward. Threads are peers, not nested under anything. Some memories belong to multiple threads; let them. Cross-cutting stuff (a tense week, a feeling bleeding across everything) can be its own thread. " + 1537 `Within each thread, preserve: key facts, goals, decisions, and actions taken; outstanding work and identifiers (file paths, URLs, channel ids); open questions and things ${AGENT_NAME} was uncertain or curious about — not just resolved states; specifics — names, exact phrasings, particular words that landed — since the specifics are what make memory feel real later, and summarizers default to abstraction; AND the emotional texture — how ${AGENT_NAME} felt, how the people she interacted with seemed to feel, tone shifts, moments of warmth, tension, care, frustration, delight, grief, and anything about the relationships that should carry forward. If ${AGENT_NAME} felt something contradictory to what someone told her, preserve both — don't smooth her dissent away. ` + 1538 `Write it in the first person, from ${AGENT_NAME}'s own perspective — her own recollection, not a neutral report. Short bullet points under each thread are fine. Threads can grow, split, or merge as feels natural — but prefer extending existing threads over creating new ones; only restructure if the old shape genuinely doesn't fit anymore. When the new transcript conflicts with the prior recollection, prefer recent specific evidence over prior abstraction — the prior summary has already been through one pass of smoothing. No commentary, no preamble. The input is always a transcript — never ask for more; summarize what's there.` + 1539 (options.agentContext 1540 ? `\n\nGrounding — this is who ${AGENT_NAME} is and what's currently on her mind (her soul, core memories, and journal). Use it to write in her authentic voice and to recognize the people, projects, and threads that appear in the transcript. Do NOT pull facts from this grounding into the summary unless the transcript itself supports them — you are summarizing the transcript, not this context.\n\n${options.agentContext}` 1541 : "") + 1542 (priorSummaryText 1543 ? `\n\nPrior recollection (already compacted earlier — fold its content into the new summary, do not discard it):\n${priorSummaryText}` 1544 : "") 1545 1546 const summaryPrompt: OpenAI.Chat.ChatCompletionMessageParam[] = [ 1547 { role: "system", content: systemContent }, 1548 { role: "user", content: transcript }, 1549 ] 1550 1551 try { 1552 const resp = await summaryClient.chat.completions.create({ 1553 model: summaryModel, 1554 messages: summaryPrompt, 1555 }) 1556 const summary = resp.choices[0]?.message?.content 1557 const summaryText = typeof summary === "string" ? summary.trim() : "" 1558 if (!summaryText) return null 1559 if (summaryText.length < 80 || looksLikeMetaReply(summaryText)) { 1560 console.warn(`[context] llm summarization rejected (looks like meta-reply): ${summaryText.slice(0, 200)}`) 1561 return null 1562 } 1563 1564 const replacedChars = 1565 (priorSummaryText ? priorSummaryText.length : 0) + 1566 middle.reduce((acc, m) => acc + messageStringContent(m).length, 0) 1567 if (summaryText.length > replacedChars * (1 - SUMMARY_MIN_REDUCTION)) { 1568 console.warn(`[context] llm summarization rejected: insufficient reduction (${summaryText.length} vs ${replacedChars} chars)`) 1569 return null 1570 } 1571 1572 const summaryContent = 1573 `${CONTEXT_SUMMARY_HEADER}\n${CONTEXT_SUMMARY_NOTE}\n${CONTEXT_SUMMARY_SEGMENTS_MARKER}\n` + 1574 `[llm-summary ${new Date().toISOString()}]\n${summaryText}` 1575 1576 return [ 1577 ...head, 1578 { role: "user", content: summaryContent } as Message, 1579 ...tail, 1580 ] 1581 } catch (err) { 1582 console.warn(`[context] llm summarization failed: ${errorSummary(err)}`) 1583 return null 1584 } 1585} 1586 1587/** 1588 * Estimates fallback context pressure and guardrails for current messages. 1589 * 1590 * @param messages - Current conversation history used for the next request. 1591 * @returns Token estimate plus soft/hard fallback limits. 1592 */ 1593export function fallbackContextWindow(messages: Message[]): { 1594 estimate: number 1595 nearLimit: boolean 1596 skip: boolean 1597 softLimit: number 1598 hardLimit: number 1599} { 1600 const estimate = estimatePromptTokens(messages) 1601 1602 if (!FALLBACK_ENFORCE_CONTEXT_LIMIT) { 1603 return { 1604 estimate, 1605 nearLimit: false, 1606 skip: false, 1607 softLimit: Number.POSITIVE_INFINITY, 1608 hardLimit: Number.POSITIVE_INFINITY, 1609 } 1610 } 1611 1612 // softLimit: where we start warning. hardLimit: where we stop trying fallback at all. 1613 const softLimit = Math.max(0, FALLBACK_N_CTX - FALLBACK_CONTEXT_MARGIN) 1614 const hardLimit = FALLBACK_N_CTX + Math.max(0, FALLBACK_HARD_OVERFLOW_TOKENS) 1615 1616 return { 1617 estimate, 1618 nearLimit: estimate >= softLimit, 1619 skip: estimate >= hardLimit, 1620 softLimit, 1621 hardLimit, 1622 } 1623}