This repository has no description
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}