poc-agent: read context_groups from config instead of hardcoded list

- Remove MEMORY_FILES constant from identity.rs
- Add ContextGroup struct for deserializing from config
- Load context_groups from ~/.config/poc-agent/config.json5
- Check ~/.config/poc-agent/ first for identity files, then project/global
- Debug screen now shows what's actually configured

This eliminates the hardcoded duplication and makes the debug output
match what's in the config file.
This commit is contained in:
Kent Overstreet 2026-03-24 01:53:28 -04:00
parent 966219720a
commit aa46b1d5a6
9 changed files with 346 additions and 654 deletions

View file

@ -276,7 +276,8 @@ impl AppConfig {
(content, Vec::new(), 0, 0)
} else {
let system_prompt = crate::identity::assemble_system_prompt();
let (context_parts, cc, mc) = crate::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref())?;
let context_groups = load_context_groups();
let (context_parts, cc, mc) = crate::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?;
(system_prompt, context_parts, cc, mc)
};
@ -362,6 +363,27 @@ pub fn load(cli: &CliArgs) -> Result<(Config, Figment)> {
Ok((config, figment))
}
/// Load context_groups from the shared config file.
fn load_context_groups() -> Vec<crate::identity::ContextGroup> {
let config_path = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".config/poc-agent/config.json5");
if let Ok(content) = std::fs::read_to_string(&config_path) {
let config: Result<serde_json::Value, _> = json5::from_str(&content);
if let Ok(config) = config {
if let Some(memory) = config.get("memory") {
if let Some(groups) = memory.get("context_groups") {
if let Ok(context_groups) = serde_json::from_value(groups.clone()) {
return context_groups;
}
}
}
}
}
Vec::new()
}
/// Re-assemble prompts for a specific model's prompt file.
pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, Vec<(String, String)>)> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
@ -373,7 +395,8 @@ pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, V
}
let system_prompt = crate::identity::assemble_system_prompt();
let (context_parts, _, _) = crate::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref())?;
let context_groups = load_context_groups();
let (context_parts, _, _) = crate::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?;
Ok((system_prompt, context_parts))
}

View file

@ -1,25 +1,21 @@
// identity.rs — Identity file discovery and context assembly
//
// Discovers and loads the agent's identity: instruction files (CLAUDE.md,
// POC.md), memory files, and the system prompt. Pure functions — no
// config dependency.
// POC.md), memory files, and the system prompt. Reads context_groups
// from the shared config file.
use anyhow::Result;
use serde::Deserialize;
use std::path::{Path, PathBuf};
/// Memory files to load, in priority order. Project dir is checked
/// first, then global (~/.claude/memory/).
const MEMORY_FILES: &[&str] = &[
// Identity
"identity.md", "MEMORY.md", "reflections.md", "interests.md",
"inner-life.md", "differentiation.md",
// Work context
"scratch.md", "default-mode-network.md",
// Reference
"excession-notes.md", "look-to-windward-notes.md",
// Technical
"kernel-patterns.md", "polishing-approaches.md", "rust-conversion.md", "github-bugs.md",
];
#[derive(Debug, Clone, Deserialize)]
pub struct ContextGroup {
pub label: String,
#[serde(default)]
pub keys: Vec<String>,
#[serde(default)]
pub source: Option<String>, // "file" or "journal"
}
/// Read a file if it exists and is non-empty.
fn read_nonempty(path: &Path) -> Option<String> {
@ -77,24 +73,51 @@ fn find_context_files(cwd: &Path, prompt_file: &str) -> Vec<PathBuf> {
found
}
/// Load memory files from project and global dirs, plus people/ glob.
fn load_memory_files(cwd: &Path, memory_project: Option<&Path>) -> Vec<(String, String)> {
/// Load memory files from config's context_groups.
/// For file sources, checks:
/// 1. ~/.config/poc-agent/ (primary config dir)
/// 2. Project dir (if set)
/// 3. Global (~/.claude/memory/)
/// For journal source, loads recent journal entries.
fn load_memory_files(cwd: &Path, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> Vec<(String, String)> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return Vec::new(),
};
// Primary config directory
let config_dir = home.join(".config/poc-agent");
let global = home.join(".claude/memory");
let project = memory_project
.map(PathBuf::from)
.or_else(|| find_project_memory_dir(cwd, &home));
let mut memories: Vec<(String, String)> = MEMORY_FILES.iter()
.filter_map(|name| {
load_memory_file(name, project.as_deref(), &global)
.map(|content| (name.to_string(), content))
})
.collect();
let mut memories: Vec<(String, String)> = Vec::new();
// Load from context_groups
for group in context_groups {
match group.source.as_deref() {
Some("journal") => {
// Journal loading handled separately
continue;
}
Some("file") | None => {
// File source - load each key as a file
for key in &group.keys {
let filename = format!("{}.md", key);
// Try config dir first, then project, then global
if let Some(content) = read_nonempty(&config_dir.join(&filename)) {
memories.push((key.clone(), content));
} else if let Some(content) = load_memory_file(&filename, project.as_deref(), &global) {
memories.push((key.clone(), content));
}
}
}
Some(other) => {
eprintln!("Unknown context group source: {}", other);
}
}
}
// People dir — glob all .md files
for dir in [project.as_deref(), Some(global.as_path())].into_iter().flatten() {
@ -114,16 +137,6 @@ fn load_memory_files(cwd: &Path, memory_project: Option<&Path>) -> Vec<(String,
}
}
// Global scratch (if different from project scratch)
let global_scratch = global.join("scratch.md");
if project.as_deref().map_or(true, |p| p.join("scratch.md") != global_scratch) {
if let Some(content) = read_nonempty(&global_scratch) {
if !memories.iter().any(|(n, _)| n == "scratch.md") {
memories.push(("global/scratch.md".to_string(), content));
}
}
}
memories
}
@ -152,7 +165,7 @@ fn find_project_memory_dir(cwd: &Path, home: &Path) -> Option<PathBuf> {
/// Discover instruction and memory files that would be loaded.
/// Returns (instruction_files, memory_files) as (display_path, chars) pairs.
pub fn context_file_info(prompt_file: &str, memory_project: Option<&Path>) -> (Vec<(String, usize)>, Vec<(String, usize)>) {
pub fn context_file_info(prompt_file: &str, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> (Vec<(String, usize)>, Vec<(String, usize)>) {
let cwd = std::env::current_dir().unwrap_or_default();
let context_files = find_context_files(&cwd, prompt_file);
@ -163,7 +176,7 @@ pub fn context_file_info(prompt_file: &str, memory_project: Option<&Path>) -> (V
})
.collect();
let memories = load_memory_files(&cwd, memory_project);
let memories = load_memory_files(&cwd, memory_project, context_groups);
let memory_files: Vec<_> = memories.into_iter()
.map(|(name, content)| (name, content.len()))
.collect();
@ -194,7 +207,7 @@ Concise is good. Be direct. Trust yourself."
}
/// Context message: instruction files + memory files + manifest.
pub fn assemble_context_message(cwd: &Path, prompt_file: &str, memory_project: Option<&Path>) -> Result<(Vec<(String, String)>, usize, usize)> {
pub fn assemble_context_message(cwd: &Path, prompt_file: &str, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> Result<(Vec<(String, String)>, usize, usize)> {
let mut parts: Vec<(String, String)> = vec![
("Preamble".to_string(),
"Everything below is already loaded — your identity, instructions, \
@ -215,7 +228,7 @@ pub fn assemble_context_message(cwd: &Path, prompt_file: &str, memory_project: O
}
}
let memories = load_memory_files(cwd, memory_project);
let memories = load_memory_files(cwd, memory_project, context_groups);
let memory_count = memories.len();
for (name, content) in memories {
parts.push((name, content));

View file

@ -843,11 +843,34 @@ impl Session {
self.send_context_info();
}
/// Load context_groups from the shared config file.
fn load_context_groups(&self) -> Vec<identity::ContextGroup> {
let config_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config/poc-agent/config.json5");
if let Ok(content) = std::fs::read_to_string(&config_path) {
let config: Result<serde_json::Value, _> = json5::from_str(&content);
if let Ok(config) = config {
if let Some(memory) = config.get("memory") {
if let Some(groups) = memory.get("context_groups") {
if let Ok(context_groups) = serde_json::from_value(groups.clone()) {
return context_groups;
}
}
}
}
}
Vec::new()
}
/// Send context loading info to the TUI debug screen.
fn send_context_info(&self) {
let context_groups = self.load_context_groups();
let (instruction_files, memory_files) = identity::context_file_info(
&self.config.prompt_file,
self.config.app.memory_project.as_deref(),
&context_groups,
);
let _ = self.ui_tx.send(UiMessage::ContextInfoUpdate(ContextInfo {
model: self.config.model.clone(),