refactor(agent): make memory summaries cumulative (#5610)

* refactor(agent): make memory summaries cumulative

Treat the latest session summary as a replacement checkpoint, preserve it through bounded raw fallbacks, and reserve history.jsonl for Dream ingestion.

* fix(agent): preserve cumulative checkpoint context

* fix(agent): preserve memory archive prompt cache

* refactor(agent): state archive prompt positively

* refactor(agent): remove checkpoint version migration

* refactor(agent): summarize full archive context

* test(agent): align cumulative archive prompt assertion

* refactor(agent): clarify memory checkpoint contract
This commit is contained in:
chengyongru
2026-08-31 13:37:20 +08:00
committed by GitHub
parent 6cd7063682
commit bb34b58f47
12 changed files with 476 additions and 514 deletions
+1 -64
View File
@@ -30,11 +30,7 @@ from nanobot.security.workspace_access import WorkspaceScopeResolver
from nanobot.session.keys import last_channel_from_metadata
from nanobot.session.manager import Session
from nanobot.session.summary import SessionSummary
from nanobot.utils.helpers import (
detect_image_mime,
load_bundled_template,
truncate_text_to_tokens,
)
from nanobot.utils.helpers import detect_image_mime, load_bundled_template
from nanobot.utils.prompt_templates import render_template
@@ -98,8 +94,6 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
@@ -115,9 +109,6 @@ class ContextBuilder:
session_summary: SessionSummary | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
root = workspace or self.workspace
@@ -155,29 +146,6 @@ class ContextBuilder:
if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
if include_memory_recent_history:
entries = self.memory.read_recent_history_for_prompt(
since_cursor=self.memory.get_last_dream_cursor(),
session_key=session_key,
unified_session=unified_session,
)
if entries:
capped = entries[-self._MAX_RECENT_HISTORY:]
capped = self._without_duplicate_session_summary(
capped,
session_key=session_key,
session_summary=session_summary,
)
if capped:
history_text = "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped
)
history_text = truncate_text_to_tokens(
history_text,
self._MAX_HISTORY_TOKENS,
)
parts.append("# Recent History\n\n" + history_text)
if session_summary:
parts.append(
"[Archived Context Summary]\n\n"
@@ -187,25 +155,6 @@ class ContextBuilder:
return "\n\n---\n\n".join(parts)
@staticmethod
def _without_duplicate_session_summary(
entries: list[dict[str, Any]],
*,
session_key: str | None,
session_summary: SessionSummary | None,
) -> list[dict[str, Any]]:
"""Drop the history entry already represented by the session summary."""
if not session_summary:
return entries
for index in range(len(entries) - 1, -1, -1):
entry = entries[index]
if (
entry.get("session_key") == session_key
and entry.get("content") == session_summary["text"]
):
return [*entries[:index], *entries[index + 1:]]
return entries
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
"""Get the core identity section."""
root = workspace or self.workspace
@@ -295,9 +244,6 @@ class ContextBuilder:
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""Compatibility wrapper for callers that need merged adjacent roles."""
messages = self.build_transcript(
@@ -312,9 +258,6 @@ class ContextBuilder:
channel=channel,
workspace=workspace,
include_memory=include_memory,
include_memory_recent_history=include_memory_recent_history,
session_key=session_key,
unified_session=unified_session,
)
current = messages[-1]
if len(messages) < 2 or messages[-2].get("role") != current.get("role"):
@@ -339,9 +282,6 @@ class ContextBuilder:
channel: str | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""Build a model transcript while preserving the fresh-turn boundary."""
root = workspace or self.workspace
@@ -353,9 +293,6 @@ class ContextBuilder:
session_summary=transcript.session_summary,
workspace=root,
include_memory=include_memory,
include_memory_recent_history=include_memory_recent_history,
session_key=session_key,
unified_session=unified_session,
),
},
*transcript.history,
+7 -4
View File
@@ -444,7 +444,6 @@ class AgentLoop:
workspace_scopes=self.workspace_scopes,
unified_session=unified_session,
),
unified_session=unified_session,
)
self.auto_compact = AutoCompact(
sessions=self.sessions,
@@ -1109,9 +1108,6 @@ class AgentLoop:
channel=request_ctx.channel,
workspace=effective_scope.project_path,
include_memory=session.policy.persist if session is not None else True,
include_memory_recent_history=not ephemeral,
session_key=session.key if session is not None else request_ctx.session_key,
unified_session=self._unified_session,
)
if request_context is None:
request_ctx = dataclasses.replace(
@@ -1883,6 +1879,13 @@ class AgentLoop:
session,
runtime=runtime,
)
# Token consolidation may have committed a replacement checkpoint
# after the compact stage captured its summary for this request.
ctx.session, ctx.pending_summary = self.auto_compact.prepare_session(
session,
ctx.session_key,
)
session = ctx.require_session()
is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent"
_hist_kwargs: dict[str, Any] = {
+142 -146
View File
@@ -35,6 +35,7 @@ from nanobot.utils.helpers import (
estimate_prompt_tokens_chain,
strip_think,
truncate_text,
truncate_text_to_tokens,
)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.workspace_prompts import (
@@ -65,8 +66,6 @@ class MemoryStore:
# durable files are tiny in practice (~5 KB total), but a runaway file must
# not unbounded the prompt.
_DREAM_FILE_EMBED_CAP = 8000
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
_LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
_LEGACY_RAW_MESSAGE_RE = re.compile(
@@ -260,6 +259,29 @@ class MemoryStore:
# -- history.jsonl — append-only, JSONL format ---------------------------
def _normalize_history_entry(
self,
entry: str,
*,
max_chars: int | None = None,
) -> str:
"""Return the exact bounded, model-safe text accepted by the journal."""
limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
raw = entry.rstrip()
content = strip_think(raw)
if len(content) > limit:
if not self._oversize_logged:
self._oversize_logged = True
logger.warning(
"history entry exceeds {} chars ({}); truncating. "
"Usually means a caller forgot its own cap; "
"further occurrences suppressed.",
limit,
len(content),
)
content = truncate_text(content, limit)
return content
def append_history(
self,
entry: str,
@@ -274,27 +296,16 @@ class MemoryStore:
persisted. If the cleaned content is empty but the raw entry wasn't,
the record is persisted with an empty string rather than falling back
to the raw leak — otherwise `strip_think`'s guarantees would be
undone by history replay / consolidation downstream.
undone when Dream consumes the journal entry.
A defensive cap (*max_chars*, default ``_HISTORY_ENTRY_HARD_CAP``) is
applied as a final safety net: individual callers should cap their own
content more tightly; this default only exists to catch unintentional
large writes (e.g. an LLM echoing its input back as a "summary").
"""
limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
raw = entry.rstrip()
if len(raw) > limit:
if not self._oversize_logged:
self._oversize_logged = True
logger.warning(
"history entry exceeds {} chars ({}); truncating. "
"Usually means a caller forgot its own cap; "
"further occurrences suppressed.",
limit, len(raw),
)
raw = truncate_text(raw, limit)
content = strip_think(raw)
content = self._normalize_history_entry(entry, max_chars=max_chars)
# Cursor allocation and the append must be atomic: concurrent writers
# could otherwise read the same current cursor and emit duplicates.
with self._append_lock:
@@ -302,7 +313,7 @@ class MemoryStore:
if raw and not content:
logger.debug(
"history entry {} stripped to empty (likely template leak); "
"persisting empty content to avoid re-polluting context",
"persisting empty content to avoid re-polluting Dream input",
cursor,
)
record = {"cursor": cursor, "timestamp": ts, "content": content}
@@ -392,36 +403,6 @@ class MemoryStore:
"""Return history entries with a valid cursor > *since_cursor*."""
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
@classmethod
def _is_internal_history_session(cls, session_key: str | None) -> bool:
if not session_key:
return False
return (
session_key in cls._INTERNAL_HISTORY_SESSION_KEYS
or session_key.startswith(cls._INTERNAL_HISTORY_SESSION_PREFIXES)
)
def read_recent_history_for_prompt(
self,
since_cursor: int,
*,
session_key: str | None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""Return unprocessed history entries safe to inject into a turn prompt."""
entries = self.read_unprocessed_history(since_cursor=since_cursor)
if session_key is None:
return entries
if not unified_session:
return [e for e in entries if e.get("session_key") == session_key]
return [
entry
for entry in entries
if (entry_session := entry.get("session_key")) == session_key
or not self._is_internal_history_session(entry_session)
]
def compact_history(self) -> None:
"""Drop oldest processed entries without discarding pending Dream input."""
if self.max_history_entries <= 0:
@@ -718,21 +699,28 @@ class MemoryStore:
*,
max_chars: int | None = None,
session_key: str | None = None,
) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
formatted = truncate_text(
self._format_messages(public_history_messages(messages)),
limit,
)
self.append_history(
f"[RAW] {len(messages)} messages\n"
f"{formatted}",
session_key=session_key,
)
) -> str:
"""Persist and return a bounded raw checkpoint when summarization degrades."""
checkpoint = self._build_raw_checkpoint(messages, max_chars=max_chars)
self.append_history(checkpoint, session_key=session_key)
logger.warning(
"Memory consolidation degraded: raw-archived {} messages", len(messages)
)
return checkpoint
def _build_raw_checkpoint(
self,
messages: list[dict[str, Any]],
*,
max_chars: int | None = None,
) -> str:
"""Build the same bounded checkpoint as :meth:`raw_archive` without writing it."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
checkpoint = (
f"[RAW] {len(messages)} messages\n"
f"{self._format_messages(public_history_messages(messages))}"
)
return self._normalize_history_entry(checkpoint, max_chars=limit)
# ------------------------------------------------------------------
# Dream helpers
@@ -787,12 +775,11 @@ class MemoryStore:
# Memory ingestion and legacy context-pressure coordination
# ---------------------------------------------------------------------------
# Individual history.jsonl writers cap their own payloads tightly; the
# _HISTORY_ENTRY_HARD_CAP at append_history() is a belt-and-suspenders default
# that catches any new caller that forgot to set its own cap.
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
# Raw fallbacks use a tighter cap. Completed model summaries may scale with the
# configured generation budget, while append_history() still enforces the
# emergency hard cap against pathological provider output.
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class MemoryArchiver:
@@ -809,13 +796,45 @@ class MemoryArchiver:
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
unified_session: bool = False,
) -> None:
self.store = store
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._resolve_prompt_context = resolve_prompt_context
self.unified_session = unified_session
def _raw_checkpoint(
self,
messages: list[dict[str, Any]],
*,
session_key: str,
previous_summary: str | None,
max_tokens: int,
) -> str:
"""Persist the failed chunk and return a bounded replacement checkpoint."""
raw = self.store.raw_archive(messages, session_key=session_key)
token_limit = max(1, max_tokens)
if not previous_summary:
return truncate_text_to_tokens(raw, token_limit)
combined = (
"[Previous archived context]\n"
f"{previous_summary}\n\n"
"[Newly archived raw context]\n"
f"{raw}"
)
bounded = truncate_text_to_tokens(combined, token_limit)
if bounded == combined:
return combined
# Keep evidence from both sides when their full concatenation cannot fit.
section_limit = max(1, (token_limit - 32) // 2)
return truncate_text_to_tokens(
"[Previous archived context]\n"
f"{truncate_text_to_tokens(previous_summary, section_limit)}\n\n"
"[Newly archived raw context]\n"
f"{truncate_text_to_tokens(raw, section_limit)}",
token_limit,
)
async def archive(
self,
@@ -825,48 +844,53 @@ class MemoryArchiver:
session_key: str,
request_messages: list[dict[str, Any]],
request_tools: list[dict[str, Any]],
previous_summary: str | None = None,
) -> str | None:
"""Execute a prepared archive request and persist its result."""
if not messages:
return None
def raw_fallback() -> str:
return self._raw_checkpoint(
messages,
session_key=session_key,
previous_summary=previous_summary,
max_tokens=runtime.generation.max_tokens,
)
try:
with llm_usage_source("dream"):
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
except Exception:
logger.warning("Memory archive provider call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
return raw_fallback()
if response.finish_reason in {"error", "length"}:
logger.warning(
"Memory archive provider did not complete ({}), raw-dumping to history",
response.finish_reason,
)
self.store.raw_archive(messages, session_key=session_key)
return None
return raw_fallback()
if response.has_tool_calls is True:
logger.warning("Memory archive provider returned tool calls, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
return raw_fallback()
summary = response.content
if not summary or not summary.strip():
logger.warning("Memory archive provider returned no summary, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if summary.strip() == "(nothing)":
return raw_fallback()
summary = self.store._normalize_history_entry(summary)
if not summary:
logger.warning("Memory archive provider summary was not safe to replay, raw-dumping")
return raw_fallback()
if summary == "(nothing)":
return "(nothing)"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key,
)
self.store.append_history(summary, session_key=session_key)
return summary
async def archive_session(
@@ -881,13 +905,26 @@ class MemoryArchiver:
messages = list(session.messages[session.last_archived:archive_end])
if not messages:
return None
session_summary = session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
previous_summary = session_summary["text"] if session_summary else None
def raw_fallback() -> str:
return self._raw_checkpoint(
messages,
session_key=session.key,
previous_summary=previous_summary,
max_tokens=runtime.generation.max_tokens,
)
if input_token_budget <= 0:
logger.debug(
"Memory archive has no safe input budget for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
return raw_fallback()
prefix = Session(
key=session.key,
messages=list(session.messages[:archive_end]),
@@ -903,13 +940,8 @@ class MemoryArchiver:
"Memory archive cannot replay the full chunk for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
prompt = render_template(
"agent/consolidator_archive.md",
strip=True,
archive_count=len(archive_history),
)
return raw_fallback()
prompt = render_template("agent/consolidator_archive.md", strip=True)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
workspace: Path | None = None
if self._resolve_prompt_context is not None:
@@ -918,13 +950,8 @@ class MemoryArchiver:
history=history,
current_message=prompt,
channel=channel,
session_summary=session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
),
session_summary=session_summary,
workspace=workspace,
session_key=session.key,
unified_session=self.unified_session,
)
tools = self._get_tool_definitions()
estimated, source = estimate_prompt_tokens_chain(
@@ -941,14 +968,14 @@ class MemoryArchiver:
input_token_budget,
source,
)
self.store.raw_archive(messages, session_key=session.key)
return None
return raw_fallback()
return await self.archive(
messages,
runtime=runtime,
session_key=session.key,
request_messages=request_messages,
request_tools=tools,
previous_summary=previous_summary,
)
@@ -964,20 +991,16 @@ class Consolidator:
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
unified_session: bool = False,
):
self.store = store
self.sessions = sessions
self.unified_session = unified_session
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._resolve_prompt_context = resolve_prompt_context
self.archiver = MemoryArchiver(
store=store,
build_messages=build_messages,
get_tool_definitions=get_tool_definitions,
resolve_prompt_context=resolve_prompt_context,
unified_session=unified_session,
)
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
@@ -1013,13 +1036,18 @@ class Consolidator:
return []
return session.get_history()
def _persist_last_summary(self, session: Session, summary: str | None) -> None:
if summary and summary != "(nothing)":
@staticmethod
def _set_last_summary(
session: Session,
summary: str,
*,
last_active: datetime | None = None,
) -> None:
if summary != "(nothing)":
session.metadata["_last_summary"] = {
"text": summary,
"last_active": session.updated_at.isoformat(),
"last_active": (last_active or session.updated_at).isoformat(),
}
self.sessions.save(session)
def estimate_session_prompt_tokens(
self,
@@ -1039,8 +1067,6 @@ class Consolidator:
current_message="[token-probe]",
channel=channel,
session_summary=summary,
session_key=session.key,
unified_session=self.unified_session,
)
return estimate_prompt_tokens_chain(
runtime.provider,
@@ -1057,24 +1083,6 @@ class Consolidator:
- self._SAFETY_BUFFER
)
async def archive(
self,
messages: list[dict[str, Any]],
*,
runtime: LLMRuntime,
session_key: str,
request_messages: list[dict[str, Any]],
request_tools: list[dict[str, Any]],
) -> str | None:
"""Compatibility wrapper for the extracted MemoryArchiver."""
return await self.archiver.archive(
messages,
runtime=runtime,
session_key=session_key,
request_messages=request_messages,
request_tools=request_tools,
)
async def archive_session(
self,
session: Session,
@@ -1101,26 +1109,23 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window.
"""
if runtime.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
# Refresh session reference: AutoCompact may have replaced it.
fresh = self.sessions.get_or_create(session.key)
if fresh is not session:
session = fresh
if runtime.context_window_tokens <= 0:
return
if not session.messages:
return
budget = self._input_token_budget(runtime)
last_summary: str | None = None
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
if estimated <= 0:
self._persist_last_summary(session, last_summary)
return
if estimated < budget:
unarchived_count = len(session.messages) - session.last_archived
@@ -1132,7 +1137,6 @@ class Consolidator:
source,
unarchived_count,
)
self._persist_last_summary(session, last_summary)
return
end_idx = self.pick_consolidation_boundary(session)
@@ -1160,18 +1164,12 @@ class Consolidator:
archive_end=end_idx,
runtime=runtime,
)
# Advance either way: archive_session raw-archives on degradation,
# and replaying the same chunk would duplicate Memory material.
if summary:
last_summary = summary
if summary is None:
return
self._set_last_summary(session, summary)
session.last_archived = end_idx
self.sessions.save(session)
# Persist the last summary to session metadata so it can be injected
# into the runtime context on the next prepare_session() call, aligning
# the summary injection strategy with AutoCompact._archive().
self._persist_last_summary(session, last_summary)
async def compact_idle_session(
self,
session_key: str,
@@ -1209,12 +1207,10 @@ class Consolidator:
archive_end=archive_end,
runtime=runtime,
)
if summary is None:
return None
if summary and summary != "(nothing)":
session.metadata["_last_summary"] = {
"text": summary,
"last_active": last_active.isoformat(),
}
self._set_last_summary(session, summary, last_active=last_active)
# A turn can append while the provider call is in flight. Advance only
# through the captured batch so new messages remain eligible next time.
+34 -19
View File
@@ -1,27 +1,42 @@
Create a memory overview for only the final {{ archive_count }} conversation messages immediately before this instruction. Earlier messages are context for resolving references; do not summarize them again.
Create a compact replacement checkpoint for this session.
Use [skip] unless a fact meets all SNIP criteria:
- Signal: would the user need to repeat this if forgotten?
- Novel: not just a restatement of another fact in this same conversation chunk
- Important: prevents rework or captures preferences / rules
- Persistent: still relevant after 2 weeks
When `[Archived Context Summary]` appears in the system prompt, update that previous checkpoint to reflect the current conversation state.
Also preserve a compact working-state handoff even when it is not Persistent: the active objective, current status, completed steps, unresolved blockers, next action, and exact identifiers needed to continue without rework. Mark these facts [ephemeral].
## Merge rules
Format each fact as:
- [mark] fact content
- Use the latest correction or decision as the current version of a fact, and merge duplicates.
- Preserve exact names, identifiers, paths, commands, decisions, results, and unresolved blockers when they are needed to continue the session.
- Retain a fact already present in long-term memory when it is needed for session continuity.
Marks (choose the best match):
- [permanent] Core preferences, personal traits, habits — never becomes stale
- [durable] Technical discoveries, project knowledge, config details — valid for months
- [ephemeral] Active task state, temporary decisions — may change in weeks
- [correction] Correction to a previous memory — state what changed
- [skip] Conversational filler, code/source facts derivable from the repo, or audit-only breadcrumbs
## What to retain
Priority: user corrections and preferences > solutions > decisions > events > environment facts.
Always retain a compact working-state handoff:
- active objective
- current status
- completed results that constrain later work
- unresolved blockers
- next action
- exact identifiers needed for that action
Do not output facts already present in the system prompt's Recent History.
Mark working-state facts `[ephemeral]`.
Do not mark something [skip] merely because it might already exist in long-term memory.
For other facts, retain a candidate only when it meets all four SNIP criteria:
- Signal: remembering it saves the user from repeating it
- Novel: it adds a distinct fact to this checkpoint
- Important: losing it would cause rework or discard a preference or rule
- Persistent: it is expected to remain useful for at least two weeks
Return only formatted fact lines, or `(nothing)` if nothing noteworthy happened.
Assign each retained fact its best current mark:
- `[permanent]` for core preferences, personal traits, and habits that remain relevant indefinitely
- `[durable]` for technical discoveries, project knowledge, and configuration that remains valid for months
- `[ephemeral]` for active task state and temporary decisions that may change within weeks
- `[correction]` for the current fact that supersedes conflicting earlier long-term memory
When space is limited, prioritize user corrections and preferences, then solutions, decisions, events, and environment facts.
## Output
Return one concise retained fact per line in this form:
- [mark] fact
Use `(nothing)` when no fact qualifies and there is no active working state.