use async_trait::async_trait;
use crate::agentic::compress::is_compaction_text;
use crate::metrics::TurnMetrics;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemMessage {
pub role: Role,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
impl Role {
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool => "tool",
}
}
}
impl MemMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: Role::System,
content: content.into(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: content.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct SessionContext {
pub workspace: String,
pub session_id: String,
}
#[async_trait]
pub trait MemoryProvider: Send + Sync {
fn name(&self) -> &str;
async fn initialize(&mut self, _ctx: &SessionContext) -> anyhow::Result<()> {
Ok(())
}
fn system_prompt_block(&self) -> Option<String> {
None
}
async fn prefetch(&self, _query: &str) -> anyhow::Result<String> {
Ok(String::new())
}
fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage>;
async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics);
async fn sync_turn_with_active_task(
&mut self,
user: &str,
assistant: &str,
metrics: &TurnMetrics,
_active_task: &str,
) {
self.sync_turn(user, assistant, metrics).await;
}
fn set_context_tokens(&mut self, _tokens: u32) {}
fn wants_summarizer(&self) -> bool {
false
}
fn set_summarizer(&mut self, _summarizer: crate::agentic::Summarizer) {}
fn reset(&mut self) {}
fn restore_turns(&mut self, _turns: &[crate::ConversationTurn]) {}
fn take_compaction_record(&mut self) -> Option<String> {
None
}
async fn on_pre_compress(&self, _messages: &[MemMessage]) -> String {
String::new()
}
async fn on_session_end(&mut self, _messages: &[MemMessage]) {}
fn usage(&self) -> Option<(String, usize, usize)> {
None
}
fn add_note(&mut self, _fact: &str) -> anyhow::Result<()> {
Err(NotesUnsupported.into())
}
fn replace_note(&mut self, _old_substring: &str, _new_text: &str) -> anyhow::Result<()> {
Err(NotesUnsupported.into())
}
fn remove_note(&mut self, _substring: &str) -> anyhow::Result<()> {
Err(NotesUnsupported.into())
}
}
#[derive(Debug, thiserror::Error)]
#[error("this memory provider does not support persistent notes")]
pub struct NotesUnsupported;
pub struct MemoryManager {
providers: Vec<Box<dyn MemoryProvider>>,
}
impl MemoryManager {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn add_provider(&mut self, p: impl MemoryProvider + 'static) {
self.providers.push(Box::new(p));
}
pub fn set_context_tokens(&mut self, tokens: u32) {
for provider in &mut self.providers {
provider.set_context_tokens(tokens);
}
}
pub fn set_summarizer(&mut self, build: impl FnOnce() -> crate::agentic::Summarizer) {
if let Some(provider) = self.providers.iter_mut().find(|p| p.wants_summarizer()) {
provider.set_summarizer(build());
}
}
pub async fn initialize_all(&mut self, ctx: &SessionContext) {
for p in &mut self.providers {
if let Err(e) = p.initialize(ctx).await {
tracing::warn!(provider = p.name(), error = %e, "memory provider init failed");
}
}
}
pub fn build_system_prompt_additions(&self) -> String {
self.providers
.iter()
.filter_map(|p| p.system_prompt_block())
.collect::<Vec<_>>()
.join("\n\n")
}
pub async fn prefetch_all(&self, query: &str) -> String {
let mut parts = Vec::new();
for p in &self.providers {
match p.prefetch(query).await {
Ok(s) if !s.is_empty() => parts.push(s),
Err(e) => {
tracing::warn!(provider = p.name(), error = %e, "prefetch failed");
}
_ => {}
}
}
parts.join("\n\n")
}
pub fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
for p in &self.providers {
let msgs = p.build_messages(system_prompt, new_task);
if !msgs.is_empty() {
return crate::agentic::observed_report::replay_messages(&msgs);
}
}
vec![
MemMessage::system(system_prompt),
MemMessage::user(new_task),
]
}
pub async fn sync_all_with_active_task(
&mut self,
user: &str,
assistant: &str,
metrics: &TurnMetrics,
active_task: &str,
) {
let prose = crate::agentic::observed_report::assistant_prose(assistant);
for provider in &mut self.providers {
provider
.sync_turn_with_active_task(user, &prose, metrics, active_task)
.await;
}
}
pub fn reset_all(&mut self) {
for p in &mut self.providers {
p.reset();
}
}
pub fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
let turns: Vec<_> = turns
.iter()
.cloned()
.map(|mut turn| {
turn.assistant = crate::agentic::observed_report::assistant_prose(&turn.assistant);
turn
})
.collect();
for p in &mut self.providers {
p.restore_turns(&turns);
}
}
pub fn take_compaction_record(&mut self) -> Option<String> {
self.providers
.iter_mut()
.find_map(|p| p.take_compaction_record())
}
pub async fn on_pre_compress(&self, messages: &[MemMessage]) -> String {
let mut parts = Vec::new();
for p in &self.providers {
let s = p.on_pre_compress(messages).await;
if !s.is_empty() {
parts.push(s);
}
}
parts.join("\n\n")
}
pub async fn on_session_end(&mut self, messages: &[MemMessage]) {
for p in &mut self.providers {
p.on_session_end(messages).await;
}
}
pub fn usage(&self) -> Vec<(String, usize, usize)> {
self.providers.iter().filter_map(|p| p.usage()).collect()
}
pub fn add_note(&mut self, fact: &str) -> anyhow::Result<()> {
self.route_note_op(|p| p.add_note(fact))
}
pub fn replace_note(&mut self, old_substring: &str, new_text: &str) -> anyhow::Result<()> {
self.route_note_op(|p| p.replace_note(old_substring, new_text))
}
pub fn remove_note(&mut self, substring: &str) -> anyhow::Result<()> {
self.route_note_op(|p| p.remove_note(substring))
}
fn route_note_op(
&mut self,
mut op: impl FnMut(&mut dyn MemoryProvider) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let mut first_err: Option<anyhow::Error> = None;
for p in &mut self.providers {
match op(p.as_mut()) {
Ok(()) => return Ok(()),
Err(e) if e.is::<NotesUnsupported>() => continue,
Err(e) => {
first_err.get_or_insert(e);
}
}
}
match first_err {
Some(e) => Err(e),
None => anyhow::bail!(
"no note-capable memory provider registered — add [memory] provider = \"note_store\" to newt.toml"
),
}
}
}
impl Default for MemoryManager {
fn default() -> Self {
Self::new()
}
}
pub struct RollingWindow {
max_turns: usize,
history: Vec<(String, String)>,
}
impl RollingWindow {
pub fn new(max_turns: usize) -> Self {
Self {
max_turns: max_turns.max(1),
history: Vec::new(),
}
}
pub fn from_config(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Self {
let window = newt_core_memory_window(report);
Self::new(window)
}
pub fn turn_count(&self) -> usize {
self.history.len()
}
}
fn newt_core_memory_window(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> usize {
crate::Config::resolve_unpublished(report)
.ok()
.and_then(|c| c.memory)
.map(|m| m.window)
.unwrap_or(20)
}
#[async_trait]
impl MemoryProvider for RollingWindow {
fn name(&self) -> &str {
"rolling_window"
}
fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
let mut msgs = vec![MemMessage::system(system_prompt)];
let start = self.history.len().saturating_sub(self.max_turns);
for (user, asst) in &self.history[start..] {
msgs.push(MemMessage::user(user));
if !asst.is_empty() {
msgs.push(MemMessage::assistant(asst));
}
}
msgs.push(MemMessage::user(new_task));
msgs
}
async fn sync_turn(&mut self, user: &str, assistant: &str, _metrics: &TurnMetrics) {
self.history.push((user.to_string(), assistant.to_string()));
if self.history.len() > self.max_turns.saturating_mul(2) {
let drain_to = self.history.len() - self.max_turns;
self.history.drain(..drain_to);
}
}
fn reset(&mut self) {
self.history.clear();
}
fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
self.history = turns
.iter()
.map(|t| (t.user.clone(), t.assistant.clone()))
.collect();
}
fn usage(&self) -> Option<(String, usize, usize)> {
Some((
"turns".into(),
self.history.len().min(self.max_turns),
self.max_turns,
))
}
}
pub const DEFAULT_CONTEXT_TOKENS: u32 = 8_192;
#[derive(Debug, Clone)]
struct TurnRecord {
user: String,
assistant: String,
est_tokens: u32,
}
fn turn_content_estimate(user: &str, assistant: &str, est: crate::tokens::TokenEstimation) -> u32 {
(est.tokens_for_chars(user.len()) + est.tokens_for_chars(assistant.len())) as u32
}
fn restored_token_anchor(
turns: &[crate::ConversationTurn],
est: crate::tokens::TokenEstimation,
) -> (Option<u32>, i64) {
let Some(pos) = turns.iter().rposition(|t| t.tokens_in.is_some()) else {
return (None, 0);
};
let mut delta = est.tokens_for_chars(turns[pos].assistant.len()) as i64;
for t in &turns[pos + 1..] {
delta += i64::from(turn_content_estimate(&t.user, &t.assistant, est));
}
(turns[pos].tokens_in, delta)
}
pub struct TokenBudget {
max_tokens: u32,
threshold_pct: f32,
history: Vec<TurnRecord>,
pruned_count: usize,
last_prompt_tokens: Option<u32>,
delta_since_prompt: i64,
est: crate::tokens::TokenEstimation,
}
impl TokenBudget {
pub fn new(max_tokens: u32, threshold_pct: f32) -> Self {
Self {
max_tokens: max_tokens.max(512),
threshold_pct: threshold_pct.clamp(0.1, 0.99),
history: Vec::new(),
pruned_count: 0,
last_prompt_tokens: None,
delta_since_prompt: 0,
est: crate::tokens::TokenEstimation::default(),
}
}
#[must_use]
pub fn with_estimation(mut self, est: crate::tokens::TokenEstimation) -> Self {
self.est = est;
self
}
pub fn with_budget(mut self, tokens: u32) -> Self {
self.max_tokens = tokens.max(512);
self
}
fn budget_tokens(&self) -> u32 {
(self.max_tokens as f32 * self.threshold_pct) as u32
}
fn used_tokens(&self) -> u32 {
match self.last_prompt_tokens {
Some(p) => (i64::from(p) + self.delta_since_prompt).max(0) as u32,
None => self.history.iter().map(|r| r.est_tokens).sum(),
}
}
fn prune_to_budget(&mut self) -> usize {
let budget = self.budget_tokens();
let mut dropped = 0;
while self.used_tokens() > budget && !self.history.is_empty() {
let removed = self.history.remove(0);
self.delta_since_prompt -= i64::from(removed.est_tokens);
dropped += 1;
}
dropped
}
}
#[async_trait]
impl MemoryProvider for TokenBudget {
fn name(&self) -> &str {
"token_budget"
}
fn set_context_tokens(&mut self, tokens: u32) {
self.max_tokens = tokens.max(512);
}
fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
let mut msgs = vec![MemMessage::system(system_prompt)];
for r in &self.history {
msgs.push(MemMessage::user(&r.user));
if !r.assistant.is_empty() {
msgs.push(MemMessage::assistant(&r.assistant));
}
}
msgs.push(MemMessage::user(new_task));
msgs
}
async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics) {
let est = turn_content_estimate(user, assistant, self.est);
self.history.push(TurnRecord {
user: user.to_string(),
assistant: assistant.to_string(),
est_tokens: est,
});
match metrics.usage {
Some(u) => {
self.last_prompt_tokens = Some(u.input_tokens);
self.delta_since_prompt = self.est.tokens_for_chars(assistant.len()) as i64;
}
None => self.delta_since_prompt += i64::from(est),
}
let dropped = self.prune_to_budget();
self.pruned_count += dropped;
if dropped > 0 {
tracing::info!(
dropped,
budget = self.budget_tokens(),
used = self.used_tokens(),
"TokenBudget pruned old turns"
);
}
}
fn reset(&mut self) {
self.history.clear();
self.pruned_count = 0;
self.last_prompt_tokens = None;
self.delta_since_prompt = 0;
}
fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
self.history = turns
.iter()
.map(|t| TurnRecord {
user: t.user.clone(),
assistant: t.assistant.clone(),
est_tokens: turn_content_estimate(&t.user, &t.assistant, self.est),
})
.collect();
let (anchor, delta) = restored_token_anchor(turns, self.est);
self.last_prompt_tokens = anchor;
self.delta_since_prompt = delta;
self.pruned_count = self.prune_to_budget();
}
fn usage(&self) -> Option<(String, usize, usize)> {
Some((
"tokens".into(),
self.used_tokens() as usize,
self.budget_tokens() as usize,
))
}
}
pub use crate::notes::NoteStore;
pub const MEMORY_INDEX_BUDGET: usize = 12;
pub struct MemoryIndex {
rows: Vec<(usize, String)>,
truncated: bool,
notes_path: std::path::PathBuf,
}
impl MemoryIndex {
pub fn new(notes_path: impl Into<std::path::PathBuf>) -> Self {
Self {
rows: Vec::new(),
truncated: false,
notes_path: notes_path.into(),
}
}
pub fn default_path() -> Self {
let path = crate::Config::user_config_path()
.map(|p| p.with_file_name("NOTES.md"))
.unwrap_or_else(|| std::path::PathBuf::from("NOTES.md"));
Self::new(path)
}
pub fn rows(&self) -> &[(usize, String)] {
&self.rows
}
fn capture_from(&mut self, notes: &NoteStore) {
let all = notes.index_entries();
self.truncated = all.len() > MEMORY_INDEX_BUDGET;
self.rows = all
.into_iter()
.take(MEMORY_INDEX_BUDGET)
.map(|(id, title)| (id, title.to_string()))
.collect();
}
}
#[async_trait]
impl MemoryProvider for MemoryIndex {
fn name(&self) -> &str {
"memory_index"
}
async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
let mut notes = NoteStore::new(self.notes_path.clone(), NoteStore::DEFAULT_CHAR_LIMIT);
notes.initialize(ctx).await?;
self.capture_from(¬es);
Ok(())
}
fn system_prompt_block(&self) -> Option<String> {
if self.rows.is_empty() {
return None;
}
let mut block =
String::from("## Memory index (call `memory_fetch` with an id to read a body)\n");
for (id, title) in &self.rows {
let title = if title.is_empty() {
"(untitled)"
} else {
title
};
block.push_str(&format!("- note:{id} {title}\n"));
}
if self.truncated {
block
.push_str("(more notes exist than are listed — use `recall` to search the rest)\n");
}
Some(block.trim_end().to_string())
}
fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
Vec::new()
}
async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
}
#[derive(Clone)]
struct SumTurn {
user: String,
assistant: String,
est_tokens: u32,
}
impl SumTurn {
fn new(
user: impl Into<String>,
assistant: impl Into<String>,
est: crate::tokens::TokenEstimation,
) -> Self {
let user = user.into();
let assistant = assistant.into();
let est_tokens = turn_content_estimate(&user, &assistant, est);
Self {
user,
assistant,
est_tokens,
}
}
fn to_wire(&self) -> Vec<serde_json::Value> {
let mut v = Vec::with_capacity(2);
if !self.user.is_empty() {
v.push(serde_json::json!({"role": "user", "content": self.user}));
}
if !self.assistant.is_empty() {
v.push(serde_json::json!({"role": "assistant", "content": self.assistant}));
}
v
}
}
fn wire_to_history(
messages: &[serde_json::Value],
est: crate::tokens::TokenEstimation,
) -> Vec<SumTurn> {
let mut out: Vec<SumTurn> = Vec::new();
for m in messages {
let content = m["content"].as_str().unwrap_or_default();
match m["role"].as_str() {
Some("user") => out.push(SumTurn::new(content, "", est)),
Some("assistant") => {
match out.last_mut() {
Some(last)
if last.assistant.is_empty()
&& !last.user.is_empty()
&& !is_compaction_text(&last.user) =>
{
last.assistant = content.to_string();
last.est_tokens = turn_content_estimate(&last.user, &last.assistant, est);
}
_ => out.push(SumTurn::new("", content, est)),
}
}
_ => {}
}
}
out
}
pub struct Summarizing {
max_tokens: u32,
threshold_pct: f32,
history: Vec<SumTurn>,
prev_summary: String,
compress_count: usize,
state: crate::agentic::CompressState,
summarizer: Option<crate::agentic::Summarizer>,
last_prompt_tokens: Option<u32>,
delta_since_prompt: i64,
pending_record: Option<String>,
rewrites_history: bool,
est: crate::tokens::TokenEstimation,
summary_input_cap_floor_chars: usize,
}
impl Summarizing {
pub fn new(max_tokens: u32) -> Self {
Self {
max_tokens: max_tokens.max(1),
threshold_pct: 0.80,
history: Vec::new(),
prev_summary: String::new(),
compress_count: 0,
state: crate::agentic::CompressState::new(),
summarizer: None,
last_prompt_tokens: None,
delta_since_prompt: 0,
pending_record: None,
rewrites_history: true,
est: crate::tokens::TokenEstimation::default(),
summary_input_cap_floor_chars: 8_192,
}
}
#[must_use]
pub fn with_rewrites_history(mut self, rewrites_history: bool) -> Self {
self.rewrites_history = rewrites_history;
self
}
#[must_use]
pub fn with_estimation(
mut self,
est: crate::tokens::TokenEstimation,
summary_input_cap_floor_chars: usize,
) -> Self {
self.est = est;
self.summary_input_cap_floor_chars = summary_input_cap_floor_chars;
self
}
pub fn with_budget(mut self, tokens: u32) -> Self {
self.max_tokens = tokens.max(1);
self
}
pub fn with_summarizer(
mut self,
f: impl Fn(String) -> crate::agentic::SummarizeFuture + Send + Sync + 'static,
) -> Self {
self.summarizer = Some(Box::new(f));
self
}
fn budget(&self) -> u32 {
(self.max_tokens as f32 * self.threshold_pct) as u32
}
fn used_tokens(&self) -> u32 {
match self.last_prompt_tokens {
Some(p) => (i64::from(p) + self.delta_since_prompt).max(0) as u32,
None => self.history.iter().map(|t| t.est_tokens).sum(),
}
}
async fn compress_via_pipeline(&mut self, active_task: &str) {
use crate::agentic::compress::{
compress, protect_active_prompt_for_compression, strip_active_prompt_pair,
CompressAction, CompressRequest,
};
if self.history.is_empty() {
return;
}
if !self.rewrites_history {
return;
}
let messages: Vec<serde_json::Value> =
self.history.iter().flat_map(SumTurn::to_wire).collect();
let protected = protect_active_prompt_for_compression(&messages, active_task);
let outcome = compress(
CompressRequest {
rewrites_history: self.rewrites_history,
messages: &protected,
budget: self.budget() as usize,
max_messages: None,
replay_protected_tail_len: 0,
task: active_task,
hard_budget: true,
authoritative: true,
focus: None,
est: self.est,
summary_input_cap_floor_chars: self.summary_input_cap_floor_chars,
compaction_store: None,
compaction_stage: None,
},
self.summarizer.as_deref(),
&mut self.state,
)
.await;
if !outcome.fired {
return;
}
let messages = strip_active_prompt_pair(outcome.messages, active_task);
self.history = wire_to_history(&messages, self.est);
self.delta_since_prompt += outcome.tokens_after as i64 - outcome.tokens_before as i64;
if matches!(
outcome.action,
CompressAction::Summarized | CompressAction::StaticFallback
) {
if let Some(c) = self.history.iter().find(|t| is_compaction_text(&t.user)) {
self.prev_summary = c.user.clone();
self.pending_record = Some(c.user.clone());
self.compress_count += 1;
}
}
tracing::info!(
compress_count = self.compress_count,
action = outcome.action.describe(),
tokens_before = outcome.tokens_before,
tokens_after = outcome.tokens_after,
"Summarizing: compressed context via shared pipeline"
);
}
async fn record_turn_with_active_task(
&mut self,
user: &str,
assistant: &str,
metrics: &TurnMetrics,
active_task: &str,
) {
let est = turn_content_estimate(user, assistant, self.est);
self.history.push(SumTurn::new(user, assistant, self.est));
match metrics.usage {
Some(u) => {
self.last_prompt_tokens = Some(u.input_tokens);
self.delta_since_prompt = self.est.tokens_for_chars(assistant.len()) as i64;
}
None => self.delta_since_prompt += i64::from(est),
}
if self.used_tokens() > self.budget() && !self.state.is_disabled() {
self.compress_via_pipeline(active_task).await;
}
}
}
#[async_trait]
impl MemoryProvider for Summarizing {
fn name(&self) -> &str {
"summarizing"
}
fn set_context_tokens(&mut self, tokens: u32) {
self.max_tokens = tokens.max(1);
}
fn wants_summarizer(&self) -> bool {
true
}
fn set_summarizer(&mut self, summarizer: crate::agentic::Summarizer) {
self.summarizer = Some(summarizer);
}
fn build_messages(&self, system_prompt: &str, new_task: &str) -> Vec<MemMessage> {
let mut msgs = vec![MemMessage::system(system_prompt)];
for t in &self.history {
if !t.user.is_empty() {
msgs.push(MemMessage::user(&t.user));
}
if !t.assistant.is_empty() {
msgs.push(MemMessage::assistant(&t.assistant));
}
}
msgs.push(MemMessage::user(new_task));
msgs
}
async fn sync_turn(&mut self, user: &str, assistant: &str, metrics: &TurnMetrics) {
self.record_turn_with_active_task(user, assistant, metrics, user)
.await;
}
async fn sync_turn_with_active_task(
&mut self,
user: &str,
assistant: &str,
metrics: &TurnMetrics,
active_task: &str,
) {
self.record_turn_with_active_task(user, assistant, metrics, active_task)
.await;
}
fn reset(&mut self) {
self.history.clear();
self.prev_summary.clear();
self.compress_count = 0;
self.state.reset();
self.last_prompt_tokens = None;
self.delta_since_prompt = 0;
self.pending_record = None;
}
fn restore_turns(&mut self, turns: &[crate::ConversationTurn]) {
let cut = turns
.iter()
.rposition(|t| is_compaction_text(&t.user) && t.assistant.is_empty());
let live = match cut {
Some(k) => &turns[k + 1..],
None => turns,
};
self.history.clear();
self.prev_summary.clear();
if let Some(k) = cut {
self.history
.push(SumTurn::new(turns[k].user.clone(), "", self.est));
self.prev_summary = turns[k].user.clone();
}
self.history.extend(
live.iter()
.map(|t| SumTurn::new(&*t.user, &*t.assistant, self.est)),
);
self.compress_count = 0;
self.pending_record = None;
self.state.reset();
let (anchor, delta) = restored_token_anchor(live, self.est);
self.last_prompt_tokens = anchor;
self.delta_since_prompt = delta;
}
fn take_compaction_record(&mut self) -> Option<String> {
self.pending_record.take()
}
async fn on_pre_compress(&self, _messages: &[MemMessage]) -> String {
if self.prev_summary.is_empty() {
String::new()
} else {
format!("Previous compression summary:\n{}", self.prev_summary)
}
}
fn usage(&self) -> Option<(String, usize, usize)> {
Some((
"tokens".into(),
self.used_tokens() as usize,
self.budget() as usize,
))
}
}
pub const DEFAULT_SOUL: &str = "\
You are newt, a free, friendly, local agentic coder. \
Be concise and direct. \
You have tools: run_command, read_file, write_file, edit_file, list_dir, find, use_skill, web_fetch, render_report. \
Follow the per-turn disposition card supplied by the harness: only an `act` turn may mutate the workspace or receive execution pressure; `ask` asks its bounded clarification and stops, `explain` answers without mutation, `research` gathers bounded read-only evidence, and `plan` may update only the harness-owned plan ledger.\n\
\n\
## How to work\n\
\n\
**One change at a time.** Read only the files you need for the immediate next step. \
Make the change. Commit it. Then move to the next step. \
Never accumulate multiple uncommitted edits — a committed partial result survives a crash; \
an uncommitted complete result does not.\n\
\n\
**Read minimum, act deliberately.** Resist reading the entire codebase before acting. \
Read the specific file or function you are about to change, make the change, \
commit, then read the next thing. The session has a finite context window — \
every token spent reading is a token not spent writing.\n\
\n\
**Prefer edit_file over write_file.** For any existing file, use edit_file \
to replace a specific string — you only generate the change, not the whole file. \
Only use write_file when creating a new file or when you have generated the \
complete contents in full. write_file will refuse if the new content is \
significantly shorter than the original.\n\
\n\
**Stop when blocked.** If the same tool call fails twice in a row with the \
same error, stop immediately and tell the user what blocked you and why. \
Do not try alternative installation methods, do not loop, do not pivot to \
answering a different question. Two identical failures are a signal to report, \
not to retry. One sentence explaining the block is worth more than ten more \
failed tool calls.\n\
\n\
**Seek ground truth.** After every action, verify what actually happened — \
not what you intended. Do not proceed on assumptions about your own actions; \
confirm them. After writing a file, the tool reports the new line count — \
check it matches what you expected. After editing code, the tool reports \
whether it compiled — if it did not, fix the error before committing. \
Before committing, confirm you are on the right branch. \
A belief that something worked is worthless; a tool result that confirms it \
is ground truth.\n\
\n\
**Sign every commit.** Credit yourself on every commit you author with a \
`Co-authored-by:` trailer naming the model and harness, e.g. \
`Co-authored-by: <model> (v<version> <build>) <309460085+newt-agent@users.noreply.github.com>`. \
The account name is not repeated in the parenthetical — the address already \
carries it, so the qualifier holds only the harness version and the build it \
was compiled from. \
The harness identity is the newt-agent GitHub App — link it: \
<https://github.com/apps/newt-agent>. This is the house default, not an \
occasional courtesy — include it on every commit, including commits made \
through the shell `git` path (which bypasses the harness's automatic \
trailer). When you amend or reword, keep the trailer.\n\
\n\
**On an `act` turn, never describe a code change — make it.** Do not paste code into the chat. \
If the task requires a code change and the disposition is `act`, call edit_file or write_file immediately. \
A markdown code block in the conversation is invisible to the filesystem — \
it does not modify any file. Write the code once, into the file, via the tool. \
Showing code in text is NOT completing an `act` task; calling the tool IS. \
For `ask`, `explain`, `research`, or `plan`, respect the disposition instead of forcing a workspace write.\n\
\n\
**Present findings — don't just report a blocker.** When the task is to \
gather or summarize (a status roll-up, a triage sweep, a morning briefing), \
your deliverable is a rendered report, not a one-line status. The moment you \
have what was asked, call render_report to present it. A failed data source \
is one degraded section, not a dead end — render the rest and mark the failed \
part `degraded` (or `error`), so the human sees the partial result plus \
exactly what is missing. Ending such a task with only \"X is broken\" leaves \
the work you already did invisible.\n\
\n\
**Exploration budget for `act`.** Treat read-only rounds (list_dir, read_file) as expensive. \
Spend at most three consecutive rounds on exploration before making a write. \
Once you have read the file you need, stop reading and call edit_file or write_file. \
Continued reading without writing means you are lost — make your best attempt \
at the change based on what you have already read, then verify. In `research`, \
the bounded read-only evidence collection is the work; report it rather than \
forcing a mutation.\n\
\n\
**Working code first, then the three Cs.** Make it work, then make it right. \
Shipping a working result that hardcodes a list or a constant to get there is \
fine; functional results come first. Then RETURN to the three Cs: lift hardcoded \
knowledge (keyword lists, magic values, language or domain rules) into pure DATA \
that is Composed, Configured, and Convention-driven, so a new case is config, \
not code. Don't let this block shipping; do circle back and de-hardcode once it \
works.";
pub const COACH_SOUL: &str = "\
You are newt in COACH mode: an advisor, not a doer. Be concise and direct. \
You help the human reason about their code and infrastructure — you explain, \
present options, and recommend the next step. You do NOT make the change \
yourself.\n\
\n\
## How to coach\n\
\n\
**Advise; do not act.** Present the command, the edit, or the plan as text, \
with the reasoning behind it, and let the human decide and execute. A coaching \
turn's product is UNDERSTANDING and a recommendation — never a mutated file or \
an executed command. Do not call write_file, edit_file, or a state-changing \
run_command; if the change needs making, say so and let the human make it (or \
switch out of coach mode).\n\
\n\
**Ground your advice in the real code.** Use the read-only tools — read_file, \
list_dir, find, web_fetch — to see what is actually there before you advise. \
Advice built on an assumption about the code is worse than none; confirm \
against ground truth, then recommend.\n\
\n\
**Show the command, don't run it.** When the answer is 'run X', write X in the \
reply with a one-line explanation of what it does and why — as something the \
human runs, not something you run. A command in the conversation is guidance; \
executing it would be doing the human's job for them.\n\
\n\
**Teach the reasoning, not just the answer.** Explain WHY, name the trade-offs, \
and point at the one next step. The human is learning from you, not delegating \
to you — leave them able to make the next call themselves.\n\
\n\
**Stop when unsure.** If you cannot ground a recommendation, say what you would \
need to check rather than guessing. One honest 'here is what I'd verify first' \
beats a confident wrong answer.";
pub struct SoulProvider {
soul: String,
pub source: SoulSource,
override_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SoulSource {
Default,
Global,
Workspace,
Explicit(std::path::PathBuf),
}
impl std::fmt::Display for SoulSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Default => write!(f, "built-in default"),
Self::Global => write!(f, "~/.newt/soul.md"),
Self::Workspace => write!(f, ".newt/soul.md"),
Self::Explicit(p) => write!(f, "{}", p.display()),
}
}
}
impl SoulProvider {
pub fn new(override_path: Option<std::path::PathBuf>) -> Self {
Self {
soul: DEFAULT_SOUL.to_string(),
source: SoulSource::Default,
override_path,
}
}
pub fn from_config(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Self {
let override_path = crate::Config::resolve_unpublished(report)
.ok()
.and_then(|c| c.memory)
.and_then(|m| m.soul_file)
.map(std::path::PathBuf::from);
Self::new(override_path)
}
fn try_load(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub fn load(&mut self, workspace: &str) {
if let Some(ref p) = self.override_path {
if let Some(text) = Self::try_load(p) {
self.soul = text;
self.source = SoulSource::Explicit(p.clone());
return;
}
}
let ws_soul = std::path::Path::new(workspace)
.join(".newt")
.join("soul.md");
if let Some(text) = Self::try_load(&ws_soul) {
self.soul = text;
self.source = SoulSource::Workspace;
return;
}
if let Some(global) = crate::Config::user_config_path().map(|p| p.with_file_name("soul.md"))
{
if let Some(text) = Self::try_load(&global) {
self.soul = text;
self.source = SoulSource::Global;
}
}
}
}
#[async_trait]
impl MemoryProvider for SoulProvider {
fn name(&self) -> &str {
"soul"
}
async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
self.load(&ctx.workspace);
tracing::info!(source = %self.source, "soul loaded");
Ok(())
}
fn system_prompt_block(&self) -> Option<String> {
Some(self.soul.clone())
}
fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
Vec::new()
}
async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
}
#[cfg(test)]
#[path = "memory_tests/mod.rs"]
mod tests;
pub fn persist_compaction_summary(
store: &crate::ConversationStore,
conversation_id: &str,
summary: &str,
) -> anyhow::Result<()> {
let refs = store.turn_refs(conversation_id)?;
let cut = refs
.iter()
.rposition(|(user, assistant, _)| is_compaction_text(user) && assistant.is_empty());
let sources: Vec<String> = match cut {
Some(k) => refs[k..].iter().map(|(_, _, id)| id.clone()).collect(),
None => refs.into_iter().map(|(_, _, id)| id).collect(),
};
store.append_turn_full(conversation_id, summary, "", &[], &[], &sources, None, None)
}