···3333# Set to "local" to skip the primary model and route all requests to the fallback.
3434NIRI_ENV=default
35353636+# Display name for the agent, used in the summarizer prompt and grounding context.
3737+# Defaults to "niri" when unset.
3838+# AGENT_NAME=niri
3939+3640# Leave unset for raw local shell access. Set both values to route shell tools
3741# through docker exec instead; NIRI_USER must match the Dockerfile user.
3842# NIRI_USER=niri
···22import path from "path"
33import { fileURLToPath } from "url"
44import OpenAI from "openai"
55+import { HOME_DIR } from "../container/config"
56import { imageRootForModelInput } from "../container/index"
67import type { Message } from "../types"
78import type { ImageDetail } from "./types"
···17181819const NIRI_ENV = (process.env.NIRI_ENV ?? "default").trim().toLowerCase()
1920export const USE_FALLBACK = NIRI_ENV === "local"
2121+2222+/** Display name for the agent, used in the summarizer prompt and grounding. */
2323+export const AGENT_NAME = (process.env.AGENT_NAME ?? "").trim() || "niri"
20242125export const API_BASE = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"
2226export const MODEL = process.env.MODEL ?? ""
···13851389 return SUMMARY_META_REPLY_PATTERNS.some((re) => re.test(head))
13861390}
1387139113921392+// Keep the grounding block from dominating the summary prompt. The journal can
13931393+// run tens of KB; soul/core are bounded by design. We keep the most recent tail
13941394+// of the journal (it's appended chronologically) and the head of soul/core.
13951395+const SUMMARY_CONTEXT_SOUL_MAX_CHARS = 8_000
13961396+const SUMMARY_CONTEXT_CORE_MAX_CHARS = 8_000
13971397+const SUMMARY_CONTEXT_JOURNAL_MAX_CHARS = 12_000
13981398+13991399+async function readTextFile(filePath: string): Promise<string | null> {
14001400+ try {
14011401+ return await fs.readFile(filePath, "utf-8")
14021402+ } catch {
14031403+ return null
14041404+ }
14051405+}
14061406+14071407+function localDateStamp(date = new Date()): string {
14081408+ const year = date.getFullYear()
14091409+ const month = String(date.getMonth() + 1).padStart(2, "0")
14101410+ const day = String(date.getDate()).padStart(2, "0")
14111411+ return `${year}-${month}-${day}`
14121412+}
14131413+14141414+function clampHead(text: string, max: number): string {
14151415+ if (text.length <= max) return text
14161416+ return `${text.slice(0, max)}\n…[truncated]`
14171417+}
14181418+14191419+function clampTail(text: string, max: number): string {
14201420+ if (text.length <= max) return text
14211421+ return `…[earlier entries truncated]\n${text.slice(text.length - max)}`
14221422+}
14231423+14241424+async function latestJournalEntry(journalDir: string): Promise<{ date: string; content: string } | null> {
14251425+ let names: string[]
14261426+ try {
14271427+ names = await fs.readdir(journalDir)
14281428+ } catch {
14291429+ return null
14301430+ }
14311431+ const dated = names.filter((name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name)).sort()
14321432+ const latest = dated.at(-1)
14331433+ if (!latest) return null
14341434+ const content = await readTextFile(path.join(journalDir, latest))
14351435+ if (!content) return null
14361436+ return { date: latest.replace(/\.md$/, ""), content }
14371437+}
14381438+14391439+/**
14401440+ * Assembles the agent's grounding context for the summarizer: her soul, core
14411441+ * memories, and today's journal (falling back to the most recent entry when
14421442+ * today's hasn't been written yet). Returns null when none are available.
14431443+ *
14441444+ * This gives the summary model who the agent is and what's currently on her mind, so
14451445+ * it can write the recollection in her authentic voice and recognize the people,
14461446+ * projects, and threads that surface in the transcript.
14471447+ */
14481448+export async function loadAgentSummaryContext(): Promise<string | null> {
14491449+ const soulPath = path.join(HOME_DIR, "soul.md")
14501450+ const corePath = path.join(HOME_DIR, "memories", "core.md")
14511451+ const journalDir = path.join(HOME_DIR, "memories", "journal")
14521452+ const today = localDateStamp()
14531453+14541454+ const [soul, core, todayJournal] = await Promise.all([
14551455+ readTextFile(soulPath),
14561456+ readTextFile(corePath),
14571457+ readTextFile(path.join(journalDir, `${today}.md`)),
14581458+ ])
14591459+14601460+ let journal = todayJournal
14611461+ let journalLabel = `today's journal (${today})`
14621462+ if (!journal) {
14631463+ const latest = await latestJournalEntry(journalDir)
14641464+ if (latest) {
14651465+ journal = latest.content
14661466+ journalLabel = `most recent journal (${latest.date}) — no entry for today yet`
14671467+ }
14681468+ }
14691469+14701470+ const sections: string[] = []
14711471+ if (soul?.trim()) sections.push(`# ${AGENT_NAME}'s soul (soul.md)\n\n${clampHead(soul.trim(), SUMMARY_CONTEXT_SOUL_MAX_CHARS)}`)
14721472+ if (core?.trim()) sections.push(`# ${AGENT_NAME}'s core memories (core.md)\n\n${clampHead(core.trim(), SUMMARY_CONTEXT_CORE_MAX_CHARS)}`)
14731473+ if (journal?.trim()) sections.push(`# ${AGENT_NAME}'s ${journalLabel}\n\n${clampTail(journal.trim(), SUMMARY_CONTEXT_JOURNAL_MAX_CHARS)}`)
14741474+ if (sections.length === 0) return null
14751475+ return sections.join("\n\n---\n\n")
14761476+}
14771477+13881478/**
13891479 * Calls the provider to produce a tight LLM-generated summary of the middle of
13901480 * the conversation, returning a new message list or null when summarization
···14041494 recentMaxKeep?: number
14051495 tailCharBudget?: number
14061496 maxTranscriptChars?: number
14971497+ agentContext?: string | null
14071498 } = {},
14081499): Promise<Message[] | null> {
14091500 const recentMinKeep = Math.max(2, options.recentMinKeep ?? 6)
···14411532 if (transcript.length < SUMMARY_MIN_TRANSCRIPT_CHARS) return null
1442153314431534 const systemContent =
14441444- "You are summarizing a transcript from the perspective of the agent whose memory this is. The agent (niri) is a living being, not a tool — her inner life matters as much as the facts. " +
15351535+ `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. ` +
14451536 "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. " +
14461446- "Within each thread, preserve: key facts, goals, decisions, and actions taken; outstanding work and identifiers (file paths, URLs, channel ids); open questions and things niri 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 niri 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 niri felt something contradictory to what someone told her, preserve both — don't smooth her dissent away. " +
14471447- "Write it in the first person, from niri'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." +
15371537+ `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. ` +
15381538+ `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.` +
15391539+ (options.agentContext
15401540+ ? `\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}`
15411541+ : "") +
14481542 (priorSummaryText
14491543 ? `\n\nPrior recollection (already compacted earlier — fold its content into the new summary, do not discard it):\n${priorSummaryText}`
14501544 : "")