feat: inject memory summary into resident prompt

This commit is contained in:
Keisuke Hirata 2026-05-26 09:21:10 +09:00
parent 4a4ff0f6c9
commit 9ec77a2a2b
9 changed files with 336 additions and 35 deletions

View File

@ -289,6 +289,7 @@ impl MemoryConfig {
workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
inject_summary: upper.inject_summary.or(self.inject_summary),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold),

View File

@ -96,6 +96,10 @@ pub struct MemoryConfig {
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
#[serde(default)]
pub query_excerpt_lines: Option<usize>,
/// Whether the body of `memory/summary.md` is exposed in the resident
/// system-prompt section. `None` ⇒ enabled.
#[serde(default)]
pub inject_summary: Option<bool>,
/// Language used by memory extraction / consolidation workers for durable
/// memory and knowledge text. Free-form so workspaces can use names like
/// `English`, `Japanese`, or locale tags. `None` ⇒
@ -669,6 +673,15 @@ model_id = "claude-sonnet-4-20250514"
let manifest = PodManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed");
assert!(mem.workspace_root.is_none());
assert_eq!(mem.inject_summary, None);
}
#[test]
fn memory_section_with_inject_summary_false() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.inject_summary, Some(false));
}
#[test]

View File

@ -22,7 +22,10 @@ pub use error::{LintError, LintWarning, MemoryError};
pub use extract::ExtractPointerPayload;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs};
pub use resident::{
ResidentKnowledgeEntry, collect_resident_knowledge, collect_resident_summary,
list_knowledge_slugs,
};
pub use scope::deny_write_rules;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,

View File

@ -1,10 +1,12 @@
//! Workspace knowledge enumeration helpers.
//! Workspace memory resident-enumeration helpers.
//!
//! Two surfaces, both walking `<workspace>/.insomnia/knowledge/*.md`:
//! Surfaces used by the Pod system-prompt assembler:
//!
//! - [`collect_resident_knowledge`] — resident-injection candidates
//! (`model_invokation: true`) returned as `(slug, description)` pairs
//! for the Pod system-prompt assembler.
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
//! - [`collect_resident_summary`] — the body of
//! `<workspace>/.insomnia/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
//! of `model_invokation`. Used by the Pod IPC layer to answer TUI `#`
//! completion (`model_invokation` is a resident-injection flag, not a
@ -14,7 +16,7 @@
//! enforces shape on write, so a malformed file here means external
//! tampering and we'd rather degrade than panic.
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
use crate::schema::{KnowledgeFrontmatter, SummaryFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
#[derive(Debug, Clone, PartialEq, Eq)]
@ -40,6 +42,21 @@ pub fn collect_resident_knowledge(layout: &WorkspaceLayout) -> Vec<ResidentKnowl
out
}
/// Read `<workspace>/.insomnia/memory/summary.md` for resident prompt
/// injection. Returns only the markdown body (frontmatter stripped), and
/// degrades to `None` for missing, unreadable, malformed, or empty records.
pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
let raw = std::fs::read_to_string(layout.summary_path()).ok()?;
let (yaml, body) = split_frontmatter(&raw).ok()?;
let _fm: SummaryFrontmatter = serde_yaml::from_str(yaml).ok()?;
let body = body.trim_matches(&['\n', '\r'][..]);
if body.trim().is_empty() {
None
} else {
Some(body.to_string())
}
}
/// Walk `<workspace>/knowledge/*.md` and return every slug whose
/// frontmatter parses, sorted ascending. Does not filter on
/// `model_invokation`. A missing `knowledge/` directory yields an empty
@ -97,6 +114,12 @@ mod tests {
Utc::now().to_rfc3339()
}
fn write_summary(dir: &Path, body: &str) {
let path = dir.join(".insomnia/memory/summary.md");
let content = format!("---\nupdated_at: {n}\n---\n{body}", n = now());
std::fs::write(path, content).unwrap();
}
fn write_knowledge(
dir: &Path,
slug: &str,
@ -116,10 +139,48 @@ mod tests {
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/knowledge")).unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/memory")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn missing_summary_returns_none() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn summary_returns_body_without_frontmatter() {
let (dir, layout) = setup();
write_summary(dir.path(), "remember this\n");
let got = collect_resident_summary(&layout).unwrap();
assert_eq!(got, "remember this");
assert!(!got.contains("updated_at"));
assert!(!got.contains("---"));
}
#[test]
fn malformed_summary_returns_none() {
let (dir, layout) = setup();
std::fs::write(
dir.path().join(".insomnia/memory/summary.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn empty_summary_body_returns_none() {
let (dir, layout) = setup();
write_summary(dir.path(), " \n");
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn missing_knowledge_dir_returns_empty() {
let dir = TempDir::new().unwrap();

View File

@ -647,6 +647,7 @@ permission = "write"
scope: &scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: None,
resident_workflows: None,
prompts: &catalog,

View File

@ -314,11 +314,12 @@ pub struct Pod<C: LlmClient, St: Store> {
/// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug.
memory_layout: Option<memory::WorkspaceLayout>,
/// When true (default), the system-prompt assembler walks
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
/// section listing records with `model_invokation: true`.
/// consolidation workers set this to false so the
/// agentic worker pulls knowledge through the search tools instead.
/// When true (default), the system-prompt assembler may append resident
/// memory sections from the workspace: `memory/summary.md`, resident
/// knowledge records, and resident workflows. Consolidation workers set
/// this to false so the agentic worker pulls knowledge through the
/// search tools instead, and so disposable internal workers avoid
/// resident memory exposure entirely.
inject_resident_knowledge: bool,
/// Latest runtime scope snapshot queued by dynamic scope changes.
/// Drained into the session log before the next turn result is
@ -593,16 +594,16 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.system_prompt_template = Some(template);
}
/// Toggle the resident-knowledge section of the system prompt.
/// Toggle resident memory sections in the system prompt.
///
/// Default `true`: when memory is enabled in the manifest, the
/// assembler walks `<workspace>/knowledge/*` and lists records with
/// `model_invokation: true`. consolidation workers and
/// other agentic memory paths set this to `false` so the worker
/// pulls knowledge through the search tools instead of riding on
/// the resident system-prompt budget. Idempotent if called multiple
/// times before the first turn; ineffective once the system prompt
/// has been materialised.
/// assembler can expose the summary body, resident knowledge records,
/// and resident workflows. Consolidation workers and other internal
/// disposable workers set this to `false` so the worker pulls knowledge
/// through the search tools instead of riding on the resident
/// system-prompt budget. Idempotent if called multiple times before
/// the first turn; ineffective once the system prompt has been
/// materialised.
pub fn set_resident_knowledge_injection(&mut self, enabled: bool) {
self.inject_resident_knowledge = enabled;
}
@ -1160,10 +1161,25 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
// Resident-injection collection: only when memory is enabled in
// the manifest AND this Pod opts in (consolidation workers opt out).
// Owned `Vec` lives for the duration of `render` below; the
// context borrows a slice into it.
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
// the manifest AND this Pod opts in (internal workers opt out).
// Owned values live for the duration of `render` below; the
// context borrows from them.
let inject_memory_resident = self.inject_resident_knowledge && self.memory_layout.is_some();
let inject_summary = inject_memory_resident
&& self
.manifest
.memory
.as_ref()
.and_then(|m| m.inject_summary)
.unwrap_or(true);
let resident_summary: Option<String> = if inject_summary {
self.memory_layout
.as_ref()
.and_then(memory::collect_resident_summary)
} else {
None
};
let resident: Vec<memory::ResidentKnowledgeEntry> = if inject_memory_resident {
self.memory_layout
.as_ref()
.map(memory::collect_resident_knowledge)
@ -1171,20 +1187,19 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} else {
Vec::new()
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident)
} else {
None
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = if inject_memory_resident {
Some(&resident)
} else {
None
};
let resident_workflows: Vec<workflow_crate::ResidentWorkflowEntry> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
if inject_memory_resident {
self.workflow_registry.resident_entries()
} else {
Vec::new()
};
let resident_workflow_slice: Option<&[workflow_crate::ResidentWorkflowEntry]> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
if inject_memory_resident {
Some(&resident_workflows)
} else {
None
@ -1200,6 +1215,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope: &scope_snapshot,
tool_names,
agents_md: agents_md_read.body,
resident_summary: resident_summary.as_deref(),
resident_knowledge: resident_slice,
resident_workflows: resident_workflow_slice,
prompts: &self.prompts,
@ -4491,6 +4507,118 @@ mod build_summary_prompt_tests {
assert_eq!(interrupt_system_count, 1);
}
async fn render_system_prompt_with_summary(
summary_doc: Option<&str>,
memory_config: Option<manifest::MemoryConfig>,
resident_injection: bool,
) -> String {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let pwd = dir.path().join("workspace");
std::fs::create_dir_all(&pwd).unwrap();
if let Some(doc) = summary_doc {
std::fs::create_dir_all(pwd.join(".insomnia/memory")).unwrap();
std::fs::write(pwd.join(".insomnia/memory/summary.md"), doc).unwrap();
}
let mut manifest = minimal_manifest_with_skills(vec![]);
manifest.memory = memory_config;
let scope = Scope::writable(&pwd).unwrap();
let mut pod = Pod::new(manifest, Worker::new(NoopClient), store, pwd.clone(), scope)
.await
.unwrap();
pod.memory_layout = pod
.manifest
.memory
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
pod.set_resident_knowledge_injection(resident_injection);
let template = SystemPromptTemplate::parse(
"$insomnia/default",
crate::prompt::loader::PromptLoader::builtins_only(),
)
.unwrap();
pod.set_system_prompt_template(template);
pod.ensure_system_prompt_materialized().unwrap();
pod.worker().get_system_prompt().unwrap().to_string()
}
fn summary_doc(body: &str) -> String {
format!("---\nupdated_at: 2026-01-01T00:00:00Z\n---\n{body}")
}
#[tokio::test]
async fn resident_summary_body_is_injected_without_frontmatter() {
let rendered = render_system_prompt_with_summary(
Some(&summary_doc("summary body for resident prompt\n")),
Some(manifest::MemoryConfig::default()),
true,
)
.await;
assert!(rendered.contains("## Resident memory summary"));
assert!(rendered.contains("summary body for resident prompt"));
assert!(!rendered.contains("updated_at: 2026-01-01T00:00:00Z"));
assert!(!rendered.contains("---\nupdated_at"));
}
#[tokio::test]
async fn resident_summary_injection_can_be_disabled_by_manifest() {
let memory = manifest::MemoryConfig {
inject_summary: Some(false),
..manifest::MemoryConfig::default()
};
let rendered = render_system_prompt_with_summary(
Some(&summary_doc("disabled summary body\n")),
Some(memory),
true,
)
.await;
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("disabled summary body"));
}
#[tokio::test]
async fn resident_summary_is_absent_without_memory_config() {
let rendered = render_system_prompt_with_summary(
Some(&summary_doc("memory-disabled summary body\n")),
None,
true,
)
.await;
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("memory-disabled summary body"));
}
#[tokio::test]
async fn malformed_resident_summary_does_not_fail_render() {
let rendered = render_system_prompt_with_summary(
Some("---\nthis is not yaml: : :\n---\nbad summary body\n"),
Some(manifest::MemoryConfig::default()),
true,
)
.await;
assert!(rendered.contains("## Working boundaries"));
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("bad summary body"));
}
#[tokio::test]
async fn resident_summary_respects_internal_worker_opt_out() {
let rendered = render_system_prompt_with_summary(
Some(&summary_doc("internal opt-out summary body\n")),
Some(manifest::MemoryConfig::default()),
false,
)
.await;
assert!(!rendered.contains("Resident memory summary"));
assert!(!rendered.contains("internal opt-out summary body"));
}
fn minimal_manifest_with_skills(dirs: Vec<PathBuf>) -> PodManifest {
// Construct the smallest possible PodManifest that resolves; only
// the `skills` field matters for `skill_dir_read_rules`.

View File

@ -79,8 +79,12 @@ pub enum PodPrompt {
/// Trailing `## Project instructions (AGENTS.md)` section, appended
/// after the scope summary when an AGENTS.md is present.
AgentsMdSection,
/// Trailing `## Resident memory summary` section, appended after the
/// AGENTS.md section when memory is enabled, summary injection is enabled,
/// and `memory/summary.md` has a valid non-empty body.
ResidentMemorySummarySection,
/// Trailing `## Resident knowledge` section, appended after the
/// AGENTS.md section when memory is enabled and at least one
/// resident memory summary when memory is enabled and at least one
/// `knowledge/*` record advertises `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
@ -100,6 +104,7 @@ impl PodPrompt {
Self::InterruptSystemNote => "interrupt_system_note",
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
}
@ -117,6 +122,7 @@ impl PodPrompt {
PodPrompt::InterruptSystemNote,
PodPrompt::WorkingBoundariesSection,
PodPrompt::AgentsMdSection,
PodPrompt::ResidentMemorySummarySection,
PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection,
];
@ -130,6 +136,7 @@ impl PodPrompt {
"interrupt_system_note",
"working_boundaries_section",
"agents_md_section",
"resident_memory_summary_section",
"resident_knowledge_section",
"resident_workflows_section",
];
@ -352,6 +359,14 @@ impl PromptCatalog {
self.render(PodPrompt::AgentsMdSection, single("agents_md", agents_md))
}
/// Render `PodPrompt::ResidentMemorySummarySection` with `{{ summary }}`.
pub fn resident_memory_summary_section(&self, summary: &str) -> Result<String, CatalogError> {
self.render(
PodPrompt::ResidentMemorySummarySection,
single("summary", summary),
)
}
/// Render `PodPrompt::ResidentKnowledgeSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_knowledge_section(&self, entries: &str) -> Result<String, CatalogError> {

View File

@ -8,9 +8,9 @@
//! prompt is materialised exactly once just before the first LLM turn:
//! the rendered body is appended with a fixed trailing section carrying
//! the Pod's `Scope` summary and (if present) the project's `AGENTS.md`
//! contents, and the whole string is handed to the Worker via
//! `set_system_prompt`. Subsequent turns and compactions reuse that
//! materialised string verbatim.
//! contents plus resident memory sections, and the whole string is handed
//! to the Worker via `set_system_prompt`. Subsequent turns and compactions
//! reuse that materialised string verbatim.
use std::collections::BTreeMap;
use std::path::Path;
@ -122,6 +122,7 @@ impl SystemPromptTemplate {
ctx.prompts,
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_summary,
ctx.resident_knowledge,
ctx.resident_workflows,
)
@ -152,6 +153,10 @@ pub struct SystemPromptContext<'a> {
/// Not visible from the template; consumed by the trailing-section
/// formatter in [`SystemPromptTemplate::render`].
pub agents_md: Option<String>,
/// The body of `<workspace>/.insomnia/memory/summary.md`, with
/// frontmatter stripped. `None` disables the resident summary section;
/// empty strings are ignored by the trailing-section formatter.
pub resident_summary: Option<&'a str>,
/// Resident-injection candidates from `<workspace>/knowledge/*` whose
/// frontmatter has `model_invokation: true`. `None` disables the
/// section entirely (memory disabled, or a consolidation worker that opts
@ -209,6 +214,7 @@ pub fn append_trailing_section(
prompts: &PromptCatalog,
scope: &Scope,
agents_md: Option<&str>,
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
) -> Result<String, SystemPromptError> {
@ -228,6 +234,15 @@ pub fn append_trailing_section(
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
if let Some(summary) = resident_summary {
let summary = summary.trim_matches(&['\n', '\r'][..]);
if !summary.trim().is_empty() {
out.push('\n');
let section = prompts.resident_memory_summary_section(summary)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if let Some(entries) = resident_knowledge {
if !entries.is_empty() {
out.push('\n');
@ -335,6 +350,26 @@ mod tests {
scope,
tool_names: tools,
agents_md,
resident_summary: None,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_summary<'a>(
cwd: &'a Path,
scope: &'a Scope,
summary: Option<&'a str>,
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd,
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: summary,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
@ -353,6 +388,7 @@ mod tests {
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
@ -568,6 +604,40 @@ mod tests {
assert!(!rendered.contains("Project instructions"));
}
#[test]
fn trailing_section_renders_resident_summary_body() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx_with_summary(
dir.path(),
&scope,
Some("Persistent summary body"),
))
.unwrap();
assert!(rendered.contains("## Resident memory summary"));
assert!(rendered.contains("Persistent summary body"));
}
#[test]
fn trailing_section_omits_resident_summary_when_none_or_empty() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx_with_summary(dir.path(), &scope, None))
.unwrap();
assert!(!rendered.contains("Resident memory summary"));
let rendered = tmpl
.render(&ctx_with_summary(dir.path(), &scope, Some(" \n")))
.unwrap();
assert!(!rendered.contains("Resident memory summary"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_none() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");

View File

@ -39,6 +39,15 @@ agents_md_section = """\
{{ agents_md }}\
"""
resident_memory_summary_section = """\
---
## Resident memory summary
The following is the current durable session/workspace summary. Treat it as background context; it is not a user request.
{{ summary }}\
"""
resident_knowledge_section = """\
---
## Resident knowledge