返回 JoyAI-Echo
memory.py
1 """Memory system: pure file I/O store, lightweight Consolidator, and Dream processor."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import json
7 import re
8 import weakref
9 import tiktoken
10 from datetime import datetime
11 from pathlib import Path
12 from typing import TYPE_CHECKING, Any, Callable, Iterator
13
14 from loguru import logger
15
16 from nanobot.utils.prompt_templates import render_template
17 from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
18
19 from nanobot.agent.runner import AgentRunSpec, AgentRunner
20 from nanobot.agent.tools.registry import ToolRegistry
21 from nanobot.utils.gitstore import GitStore
22
23 if TYPE_CHECKING:
24 from nanobot.providers.base import LLMProvider
25 from nanobot.session.manager import Session, SessionManager
26
27
28 # ---------------------------------------------------------------------------
29 # MemoryStore — pure file I/O layer
30 # ---------------------------------------------------------------------------
31
32 class MemoryStore:
33 """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
34
35 _DEFAULT_MAX_HISTORY = 1000
36 _LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
37 _LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
38 _LEGACY_RAW_MESSAGE_RE = re.compile(
39 r"^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s+[A-Z][A-Z0-9_]*(?:\s+\[tools:\s*[^\]]+\])?:"
40 )
41
42 def __init__(self, workspace: Path, max_history_entries: int = _DEFAULT_MAX_HISTORY):
43 self.workspace = workspace
44 self.max_history_entries = max_history_entries
45 self.memory_dir = ensure_dir(workspace / "memory")
46 self.memory_file = self.memory_dir / "MEMORY.md"
47 self.history_file = self.memory_dir / "history.jsonl"
48 self.legacy_history_file = self.memory_dir / "HISTORY.md"
49 self.soul_file = workspace / "SOUL.md"
50 self.user_file = workspace / "USER.md"
51 self._cursor_file = self.memory_dir / ".cursor"
52 self._dream_cursor_file = self.memory_dir / ".dream_cursor"
53 self._corruption_logged = False # rate-limit non-int cursor warning
54 self._oversize_logged = False # rate-limit oversized-entry warning
55 self._git = GitStore(workspace, tracked_files=[
56 "SOUL.md", "USER.md", "memory/MEMORY.md",
57 ])
58 self._maybe_migrate_legacy_history()
59
60 @property
61 def git(self) -> GitStore:
62 return self._git
63
64 # -- generic helpers -----------------------------------------------------
65
66 @staticmethod
67 def read_file(path: Path) -> str:
68 try:
69 return path.read_text(encoding="utf-8")
70 except FileNotFoundError:
71 return ""
72
73 def _maybe_migrate_legacy_history(self) -> None:
74 """One-time upgrade from legacy HISTORY.md to history.jsonl.
75
76 The migration is best-effort and prioritizes preserving as much content
77 as possible over perfect parsing.
78 """
79 if not self.legacy_history_file.exists():
80 return
81 if self.history_file.exists() and self.history_file.stat().st_size > 0:
82 return
83
84 try:
85 legacy_text = self.legacy_history_file.read_text(
86 encoding="utf-8",
87 errors="replace",
88 )
89 except OSError:
90 logger.exception("Failed to read legacy HISTORY.md for migration")
91 return
92
93 entries = self._parse_legacy_history(legacy_text)
94 try:
95 if entries:
96 self._write_entries(entries)
97 last_cursor = entries[-1]["cursor"]
98 self._cursor_file.write_text(str(last_cursor), encoding="utf-8")
99 # Default to "already processed" so upgrades do not replay the
100 # user's entire historical archive into Dream on first start.
101 self._dream_cursor_file.write_text(str(last_cursor), encoding="utf-8")
102
103 backup_path = self._next_legacy_backup_path()
104 self.legacy_history_file.replace(backup_path)
105 logger.info(
106 "Migrated legacy HISTORY.md to history.jsonl ({} entries)",
107 len(entries),
108 )
109 except Exception:
110 logger.exception("Failed to migrate legacy HISTORY.md")
111
112 def _parse_legacy_history(self, text: str) -> list[dict[str, Any]]:
113 normalized = text.replace("\r\n", "\n").replace("\r", "\n").strip()
114 if not normalized:
115 return []
116
117 fallback_timestamp = self._legacy_fallback_timestamp()
118 entries: list[dict[str, Any]] = []
119 chunks = self._split_legacy_history_chunks(normalized)
120
121 for cursor, chunk in enumerate(chunks, start=1):
122 timestamp = fallback_timestamp
123 content = chunk
124 match = self._LEGACY_TIMESTAMP_RE.match(chunk)
125 if match:
126 timestamp = match.group(1)
127 remainder = chunk[match.end():].lstrip()
128 if remainder:
129 content = remainder
130
131 entries.append({
132 "cursor": cursor,
133 "timestamp": timestamp,
134 "content": content,
135 })
136 return entries
137
138 def _split_legacy_history_chunks(self, text: str) -> list[str]:
139 lines = text.split("\n")
140 chunks: list[str] = []
141 current: list[str] = []
142 saw_blank_separator = False
143
144 for line in lines:
145 if saw_blank_separator and line.strip() and current:
146 chunks.append("\n".join(current).strip())
147 current = [line]
148 saw_blank_separator = False
149 continue
150 if self._should_start_new_legacy_chunk(line, current):
151 chunks.append("\n".join(current).strip())
152 current = [line]
153 saw_blank_separator = False
154 continue
155 current.append(line)
156 saw_blank_separator = not line.strip()
157
158 if current:
159 chunks.append("\n".join(current).strip())
160 return [chunk for chunk in chunks if chunk]
161
162 def _should_start_new_legacy_chunk(self, line: str, current: list[str]) -> bool:
163 if not current:
164 return False
165 if not self._LEGACY_ENTRY_START_RE.match(line):
166 return False
167 if self._is_raw_legacy_chunk(current) and self._LEGACY_RAW_MESSAGE_RE.match(line):
168 return False
169 return True
170
171 def _is_raw_legacy_chunk(self, lines: list[str]) -> bool:
172 first_nonempty = next((line for line in lines if line.strip()), "")
173 match = self._LEGACY_TIMESTAMP_RE.match(first_nonempty)
174 if not match:
175 return False
176 return first_nonempty[match.end():].lstrip().startswith("[RAW]")
177
178 def _legacy_fallback_timestamp(self) -> str:
179 try:
180 return datetime.fromtimestamp(
181 self.legacy_history_file.stat().st_mtime,
182 ).strftime("%Y-%m-%d %H:%M")
183 except OSError:
184 return datetime.now().strftime("%Y-%m-%d %H:%M")
185
186 def _next_legacy_backup_path(self) -> Path:
187 candidate = self.memory_dir / "HISTORY.md.bak"
188 suffix = 2
189 while candidate.exists():
190 candidate = self.memory_dir / f"HISTORY.md.bak.{suffix}"
191 suffix += 1
192 return candidate
193
194 # -- MEMORY.md (long-term facts) -----------------------------------------
195
196 def read_memory(self) -> str:
197 return self.read_file(self.memory_file)
198
199 def write_memory(self, content: str) -> None:
200 self.memory_file.write_text(content, encoding="utf-8")
201
202 # -- SOUL.md -------------------------------------------------------------
203
204 def read_soul(self) -> str:
205 return self.read_file(self.soul_file)
206
207 def write_soul(self, content: str) -> None:
208 self.soul_file.write_text(content, encoding="utf-8")
209
210 # -- USER.md -------------------------------------------------------------
211
212 def read_user(self) -> str:
213 return self.read_file(self.user_file)
214
215 def write_user(self, content: str) -> None:
216 self.user_file.write_text(content, encoding="utf-8")
217
218 # -- context injection (used by context.py) ------------------------------
219
220 def get_memory_context(self) -> str:
221 long_term = self.read_memory()
222 return f"## Long-term Memory\n{long_term}" if long_term else ""
223
224 # -- history.jsonl — append-only, JSONL format ---------------------------
225
226 def append_history(self, entry: str, *, max_chars: int | None = None) -> int:
227 """Append *entry* to history.jsonl and return its auto-incrementing cursor.
228
229 Entries are passed through `strip_think` to drop template-level leaks
230 (e.g. unclosed `<think` prefixes, `<channel|>` markers) before being
231 persisted. If the cleaned content is empty but the raw entry wasn't,
232 the record is persisted with an empty string rather than falling back
233 to the raw leak — otherwise `strip_think`'s guarantees would be
234 undone by history replay / consolidation downstream.
235
236 A defensive cap (*max_chars*, default ``_HISTORY_ENTRY_HARD_CAP``) is
237 applied as a final safety net: individual callers should cap their own
238 content more tightly; this default only exists to catch unintentional
239 large writes (e.g. an LLM echoing its input back as a "summary").
240 """
241 limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
242 cursor = self._next_cursor()
243 ts = datetime.now().strftime("%Y-%m-%d %H:%M")
244 raw = entry.rstrip()
245 if len(raw) > limit:
246 if not self._oversize_logged:
247 self._oversize_logged = True
248 logger.warning(
249 "history entry exceeds {} chars ({}); truncating. "
250 "Usually means a caller forgot its own cap; "
251 "further occurrences suppressed.",
252 limit, len(raw),
253 )
254 raw = truncate_text(raw, limit)
255 content = strip_think(raw)
256 if raw and not content:
257 logger.debug(
258 "history entry {} stripped to empty (likely template leak); "
259 "persisting empty content to avoid re-polluting context",
260 cursor,
261 )
262 record = {"cursor": cursor, "timestamp": ts, "content": content}
263 with open(self.history_file, "a", encoding="utf-8") as f:
264 f.write(json.dumps(record, ensure_ascii=False) + "\n")
265 self._cursor_file.write_text(str(cursor), encoding="utf-8")
266 return cursor
267
268 @staticmethod
269 def _valid_cursor(value: Any) -> int | None:
270 """Int cursors only — reject bool (``isinstance(True, int)`` is True)."""
271 if isinstance(value, bool) or not isinstance(value, int):
272 return None
273 return value
274
275 def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]:
276 """Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption."""
277 poisoned: Any = None
278 for entry in self._read_entries():
279 raw = entry.get("cursor")
280 if raw is None:
281 continue
282 cursor = self._valid_cursor(raw)
283 if cursor is None:
284 poisoned = raw
285 continue
286 yield entry, cursor
287 if poisoned is not None and not self._corruption_logged:
288 self._corruption_logged = True
289 logger.warning(
290 "history.jsonl contains a non-int cursor ({!r}); dropping it. "
291 "Usually caused by an external writer; further occurrences suppressed.",
292 poisoned,
293 )
294
295 def _next_cursor(self) -> int:
296 """Read the current cursor counter and return the next value."""
297 if self._cursor_file.exists():
298 try:
299 return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
300 except (ValueError, OSError):
301 pass
302 # Fast path: trust the tail when intact. Otherwise scan the whole
303 # file and take ``max`` — that stays correct even if the monotonic
304 # invariant was broken by external writes.
305 last = self._read_last_entry() or {}
306 cursor = self._valid_cursor(last.get("cursor"))
307 if cursor is not None:
308 return cursor + 1
309 return max((c for _, c in self._iter_valid_entries()), default=0) + 1
310
311 def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
312 """Return history entries with a valid cursor > *since_cursor*."""
313 return [e for e, c in self._iter_valid_entries() if c > since_cursor]
314
315 def compact_history(self) -> None:
316 """Drop oldest entries if the file exceeds *max_history_entries*."""
317 if self.max_history_entries <= 0:
318 return
319 entries = self._read_entries()
320 if len(entries) <= self.max_history_entries:
321 return
322 kept = entries[-self.max_history_entries:]
323 self._write_entries(kept)
324
325 # -- JSONL helpers -------------------------------------------------------
326
327 def _read_entries(self) -> list[dict[str, Any]]:
328 """Read all entries from history.jsonl."""
329 entries: list[dict[str, Any]] = []
330 try:
331 with open(self.history_file, "r", encoding="utf-8") as f:
332 for line in f:
333 line = line.strip()
334 if line:
335 try:
336 entries.append(json.loads(line))
337 except json.JSONDecodeError:
338 continue
339 except FileNotFoundError:
340 pass
341 return entries
342
343 def _read_last_entry(self) -> dict[str, Any] | None:
344 """Read the last entry from the JSONL file efficiently."""
345 try:
346 with open(self.history_file, "rb") as f:
347 f.seek(0, 2)
348 size = f.tell()
349 if size == 0:
350 return None
351 read_size = min(size, 4096)
352 f.seek(size - read_size)
353 data = f.read().decode("utf-8")
354 lines = [l for l in data.split("\n") if l.strip()]
355 if not lines:
356 return None
357 return json.loads(lines[-1])
358 except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
359 return None
360
361 def _write_entries(self, entries: list[dict[str, Any]]) -> None:
362 """Overwrite history.jsonl with the given entries."""
363 with open(self.history_file, "w", encoding="utf-8") as f:
364 for entry in entries:
365 f.write(json.dumps(entry, ensure_ascii=False) + "\n")
366
367 # -- dream cursor --------------------------------------------------------
368
369 def get_last_dream_cursor(self) -> int:
370 if self._dream_cursor_file.exists():
371 try:
372 return int(self._dream_cursor_file.read_text(encoding="utf-8").strip())
373 except (ValueError, OSError):
374 pass
375 return 0
376
377 def set_last_dream_cursor(self, cursor: int) -> None:
378 self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
379
380 # -- message formatting utility ------------------------------------------
381
382 @staticmethod
383 def _format_messages(messages: list[dict]) -> str:
384 lines = []
385 for message in messages:
386 if not message.get("content"):
387 continue
388 tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
389 lines.append(
390 f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
391 )
392 return "\n".join(lines)
393
394 def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
395 """Fallback: dump raw messages to history.jsonl without LLM summarization."""
396 limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
397 formatted = truncate_text(self._format_messages(messages), limit)
398 self.append_history(
399 f"[RAW] {len(messages)} messages\n"
400 f"{formatted}"
401 )
402 logger.warning(
403 "Memory consolidation degraded: raw-archived {} messages", len(messages)
404 )
405
406
407
408 # ---------------------------------------------------------------------------
409 # Consolidator — lightweight token-budget triggered consolidation
410 # ---------------------------------------------------------------------------
411
412
413 # Individual history.jsonl writers cap their own payloads tightly; the
414 # _HISTORY_ENTRY_HARD_CAP at append_history() is a belt-and-suspenders default
415 # that catches any new caller that forgot to set its own cap.
416 _RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
417 _ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
418 _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
419
420
421 class Consolidator:
422 """Lightweight consolidation: summarizes evicted messages into history.jsonl."""
423
424 _MAX_CONSOLIDATION_ROUNDS = 5
425
426 _SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
427
428 def __init__(
429 self,
430 store: MemoryStore,
431 provider: LLMProvider,
432 model: str,
433 sessions: SessionManager,
434 context_window_tokens: int,
435 build_messages: Callable[..., list[dict[str, Any]]],
436 get_tool_definitions: Callable[[], list[dict[str, Any]]],
437 max_completion_tokens: int = 4096,
438 ):
439 self.store = store
440 self.provider = provider
441 self.model = model
442 self.sessions = sessions
443 self.context_window_tokens = context_window_tokens
444 self.max_completion_tokens = max_completion_tokens
445 self._build_messages = build_messages
446 self._get_tool_definitions = get_tool_definitions
447 self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
448 weakref.WeakValueDictionary()
449 )
450
451 def get_lock(self, session_key: str) -> asyncio.Lock:
452 """Return the shared consolidation lock for one session."""
453 return self._locks.setdefault(session_key, asyncio.Lock())
454
455 def pick_consolidation_boundary(
456 self,
457 session: Session,
458 tokens_to_remove: int,
459 ) -> tuple[int, int] | None:
460 """Pick a user-turn boundary that removes enough old prompt tokens."""
461 start = session.last_consolidated
462 if start >= len(session.messages) or tokens_to_remove <= 0:
463 return None
464
465 removed_tokens = 0
466 last_boundary: tuple[int, int] | None = None
467 for idx in range(start, len(session.messages)):
468 message = session.messages[idx]
469 if idx > start and message.get("role") == "user":
470 last_boundary = (idx, removed_tokens)
471 if removed_tokens >= tokens_to_remove:
472 return last_boundary
473 removed_tokens += estimate_message_tokens(message)
474
475 return last_boundary
476
477 def estimate_session_prompt_tokens(
478 self,
479 session: Session,
480 *,
481 session_summary: str | None = None,
482 ) -> tuple[int, str]:
483 """Estimate current prompt size for the normal session history view."""
484 from nanobot.agent.event_stacker import EventStacker
485
486 history = session.get_history(max_messages=0)
487 channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
488 with EventStacker.phase("token_estimation"):
489 probe_messages = self._build_messages(
490 history=history,
491 current_message="[token-probe]",
492 channel=channel,
493 chat_id=chat_id,
494 session_summary=session_summary,
495 )
496 return estimate_prompt_tokens_chain(
497 self.provider,
498 self.model,
499 probe_messages,
500 self._get_tool_definitions(),
501 )
502
503 @property
504 def _input_token_budget(self) -> int:
505 """Available input token budget for consolidation LLM."""
506 return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
507
508 def _truncate_to_token_budget(self, text: str) -> str:
509 """Truncate text so it fits within the consolidation LLM's token budget."""
510 budget = self._input_token_budget
511 if budget <= 0:
512 return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
513 try:
514 enc = tiktoken.get_encoding("cl100k_base")
515 tokens = enc.encode(text)
516 if len(tokens) <= budget:
517 return text
518 return enc.decode(tokens[:budget]) + "\n... (truncated)"
519 except Exception:
520 return truncate_text(text, budget * 4)
521
522 async def archive(self, messages: list[dict]) -> str | None:
523 """Summarize messages via LLM and append to history.jsonl.
524
525 Returns the summary text on success, None if nothing to archive.
526 """
527 if not messages:
528 return None
529 try:
530 formatted = MemoryStore._format_messages(messages)
531 formatted = self._truncate_to_token_budget(formatted)
532 response = await self.provider.chat_with_retry(
533 model=self.model,
534 messages=[
535 {
536 "role": "system",
537 "content": render_template(
538 "agent/consolidator_archive.md",
539 strip=True,
540 ),
541 },
542 {"role": "user", "content": formatted},
543 ],
544 tools=None,
545 tool_choice=None,
546 )
547 if response.finish_reason == "error":
548 raise RuntimeError(f"LLM returned error: {response.content}")
549 summary = response.content or "[no summary]"
550 self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
551 return summary
552 except Exception:
553 logger.warning("Consolidation LLM call failed, raw-dumping to history")
554 self.store.raw_archive(messages)
555 return None
556
557 async def maybe_consolidate_by_tokens(
558 self,
559 session: Session,
560 *,
561 session_summary: str | None = None,
562 ) -> None:
563 """Loop: archive old messages until prompt fits within safe budget.
564
565 The budget reserves space for completion tokens and a safety buffer
566 so the LLM request never exceeds the context window.
567 """
568 if not session.messages or self.context_window_tokens <= 0:
569 return
570
571 lock = self.get_lock(session.key)
572 async with lock:
573 budget = self._input_token_budget
574 target = budget // 2
575 try:
576 estimated, source = self.estimate_session_prompt_tokens(
577 session,
578 session_summary=session_summary,
579 )
580 except Exception:
581 logger.exception("Token estimation failed for {}", session.key)
582 estimated, source = 0, "error"
583 if estimated <= 0:
584 return
585 if estimated < budget:
586 unconsolidated_count = len(session.messages) - session.last_consolidated
587 logger.debug(
588 "Token consolidation idle {}: {}/{} via {}, msgs={}",
589 session.key,
590 estimated,
591 self.context_window_tokens,
592 source,
593 unconsolidated_count,
594 )
595 return
596
597 last_summary = None
598 for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
599 if estimated <= target:
600 break
601
602 boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
603 if boundary is None:
604 logger.debug(
605 "Token consolidation: no safe boundary for {} (round {})",
606 session.key,
607 round_num,
608 )
609 break
610
611 end_idx = boundary[0]
612
613 chunk = session.messages[session.last_consolidated:end_idx]
614 if not chunk:
615 break
616
617 logger.info(
618 "Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
619 round_num,
620 session.key,
621 estimated,
622 self.context_window_tokens,
623 source,
624 len(chunk),
625 )
626 summary = await self.archive(chunk)
627 # Advance the cursor either way: on success the chunk was
628 # summarized; on failure archive() already raw-archived it as
629 # a breadcrumb. Re-archiving the same chunk on the next call
630 # would just emit duplicate [RAW] entries.
631 if summary:
632 last_summary = summary
633 session.last_consolidated = end_idx
634 self.sessions.save(session)
635 if not summary:
636 # LLM is degraded — stop hammering it this call;
637 # the next invocation can retry a fresh chunk.
638 break
639
640 try:
641 estimated, source = self.estimate_session_prompt_tokens(
642 session,
643 session_summary=session_summary,
644 )
645 except Exception:
646 logger.exception("Token estimation failed for {}", session.key)
647 estimated, source = 0, "error"
648 if estimated <= 0:
649 break
650
651 # Persist the last summary to session metadata so it can be injected
652 # into the runtime context on the next prepare_session() call, aligning
653 # the summary injection strategy with AutoCompact._archive().
654 if last_summary and last_summary != "(nothing)":
655 session.metadata["_last_summary"] = {
656 "text": last_summary,
657 "last_active": session.updated_at.isoformat(),
658 }
659 self.sessions.save(session)
660
661
662 # ---------------------------------------------------------------------------
663 # Dream — heavyweight cron-scheduled memory consolidation
664 # ---------------------------------------------------------------------------
665
666
667 # Single source of truth for the staleness threshold used in _annotate_with_ages
668 # *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
669 # Keep code and prompt aligned — if you bump this, the LLM's instruction string
670 # updates automatically.
671 _STALE_THRESHOLD_DAYS = 14
672
673
674 class Dream:
675 """Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
676
677 Phase 1 produces an analysis summary (plain LLM call).
678 Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
679 LLM can make targeted, incremental edits instead of replacing entire files.
680 """
681
682 # Caps on prompt-bound inputs so Dream's LLM calls never exceed the model's
683 # context window just because a file (or a legacy large history entry) grew
684 # unexpectedly. Each file still appears in full via read_file when the agent
685 # needs it in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
686 _MEMORY_FILE_MAX_CHARS = 32_000
687 _SOUL_FILE_MAX_CHARS = 16_000
688 _USER_FILE_MAX_CHARS = 16_000
689 _HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
690
691 def __init__(
692 self,
693 store: MemoryStore,
694 provider: LLMProvider,
695 model: str,
696 max_batch_size: int = 20,
697 max_iterations: int = 10,
698 max_tool_result_chars: int = 16_000,
699 annotate_line_ages: bool = True,
700 ):
701 self.store = store
702 self.provider = provider
703 self.model = model
704 self.max_batch_size = max_batch_size
705 self.max_iterations = max_iterations
706 self.max_tool_result_chars = max_tool_result_chars
707 # Kill switch for the git-blame-based per-line age annotation in Phase 1.
708 # Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
709 # (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
710 self.annotate_line_ages = annotate_line_ages
711 self._runner = AgentRunner(provider)
712 self._tools = self._build_tools()
713
714 # -- tool registry -------------------------------------------------------
715
716 def _build_tools(self) -> ToolRegistry:
717 """Build a minimal tool registry for the Dream agent."""
718 from nanobot.agent.skills import BUILTIN_SKILLS_DIR
719 from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
720
721 tools = ToolRegistry()
722 workspace = self.store.workspace
723 # Allow reading builtin skills for reference during skill creation
724 extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
725 tools.register(ReadFileTool(
726 workspace=workspace,
727 allowed_dir=workspace,
728 extra_allowed_dirs=extra_read,
729 ))
730 tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
731 # write_file resolves relative paths from workspace root, but can only
732 # write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
733 skills_dir = workspace / "skills"
734 skills_dir.mkdir(parents=True, exist_ok=True)
735 tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir))
736 return tools
737
738 # -- skill listing --------------------------------------------------------
739
740 def _list_existing_skills(self) -> list[str]:
741 """List existing skills as 'name — description' for dedup context."""
742 import re as _re
743
744 from nanobot.agent.skills import BUILTIN_SKILLS_DIR
745
746 _DESC_RE = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
747 entries: dict[str, str] = {}
748 for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
749 if not base.exists():
750 continue
751 for d in base.iterdir():
752 if not d.is_dir():
753 continue
754 skill_md = d / "SKILL.md"
755 if not skill_md.exists():
756 continue
757 # Prefer workspace skills over builtin (same name)
758 if d.name in entries and base == BUILTIN_SKILLS_DIR:
759 continue
760 content = skill_md.read_text(encoding="utf-8")[:500]
761 m = _DESC_RE.search(content)
762 desc = m.group(1).strip() if m else "(no description)"
763 entries[d.name] = desc
764 return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
765
766 # -- main entry ----------------------------------------------------------
767
768 def _annotate_with_ages(self, content: str) -> str:
769 """Append per-line age suffixes to MEMORY.md content.
770
771 Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
772 suffix like ``← 30d`` indicating days since last modification.
773 Returns the original content unchanged if git is unavailable,
774 annotate fails, or the line count doesn't match the age count
775 (which can happen with an uncommitted working-tree edit — better to
776 skip annotation than to tag the wrong line).
777 SOUL.md and USER.md are never annotated.
778 """
779 file_path = "memory/MEMORY.md"
780 try:
781 ages = self.store.git.line_ages(file_path)
782 except Exception:
783 logger.debug("line_ages failed for {}", file_path)
784 return content
785 if not ages:
786 return content
787
788 had_trailing = content.endswith("\n")
789 lines = content.splitlines()
790 # If HEAD-blob line count disagrees with the working-tree content we
791 # received, ages would be assigned to the wrong lines — skip entirely
792 # and feed the LLM un-annotated content rather than misleading data.
793 if len(lines) != len(ages):
794 logger.debug(
795 "line_ages length mismatch for {} (lines={}, ages={}); skipping annotation",
796 file_path, len(lines), len(ages),
797 )
798 return content
799
800 annotated: list[str] = []
801 for line, age in zip(lines, ages):
802 if not line.strip():
803 annotated.append(line)
804 continue
805 if age.age_days > _STALE_THRESHOLD_DAYS:
806 annotated.append(f"{line} \u2190 {age.age_days}d")
807 else:
808 annotated.append(line)
809 result = "\n".join(annotated)
810 if had_trailing:
811 result += "\n"
812 return result
813
814 async def run(self) -> bool:
815 """Process unprocessed history entries. Returns True if work was done."""
816 from nanobot.agent.skills import BUILTIN_SKILLS_DIR
817
818 last_cursor = self.store.get_last_dream_cursor()
819 entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
820 if not entries:
821 return False
822
823 batch = entries[: self.max_batch_size]
824 logger.info(
825 "Dream: processing {} entries (cursor {}→{}), batch={}",
826 len(entries), last_cursor, batch[-1]["cursor"], len(batch),
827 )
828
829 # Build history text for LLM — cap each entry so a legacy oversized
830 # record (e.g. pre-#3412 raw_archive dump) can't blow up the prompt.
831 history_text = "\n".join(
832 f"[{e['timestamp']}] "
833 f"{truncate_text(e['content'], self._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
834 for e in batch
835 )
836
837 # Current file contents + per-line age annotations (MEMORY.md only).
838 # Each file is capped in the *prompt preview* only; Phase 2 still sees
839 # the full file via the read_file tool.
840 current_date = datetime.now().strftime("%Y-%m-%d")
841 raw_memory = self.store.read_memory() or "(empty)"
842 annotated_memory = (
843 self._annotate_with_ages(raw_memory)
844 if self.annotate_line_ages
845 else raw_memory
846 )
847 current_memory = truncate_text(annotated_memory, self._MEMORY_FILE_MAX_CHARS)
848 current_soul = truncate_text(
849 self.store.read_soul() or "(empty)", self._SOUL_FILE_MAX_CHARS,
850 )
851 current_user = truncate_text(
852 self.store.read_user() or "(empty)", self._USER_FILE_MAX_CHARS,
853 )
854
855 file_context = (
856 f"## Current Date\n{current_date}\n\n"
857 f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
858 f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
859 f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
860 )
861
862 # Phase 1: Analyze (no skills list — dedup is Phase 2's job)
863 phase1_prompt = (
864 f"## Conversation History\n{history_text}\n\n{file_context}"
865 )
866
867 try:
868 phase1_response = await self.provider.chat_with_retry(
869 model=self.model,
870 messages=[
871 {
872 "role": "system",
873 "content": render_template(
874 "agent/dream_phase1.md",
875 strip=True,
876 stale_threshold_days=_STALE_THRESHOLD_DAYS,
877 ),
878 },
879 {"role": "user", "content": phase1_prompt},
880 ],
881 tools=None,
882 tool_choice=None,
883 )
884 analysis = phase1_response.content or ""
885 logger.debug("Dream Phase 1 analysis ({} chars): {}", len(analysis), analysis[:500])
886 except Exception:
887 logger.exception("Dream Phase 1 failed")
888 return False
889
890 # Phase 2: Delegate to AgentRunner with read_file / edit_file
891 existing_skills = self._list_existing_skills()
892 skills_section = ""
893 if existing_skills:
894 skills_section = (
895 "\n\n## Existing Skills\n"
896 + "\n".join(f"- {s}" for s in existing_skills)
897 )
898 phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
899
900 tools = self._tools
901 skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
902 messages: list[dict[str, Any]] = [
903 {
904 "role": "system",
905 "content": render_template(
906 "agent/dream_phase2.md",
907 strip=True,
908 skill_creator_path=str(skill_creator_path),
909 ),
910 },
911 {"role": "user", "content": phase2_prompt},
912 ]
913
914 try:
915 result = await self._runner.run(AgentRunSpec(
916 initial_messages=messages,
917 tools=tools,
918 model=self.model,
919 max_iterations=self.max_iterations,
920 max_tool_result_chars=self.max_tool_result_chars,
921 fail_on_tool_error=False,
922 ))
923 logger.debug(
924 "Dream Phase 2 complete: stop_reason={}, tool_events={}",
925 result.stop_reason, len(result.tool_events),
926 )
927 for ev in (result.tool_events or []):
928 logger.info("Dream tool_event: name={}, status={}, detail={}", ev.get("name"), ev.get("status"), ev.get("detail", "")[:200])
929 except Exception:
930 logger.exception("Dream Phase 2 failed")
931 result = None
932
933 # Build changelog from tool events
934 changelog: list[str] = []
935 if result and result.tool_events:
936 for event in result.tool_events:
937 if event["status"] == "ok":
938 changelog.append(f"{event['name']}: {event['detail']}")
939
940 # Advance cursor — always, to avoid re-processing Phase 1
941 new_cursor = batch[-1]["cursor"]
942 self.store.set_last_dream_cursor(new_cursor)
943 self.store.compact_history()
944
945 if result and result.stop_reason == "completed":
946 logger.info(
947 "Dream done: {} change(s), cursor advanced to {}",
948 len(changelog), new_cursor,
949 )
950 else:
951 reason = result.stop_reason if result else "exception"
952 logger.warning(
953 "Dream incomplete ({}): cursor advanced to {}",
954 reason, new_cursor,
955 )
956
957 # Git auto-commit (only when there are actual changes)
958 if changelog and self.store.git.is_initialized():
959 ts = batch[-1]["timestamp"]
960 summary = f"dream: {ts}, {len(changelog)} change(s)"
961 commit_msg = f"{summary}\n\n{analysis.strip()}"
962 sha = self.store.git.auto_commit(commit_msg)
963 if sha:
964 logger.info("Dream commit: {}", sha)
965
966 return True
967
967 lines PYTHON