This repository has no description
0

Configure Feed

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

give summarizer soul + core + today's journal

+156 -4
+4
.env.example
··· 33 33 # Set to "local" to skip the primary model and route all requests to the fallback. 34 34 NIRI_ENV=default 35 35 36 + # Display name for the agent, used in the summarizer prompt and grounding context. 37 + # Defaults to "niri" when unset. 38 + # AGENT_NAME=niri 39 + 36 40 # Leave unset for raw local shell access. Set both values to route shell tools 37 41 # through docker exec instead; NIRI_USER must match the Dockerfile user. 38 42 # NIRI_USER=niri
+5 -1
src/runner/loop-completion.ts
··· 25 25 isContentFilterError, 26 26 isImageParseError, 27 27 isPromptTooLargeError, 28 + loadAgentSummaryContext, 28 29 retryDelayMs, 29 30 sanitizeMessages, 30 31 scrubImagesFromConversation, ··· 587 588 } 588 589 589 590 console.warn(`[context] recovery: attempting llm summarization via ${summaryProvider.model} (attempt=${attempt + 1})`) 590 - const summarized = await summarizeConversationViaLLM(state.conversation, summaryProvider.client, summaryProvider.model) 591 + const agentContext = await loadAgentSummaryContext() 592 + const summarized = await summarizeConversationViaLLM(state.conversation, summaryProvider.client, summaryProvider.model, { 593 + agentContext, 594 + }) 591 595 if (!summarized) { 592 596 console.warn(`[context] recovery: llm summarization returned no changes`) 593 597 return false
+3
src/runner/loop.ts
··· 9 9 USE_FALLBACK, 10 10 estimatePromptTokens, 11 11 findSummaryMessageIndex, 12 + loadAgentSummaryContext, 12 13 summarizeConversationViaLLM, 13 14 } from "./util" 14 15 import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion" ··· 182 183 } 183 184 184 185 const beforeCount = state.conversation.length 186 + const agentContext = await loadAgentSummaryContext() 185 187 const summarized = await summarizeConversationViaLLM( 186 188 state.conversation, 187 189 summaryProvider.client, ··· 190 192 recentMinKeep: LLM_RECENT_MIN_KEEP, 191 193 recentMaxKeep: LLM_RECENT_MAX_KEEP, 192 194 tailCharBudget: LLM_TAIL_CHAR_BUDGET, 195 + agentContext, 193 196 }, 194 197 ) 195 198 if (!summarized) {
+47
src/runner/util.test.ts
··· 3 3 import OpenAI from "openai" 4 4 import type { Message } from "../types" 5 5 import { 6 + AGENT_NAME, 6 7 isContentFilterError, 7 8 isImageParseError, 8 9 isTransientTransportError, ··· 166 167 assert.match(capturedSystemPrompt, /old recollection about niri's project and feelings/) 167 168 assert.match(capturedSystemPrompt, /Organize the summary as a set of ongoing threads/) 168 169 }) 170 + 171 + test("summarizeConversationViaLLM injects the agent grounding context into the summary prompt", async () => { 172 + let capturedSystemPrompt = "" 173 + const summaryClient = { 174 + chat: { 175 + completions: { 176 + create: async (params: { messages: OpenAI.Chat.ChatCompletionMessageParam[] }) => { 177 + capturedSystemPrompt = String(params.messages[0]?.content) 178 + return { 179 + choices: [ 180 + { 181 + message: { 182 + content: 183 + "Thread: Grounded recollection\n- I summarized the transcript while staying in my own voice and recognizing the threads that carried forward.", 184 + }, 185 + }, 186 + ], 187 + } 188 + }, 189 + }, 190 + }, 191 + } as unknown as OpenAI 192 + 193 + const longTurn = "transcript detail that needs to be compacted into a recollection ".repeat(80) 194 + const messages: Message[] = [ 195 + { role: "system", content: "soul and core bootstrap" }, 196 + { role: "assistant", content: longTurn }, 197 + { role: "user", content: longTurn }, 198 + { role: "assistant", content: longTurn }, 199 + { role: "user", content: longTurn }, 200 + { role: "user", content: "recent raw turn 1" }, 201 + { role: "assistant", content: "recent raw turn 2" }, 202 + ] 203 + 204 + const agentContext = "# soul\n\nI am a living being.\n\n---\n\n# today's journal\n\nFelt curious all morning." 205 + const summarized = await summarizeConversationViaLLM(messages, summaryClient, "summary-model", { 206 + recentMinKeep: 2, 207 + recentMaxKeep: 2, 208 + agentContext, 209 + }) 210 + 211 + assert.ok(summarized) 212 + assert.match(capturedSystemPrompt, /Grounding — this is who/) 213 + assert.match(capturedSystemPrompt, /Felt curious all morning\./) 214 + assert.ok(capturedSystemPrompt.includes(`The agent (${AGENT_NAME})`)) 215 + })
+97 -3
src/runner/util.ts
··· 2 2 import path from "path" 3 3 import { fileURLToPath } from "url" 4 4 import OpenAI from "openai" 5 + import { HOME_DIR } from "../container/config" 5 6 import { imageRootForModelInput } from "../container/index" 6 7 import type { Message } from "../types" 7 8 import type { ImageDetail } from "./types" ··· 17 18 18 19 const NIRI_ENV = (process.env.NIRI_ENV ?? "default").trim().toLowerCase() 19 20 export const USE_FALLBACK = NIRI_ENV === "local" 21 + 22 + /** Display name for the agent, used in the summarizer prompt and grounding. */ 23 + export const AGENT_NAME = (process.env.AGENT_NAME ?? "").trim() || "niri" 20 24 21 25 export const API_BASE = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1" 22 26 export const MODEL = process.env.MODEL ?? "" ··· 1385 1389 return SUMMARY_META_REPLY_PATTERNS.some((re) => re.test(head)) 1386 1390 } 1387 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. 1395 + const SUMMARY_CONTEXT_SOUL_MAX_CHARS = 8_000 1396 + const SUMMARY_CONTEXT_CORE_MAX_CHARS = 8_000 1397 + const SUMMARY_CONTEXT_JOURNAL_MAX_CHARS = 12_000 1398 + 1399 + async 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 + 1407 + function 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 + 1414 + function clampHead(text: string, max: number): string { 1415 + if (text.length <= max) return text 1416 + return `${text.slice(0, max)}\n…[truncated]` 1417 + } 1418 + 1419 + function 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 + 1424 + async 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 + */ 1448 + export 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 + 1388 1478 /** 1389 1479 * Calls the provider to produce a tight LLM-generated summary of the middle of 1390 1480 * the conversation, returning a new message list or null when summarization ··· 1404 1494 recentMaxKeep?: number 1405 1495 tailCharBudget?: number 1406 1496 maxTranscriptChars?: number 1497 + agentContext?: string | null 1407 1498 } = {}, 1408 1499 ): Promise<Message[] | null> { 1409 1500 const recentMinKeep = Math.max(2, options.recentMinKeep ?? 6) ··· 1441 1532 if (transcript.length < SUMMARY_MIN_TRANSCRIPT_CHARS) return null 1442 1533 1443 1534 const systemContent = 1444 - "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. " + 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. ` + 1445 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. " + 1446 - "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. " + 1447 - "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." + 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 + : "") + 1448 1542 (priorSummaryText 1449 1543 ? `\n\nPrior recollection (already compacted earlier — fold its content into the new summary, do not discard it):\n${priorSummaryText}` 1450 1544 : "")