From 4efebd218c0cf0fcce70e0788eb156beb0bbf06a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 06:20:05 +0000 Subject: [PATCH 1/2] feat: MEMORY.md / BOOTSTRAP.md cross-session long-term memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #160. Introduces a durable layer above the auto-generated Tier 2 summaries: - MEMORY.md at ~/.qracer/MEMORY.md with a machine-managed auto region (delimited by / ) that is regenerated from FactStore after every thesis save; everything outside the auto region is user-curated and preserved verbatim. - BOOTSTRAP.md at ~/.qracer/BOOTSTRAP.md — optional raw text loaded once at ConversationEngine init and injected as a system turn so preferences reach the synthesizer without code changes. New module qracer/memory/memory_file.py exposes load/save/parse/render, refresh_memory(doc, fact_store), refresh_memory_file(path, fact_store), and load_bootstrap(path). ConversationEngine gains memory_path and bootstrap_path kwargs plus a memory_document property. CLI wires both files in `repl()` and adds `memory show / refresh / edit` commands. --- docs/memory-system.md | 39 +++- qracer/cli.py | 98 ++++++++- qracer/conversation/engine.py | 77 +++++++ qracer/memory/memory_file.py | 324 ++++++++++++++++++++++++++++++ tests/conversation/test_engine.py | 148 ++++++++++++++ tests/memory/test_memory_file.py | 284 ++++++++++++++++++++++++++ 6 files changed, 966 insertions(+), 4 deletions(-) create mode 100644 qracer/memory/memory_file.py create mode 100644 tests/memory/test_memory_file.py diff --git a/docs/memory-system.md b/docs/memory-system.md index 1a394b5..e958d6b 100644 --- a/docs/memory-system.md +++ b/docs/memory-system.md @@ -35,7 +35,40 @@ On `qracer repl` startup, the CLI instantiates a file-backed `MemorySearcher` at ## MEMORY.md vs. Tier 2 -> **구현 예정** — MEMORY.md, BOOTSTRAP.md 기반 크로스 세션 메모리는 아직 구현되지 않았습니다. - - **Tier 2**: auto-generated, per-session. Temporary working memory. -- **MEMORY.md**: cross-session long-term memory. Manually curated or auto-aggregated. Contains active theses and strong multi-session signals. Loaded at session start via `BOOTSTRAP.md`. +- **MEMORY.md**: cross-session long-term memory stored at `~/.qracer/MEMORY.md`. A machine-managed auto region (delimited by `` / ``) holds open theses and upcoming catalysts regenerated from `FactStore` after every thesis save; everything outside the auto region is user-curated free text and preserved verbatim across refreshes. +- **BOOTSTRAP.md**: optional user-authored system prompt extension at `~/.qracer/BOOTSTRAP.md`. Loaded once at `ConversationEngine` init and injected as a `system` turn so preferences ("I'm a long-term value investor") reach the synthesizer without code changes. + +### MEMORY.md format + +```markdown +# qracer MEMORY.md + +*Last updated: 2026-04-15T12:34:56+00:00* + + +## Active Theses + +- **AAPL** (conviction 8/10): Long AAPL on AI tailwinds. Entry $175.00-$180.00, target $200.00, stop $165.00. Catalyst: AI revenue growth (Q2 2026). + +## Upcoming Catalysts + +- AAPL: AI revenue growth — Q2 2026 + + + +## Watchpoints + +_(User-editable. Anything outside the auto block is preserved across refreshes.)_ + +## User Preferences + +- Risk tolerance: +- Preferred sectors: +``` + +### CLI commands + +- `memory show` — print the current MEMORY.md. +- `memory refresh` — regenerate the auto region from `FactStore` (also happens automatically after each thesis save). +- `memory edit` — open MEMORY.md in `$EDITOR` for hand-curation; the file is seeded on first use. diff --git a/qracer/cli.py b/qracer/cli.py index 7b394c7..1795e5f 100644 --- a/qracer/cli.py +++ b/qracer/cli.py @@ -33,7 +33,7 @@ ║ qracer — conversational alpha engine ║ ╚══════════════════════════════════════════╝ Type your query, or 'quit' to exit. -Commands: save, save json, save pdf, backtest, help +Commands: save, save json, save pdf, memory, backtest, help """ @@ -351,6 +351,9 @@ def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]: save Save last analysis as Markdown save json Save last analysis as JSON save pdf Save last analysis as PDF (requires qracer[pdf] extra) + memory show Display the current MEMORY.md cross-session memory file + memory refresh Regenerate the auto region of MEMORY.md from the fact store + memory edit Open MEMORY.md in $EDITOR (defaults to nano) backtest Backtest the last trade thesis against historical data watchlist Show watchlist with current prices watch TICKER Add ticker to watchlist @@ -385,6 +388,7 @@ async def _repl_loop( sessions_dir: Path | None = None, current_session: Path | None = None, fact_store: object | None = None, + memory_path: Path | None = None, ) -> None: """Run the interactive read-eval-print loop.""" from qracer.alert_monitor import AlertMonitor @@ -515,6 +519,19 @@ async def _repl_loop( click.echo("No analysis to save. Run a query first.\n") continue + # MEMORY.md commands + if cmd in ("memory", "memory show", "/memory", "/memory show"): + _handle_memory_show(memory_path) + continue + + if cmd in ("memory refresh", "/memory refresh"): + _handle_memory_refresh(memory_path, fact_store, engine) + continue + + if cmd in ("memory edit", "/memory edit"): + _handle_memory_edit(memory_path) + continue + # Watchlist commands if cmd in ("watchlist", "wl", "/watchlist"): _show_watchlist(watchlist) # type: ignore[arg-type] @@ -710,6 +727,76 @@ def _handle_remove_alert(user_input: str, monitor: object | None) -> None: click.echo(f"No alert found with ID {alert_id}.\n") +# --------------------------------------------------------------------------- +# MEMORY.md helpers +# --------------------------------------------------------------------------- + + +def _handle_memory_show(memory_path: Path | None) -> None: + """Print the current MEMORY.md file content (or a hint if absent).""" + if memory_path is None: + click.echo("MEMORY.md is not configured for this session.\n") + return + if not memory_path.exists(): + click.echo( + f"MEMORY.md does not exist yet at {memory_path}.\n" + "Run 'memory refresh' to generate one from the fact store.\n" + ) + return + try: + click.echo(memory_path.read_text(encoding="utf-8")) + except OSError as exc: + click.echo(f"Could not read MEMORY.md: {exc}\n") + + +def _handle_memory_refresh( + memory_path: Path | None, + fact_store: object | None, + engine: object, +) -> None: + """Regenerate the auto region of MEMORY.md from the fact store.""" + from qracer.memory.fact_store import FactStore + from qracer.memory.memory_file import refresh_memory_file + + if memory_path is None or not isinstance(fact_store, FactStore): + click.echo("MEMORY.md refresh is unavailable (no fact store configured).\n") + return + try: + doc = refresh_memory_file(memory_path, fact_store) + except Exception as exc: + click.echo(f"MEMORY.md refresh failed: {type(exc).__name__}: {exc}\n") + return + engine._memory_doc = doc # type: ignore[attr-defined] + click.echo(f"✓ MEMORY.md refreshed at {memory_path}") + click.echo(f" {doc.summary_line()}\n") + + +def _handle_memory_edit(memory_path: Path | None) -> None: + """Open MEMORY.md in $EDITOR for ad-hoc curation.""" + import subprocess + + if memory_path is None: + click.echo("MEMORY.md is not configured for this session.\n") + return + memory_path.parent.mkdir(parents=True, exist_ok=True) + if not memory_path.exists(): + # Seed the file so the editor opens on the canonical template. + from qracer.memory.memory_file import MemoryDocument, save_memory + + save_memory(MemoryDocument(), memory_path) + + editor = os.environ.get("EDITOR") or shutil.which("nano") or shutil.which("vi") + if not editor: + click.echo( + "No editor available. Set $EDITOR or install nano/vi to use 'memory edit'.\n" + ) + return + try: + subprocess.call([editor, str(memory_path)]) + except OSError as exc: + click.echo(f"Failed to launch editor: {exc}\n") + + def _show_watchlist(watchlist: object) -> None: """Display the current watchlist.""" from qracer.watchlist import Watchlist @@ -968,6 +1055,9 @@ def repl() -> None: task_store = TaskStore(_user_dir() / "tasks.json") + memory_path = _user_dir() / "MEMORY.md" + bootstrap_path = _user_dir() / "BOOTSTRAP.md" + engine = ConversationEngine( llm_registry, data_registry, @@ -979,8 +1069,13 @@ def repl() -> None: memory_searcher=memory_searcher, summaries_dir=summaries_dir, fact_store=fact_store, + memory_path=memory_path, + bootstrap_path=bootstrap_path, ) + if engine.memory_document is not None: + click.echo(f" ✓ {engine.memory_document.summary_line()}") + task_executor = TaskExecutor(task_store, data_registry, llm_registry, engine=engine) asyncio.run( @@ -993,6 +1088,7 @@ def repl() -> None: sessions_dir=sessions_dir, current_session=session_logger.path, fact_store=fact_store, + memory_path=memory_path, ) ) diff --git a/qracer/conversation/engine.py b/qracer/conversation/engine.py index 2d546e5..9e68710 100644 --- a/qracer/conversation/engine.py +++ b/qracer/conversation/engine.py @@ -36,6 +36,13 @@ from qracer.data.registry import DataRegistry from qracer.llm.registry import LLMRegistry from qracer.memory.fact_store import FactStore +from qracer.memory.memory_file import ( + MemoryDocument, + load_bootstrap, + load_memory, + refresh_memory, + save_memory, +) from qracer.memory.memory_searcher import MemorySearcher from qracer.memory.session_compactor import SessionCompactor from qracer.memory.session_logger import SessionLogger, TurnRecord @@ -73,6 +80,8 @@ def __init__( language: str = "en", summaries_dir: Path | None = None, fact_store: FactStore | None = None, + memory_path: Path | None = None, + bootstrap_path: Path | None = None, ) -> None: self._llm = llm_registry self._data = data_registry @@ -82,6 +91,9 @@ def __init__( self._language = language self._summaries_dir = summaries_dir self._fact_store = fact_store + self._memory_path = memory_path + self._bootstrap_path = bootstrap_path + self._memory_doc: MemoryDocument | None = None analysis_loop = AnalysisLoop( llm_registry, @@ -122,6 +134,10 @@ def __init__( self._last_response: EngineResponse | None = None self._config_version = 0 + # Long-term memory: prime the session with MEMORY.md + BOOTSTRAP.md + # before the first user query so synthesizers see them via history. + self._prime_long_term_memory() + def update_registries( self, llm_registry: LLMRegistry, @@ -336,3 +352,64 @@ def _persist_facts(self, analysis: AnalysisResult) -> None: self._fact_store.save_thesis(analysis.trade_thesis, self._session_id) except Exception: logger.warning("Failed to persist thesis to fact store", exc_info=True) + return + self._refresh_memory_file() + + # ------------------------------------------------------------------ + # Long-term memory (MEMORY.md + BOOTSTRAP.md) + # ------------------------------------------------------------------ + + @property + def memory_document(self) -> MemoryDocument | None: + """Most recently loaded MEMORY.md, or ``None`` if not configured.""" + return self._memory_doc + + def _prime_long_term_memory(self) -> None: + """Inject BOOTSTRAP.md and MEMORY.md into the session seed. + + Both files are optional and isolated from the rest of the engine — + any failure is logged but never prevents the session from + starting. Content is pushed onto ``self._history`` as ``system`` + messages so synthesizers and the intent parser see it through the + usual history path. + """ + if self._bootstrap_path is not None: + try: + bootstrap = load_bootstrap(self._bootstrap_path) + except Exception: + logger.warning("Failed to load BOOTSTRAP.md", exc_info=True) + bootstrap = None + if bootstrap: + self._history.append({"role": "system", "content": bootstrap}) + + if self._memory_path is not None: + try: + self._memory_doc = load_memory(self._memory_path) + except Exception: + logger.warning("Failed to load MEMORY.md", exc_info=True) + self._memory_doc = None + if self._memory_doc is not None and ( + self._memory_doc.auto_theses or self._memory_doc.auto_catalysts + ): + from qracer.memory.memory_file import render_memory + + self._history.append( + {"role": "system", "content": render_memory(self._memory_doc)} + ) + + def _refresh_memory_file(self) -> None: + """Regenerate the MEMORY.md auto region from the fact store. + + Called after each successful ``_persist_facts`` so active theses + stay current without user intervention. User-authored sections + are preserved verbatim. + """ + if self._memory_path is None or self._fact_store is None: + return + try: + current = self._memory_doc or load_memory(self._memory_path) + refreshed = refresh_memory(current, self._fact_store) + save_memory(refreshed, self._memory_path) + self._memory_doc = refreshed + except Exception: + logger.warning("Failed to refresh MEMORY.md", exc_info=True) diff --git a/qracer/memory/memory_file.py b/qracer/memory/memory_file.py new file mode 100644 index 0000000..c983b2f --- /dev/null +++ b/qracer/memory/memory_file.py @@ -0,0 +1,324 @@ +"""MEMORY.md / BOOTSTRAP.md — user-curated cross-session long-term memory. + +Bridges the auto-generated Tier 2 summaries with a human-editable document +that keeps active theses, upcoming catalysts, and free-form user notes +visible at the start of every session. + +Format +------ + +MEMORY.md is Markdown with a single machine-managed region delimited by +HTML comment markers:: + + # qracer MEMORY.md + + *Last updated: 2026-04-15T12:34:56+00:00* + + + ## Active Theses + + - **AAPL** (conviction 8/10): Long AAPL on AI tailwinds. ... + + ## Upcoming Catalysts + + - AAPL: AI revenue growth — Q2 2026 + + + + ## Watchpoints + + (User-editable free text. Preserved across refreshes.) + +Everything outside the ``BEGIN:auto`` / ``END:auto`` markers is user +content and is never overwritten by :func:`refresh_memory`. The auto +region is regenerated wholesale from the :class:`FactStore`. + +BOOTSTRAP.md is an even simpler file — its raw text is loaded at session +start and prepended to the session history as a system message, letting +users seed the conversation with long-term preferences ("I'm a long-term +value investor") without editing code. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from qracer.memory.fact_models import PersistedThesis +from qracer.memory.fact_store import FactStore + +logger = logging.getLogger(__name__) + + +AUTO_BEGIN = "" +AUTO_END = "" + +_HEADER = "# qracer MEMORY.md" +_DEFAULT_USER_CONTENT = """\ +## Watchpoints + +_(User-editable. Anything outside the auto block is preserved across refreshes.)_ + +## User Preferences + +- Risk tolerance: +- Preferred sectors: +- Position sizing: + +## Notes + +_Free-text notes live here._ +""" + + +@dataclass +class MemoryDocument: + """In-memory representation of MEMORY.md. + + ``auto_theses`` and ``auto_catalysts`` are lists of fully-rendered + bullet lines (without the leading ``- ``). ``user_content`` is the + raw Markdown that falls outside the machine-managed region — it is + preserved verbatim across refreshes. + """ + + auto_theses: list[str] = field(default_factory=list) + auto_catalysts: list[str] = field(default_factory=list) + user_content: str = _DEFAULT_USER_CONTENT + last_updated: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + + def summary_line(self) -> str: + """One-line summary for session briefings / status output.""" + return ( + f"MEMORY.md: {len(self.auto_theses)} active theses, " + f"{len(self.auto_catalysts)} upcoming catalysts" + ) + + +# ---------------------------------------------------------------------- +# Rendering +# ---------------------------------------------------------------------- + + +def _render_theses_line(thesis: PersistedThesis) -> str: + """Render a single thesis as a MEMORY.md bullet line.""" + catalyst = thesis.catalyst + if thesis.catalyst_date: + catalyst = f"{catalyst} ({thesis.catalyst_date})" + return ( + f"**{thesis.ticker}** (conviction {thesis.conviction}/10): " + f"{thesis.summary.rstrip('.')}. " + f"Entry ${thesis.entry_zone_low:.2f}-${thesis.entry_zone_high:.2f}, " + f"target ${thesis.target_price:.2f}, stop ${thesis.stop_loss:.2f}. " + f"Catalyst: {catalyst}." + ) + + +def _render_catalyst_line(thesis: PersistedThesis) -> str: + """Render a single upcoming catalyst as a MEMORY.md bullet line.""" + date = thesis.catalyst_date or "TBD" + return f"{thesis.ticker}: {thesis.catalyst} — {date}" + + +def render_memory(doc: MemoryDocument) -> str: + """Render a :class:`MemoryDocument` as canonical MEMORY.md text.""" + parts: list[str] = [ + _HEADER, + "", + f"*Last updated: {doc.last_updated.isoformat()}*", + "", + AUTO_BEGIN, + "", + "## Active Theses", + "", + ] + if doc.auto_theses: + parts.extend(f"- {line}" for line in doc.auto_theses) + else: + parts.append("_No open theses yet._") + parts.extend(["", "## Upcoming Catalysts", ""]) + if doc.auto_catalysts: + parts.extend(f"- {line}" for line in doc.auto_catalysts) + else: + parts.append("_No upcoming catalysts within the horizon._") + parts.extend(["", AUTO_END, "", doc.user_content.rstrip(), ""]) + return "\n".join(parts) + + +# ---------------------------------------------------------------------- +# Parsing +# ---------------------------------------------------------------------- + +_AUTO_REGION_RE = re.compile( + rf"{re.escape(AUTO_BEGIN)}(.*?){re.escape(AUTO_END)}", + re.DOTALL, +) + +_LAST_UPDATED_RE = re.compile(r"\*Last updated:\s*([^*]+?)\s*\*") + +_THESES_HEADING = re.compile(r"^##\s+Active Theses\s*$", re.MULTILINE) +_CATALYSTS_HEADING = re.compile(r"^##\s+Upcoming Catalysts\s*$", re.MULTILINE) + + +def _extract_bullets(block: str) -> list[str]: + """Extract bullet-line content (without the leading ``- ``) from *block*.""" + bullets: list[str] = [] + for raw in block.splitlines(): + line = raw.strip() + if line.startswith("- "): + bullets.append(line[2:].strip()) + return bullets + + +def _parse_auto_region(region: str) -> tuple[list[str], list[str]]: + """Split the auto region into ``(theses, catalysts)`` bullet lists.""" + theses_match = _THESES_HEADING.search(region) + catalysts_match = _CATALYSTS_HEADING.search(region) + + theses_block = "" + catalysts_block = "" + if theses_match: + start = theses_match.end() + end = catalysts_match.start() if catalysts_match else len(region) + theses_block = region[start:end] + if catalysts_match: + catalysts_block = region[catalysts_match.end() :] + + return _extract_bullets(theses_block), _extract_bullets(catalysts_block) + + +def parse_memory(text: str) -> MemoryDocument: + """Parse MEMORY.md source text into a :class:`MemoryDocument`. + + Malformed files are tolerated: missing auto region, missing + timestamp, or entirely empty input all yield a sensible default. + """ + if not text.strip(): + return MemoryDocument() + + last_updated = datetime.now(timezone.utc) + m = _LAST_UPDATED_RE.search(text) + if m: + try: + last_updated = datetime.fromisoformat(m.group(1).strip()) + except ValueError: + pass + + auto_match = _AUTO_REGION_RE.search(text) + if auto_match is None: + # No auto region — treat entire body (minus header/timestamp) as + # user content so we don't silently drop anything on a rewrite. + body = text + for pattern in (re.compile(rf"^{re.escape(_HEADER)}\s*$", re.MULTILINE), _LAST_UPDATED_RE): + body = pattern.sub("", body, count=1) + return MemoryDocument( + user_content=body.strip() or _DEFAULT_USER_CONTENT, + last_updated=last_updated, + ) + + auto_theses, auto_catalysts = _parse_auto_region(auto_match.group(1)) + user_content = (text[: auto_match.start()] + text[auto_match.end() :]).strip() + # Strip header + last-updated line from the user content so we don't + # duplicate them when rendering. + user_content = re.sub( + rf"^{re.escape(_HEADER)}\s*$", "", user_content, count=1, flags=re.MULTILINE + ) + user_content = _LAST_UPDATED_RE.sub("", user_content, count=1) + user_content = user_content.strip() or _DEFAULT_USER_CONTENT + + return MemoryDocument( + auto_theses=auto_theses, + auto_catalysts=auto_catalysts, + user_content=user_content, + last_updated=last_updated, + ) + + +# ---------------------------------------------------------------------- +# Persistence + refresh +# ---------------------------------------------------------------------- + + +def load_memory(path: Path) -> MemoryDocument: + """Load a MEMORY.md file, returning a fresh :class:`MemoryDocument` + if the file is missing or unreadable.""" + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return MemoryDocument() + except OSError: + logger.warning("MEMORY.md unreadable at %s", path, exc_info=True) + return MemoryDocument() + return parse_memory(text) + + +def save_memory(doc: MemoryDocument, path: Path) -> None: + """Atomically write *doc* to *path*, creating parent dirs as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(render_memory(doc), encoding="utf-8") + tmp.replace(path) + + +def refresh_memory( + doc: MemoryDocument, + fact_store: FactStore, + *, + catalyst_horizon_days: int = 30, +) -> MemoryDocument: + """Return a new :class:`MemoryDocument` with auto sections regenerated + from *fact_store*. User content and the provided doc are not mutated.""" + open_theses = fact_store.get_open_theses() + upcoming = fact_store.get_upcoming_catalysts(days_ahead=catalyst_horizon_days) + + # ``get_upcoming_catalysts`` only returns theses whose catalyst_date is + # parseable *and* within the horizon. Overlap with open_theses is + # possible but they render differently, so dedup is unnecessary. + + return MemoryDocument( + auto_theses=[_render_theses_line(t) for t in open_theses], + auto_catalysts=[_render_catalyst_line(t) for t in upcoming], + user_content=doc.user_content, + last_updated=datetime.now(timezone.utc), + ) + + +def refresh_memory_file( + path: Path, + fact_store: FactStore, + *, + catalyst_horizon_days: int = 30, +) -> MemoryDocument: + """Convenience: load MEMORY.md, refresh auto sections, write back.""" + current = load_memory(path) + refreshed = refresh_memory( + current, fact_store, catalyst_horizon_days=catalyst_horizon_days + ) + save_memory(refreshed, path) + return refreshed + + +# ---------------------------------------------------------------------- +# BOOTSTRAP.md +# ---------------------------------------------------------------------- + + +def load_bootstrap(path: Path) -> str | None: + """Load BOOTSTRAP.md contents as a system-prompt extension. + + Returns ``None`` when the file is missing, empty, or unreadable so + callers can cheaply skip injection without branching on exceptions. + """ + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except OSError: + logger.warning("BOOTSTRAP.md unreadable at %s", path, exc_info=True) + return None + text = text.strip() + return text or None diff --git a/tests/conversation/test_engine.py b/tests/conversation/test_engine.py index 1e55129..0fdc9c8 100644 --- a/tests/conversation/test_engine.py +++ b/tests/conversation/test_engine.py @@ -935,3 +935,151 @@ async def test_no_crash_without_fact_store(self) -> None: response = await engine.query("AAPL price") assert response.text # Should produce a response without crashing + + +# --------------------------------------------------------------------------- +# MEMORY.md / BOOTSTRAP.md long-term memory +# --------------------------------------------------------------------------- + + +class TestLongTermMemory: + def test_bootstrap_seeds_system_message(self, tmp_path) -> None: + """BOOTSTRAP.md content should be prepended to history as a system + message before the first query so synthesizers can see it.""" + bootstrap = tmp_path / "BOOTSTRAP.md" + bootstrap.write_text("Long-term value investor.", encoding="utf-8") + + llm = _mock_llm_registry({}) + engine = ConversationEngine( + llm, DataRegistry(), bootstrap_path=bootstrap + ) + + system_msgs = [h for h in engine.history if h["role"] == "system"] + assert any("Long-term value investor." in h["content"] for h in system_msgs) + + def test_empty_bootstrap_not_injected(self, tmp_path) -> None: + """A missing BOOTSTRAP.md leaves history untouched.""" + llm = _mock_llm_registry({}) + engine = ConversationEngine( + llm, DataRegistry(), bootstrap_path=tmp_path / "absent.md" + ) + assert engine.history == [] + + def test_memory_file_loaded_into_document(self, tmp_path) -> None: + """Pre-existing MEMORY.md is parsed and exposed via the property.""" + from qracer.memory.memory_file import MemoryDocument, save_memory + + path = tmp_path / "MEMORY.md" + save_memory( + MemoryDocument( + auto_theses=["**AAPL** (conviction 8/10): x."], + user_content="## Notes\n\nhi.", + ), + path, + ) + + llm = _mock_llm_registry({}) + engine = ConversationEngine(llm, DataRegistry(), memory_path=path) + + doc = engine.memory_document + assert doc is not None + assert doc.auto_theses == ["**AAPL** (conviction 8/10): x."] + assert "hi." in doc.user_content + + def test_memory_with_theses_seeded_into_history(self, tmp_path) -> None: + """A non-empty MEMORY.md is injected as a system message so it + shows up in the synthesizer's context window.""" + from qracer.memory.memory_file import MemoryDocument, save_memory + + path = tmp_path / "MEMORY.md" + save_memory( + MemoryDocument(auto_theses=["**AAPL** (conviction 8/10): x."]), + path, + ) + + llm = _mock_llm_registry({}) + engine = ConversationEngine(llm, DataRegistry(), memory_path=path) + + system_msgs = [h for h in engine.history if h["role"] == "system"] + assert any("**AAPL**" in h["content"] for h in system_msgs) + + def test_empty_memory_not_injected(self, tmp_path) -> None: + """A MEMORY.md with no auto entries should not leak the boilerplate + system message.""" + llm = _mock_llm_registry({}) + engine = ConversationEngine( + llm, DataRegistry(), memory_path=tmp_path / "absent.md" + ) + # load_memory returns a default doc, but no auto content → no inject. + assert engine.history == [] + + async def test_memory_refreshed_after_thesis_persisted( + self, tmp_path + ) -> None: + """When a query persists a thesis, MEMORY.md's auto region should be + regenerated in place with the new thesis visible.""" + from qracer.conversation.handlers import HandlerResult + from qracer.memory.fact_store import FactStore + from qracer.memory.memory_file import load_memory + from qracer.models.base import TradeThesis + + fact_store = FactStore() + memory_path = tmp_path / "MEMORY.md" + + intent_resp = json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}) + llm = _mock_llm_registry( + {Role.RESEARCHER: intent_resp, Role.STRATEGIST: "Response"} + ) + engine = ConversationEngine( + llm, + DataRegistry(), + fact_store=fact_store, + memory_path=memory_path, + ) + + thesis = TradeThesis( + ticker="AAPL", + entry_zone=(170.0, 175.0), + target_price=200.0, + stop_loss=160.0, + risk_reward_ratio=2.4, + catalyst="AI revenue", + catalyst_date="Q2 2026", + conviction=8, + summary="Long AAPL on AI", + ) + analysis = AnalysisResult( + results=[_ok_result("price_event")], + confidence=0.9, + iterations=1, + trade_thesis=thesis, + ) + with patch.object( + engine._standard_handler, "handle", new=AsyncMock() + ) as mh: + mh.return_value = HandlerResult(text="Response", analysis=analysis) + await engine.query("Analyze AAPL") + + assert memory_path.exists() + doc_on_disk = load_memory(memory_path) + assert any("AAPL" in line for line in doc_on_disk.auto_theses) + # Engine's cached doc should also reflect the refresh. + assert engine.memory_document is not None + assert any("AAPL" in line for line in engine.memory_document.auto_theses) + + fact_store.close() + + def test_malformed_memory_file_does_not_crash_init( + self, tmp_path + ) -> None: + """Broken MEMORY.md must not prevent engine construction.""" + path = tmp_path / "MEMORY.md" + path.write_bytes(b"\x00\x01\x02 not valid utf-8 anywhere \xff\xfe") + + llm = _mock_llm_registry({}) + # Should not raise; the engine falls back to an empty doc / no doc. + engine = ConversationEngine(llm, DataRegistry(), memory_path=path) + # Either None (OSError path) or a default MemoryDocument (parse path) + # — both are acceptable; crashing is not. + doc = engine.memory_document + assert doc is None or doc.auto_theses == [] diff --git a/tests/memory/test_memory_file.py b/tests/memory/test_memory_file.py new file mode 100644 index 0000000..e132041 --- /dev/null +++ b/tests/memory/test_memory_file.py @@ -0,0 +1,284 @@ +"""Tests for MEMORY.md / BOOTSTRAP.md long-term memory helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from qracer.memory.fact_store import FactStore +from qracer.memory.memory_file import ( + AUTO_BEGIN, + AUTO_END, + MemoryDocument, + load_bootstrap, + load_memory, + parse_memory, + refresh_memory, + refresh_memory_file, + render_memory, + save_memory, +) +from qracer.models.base import TradeThesis + + +def _thesis( + ticker: str = "AAPL", + catalyst_date: str | None = None, + conviction: int = 8, +) -> TradeThesis: + return TradeThesis( + ticker=ticker, + entry_zone=(175.0, 180.0), + target_price=200.0, + stop_loss=165.0, + risk_reward_ratio=2.5, + catalyst="AI revenue growth", + catalyst_date=catalyst_date, + conviction=conviction, + summary=f"Long {ticker} on AI tailwinds", + ) + + +@pytest.fixture +def fact_store() -> Iterator[FactStore]: + store = FactStore() # in-memory + try: + yield store + finally: + store.close() + + +# ---------------------------------------------------------------------- +# Render / parse +# ---------------------------------------------------------------------- + + +class TestRenderMemory: + def test_empty_doc_renders_placeholders(self) -> None: + text = render_memory(MemoryDocument()) + assert AUTO_BEGIN in text + assert AUTO_END in text + assert "## Active Theses" in text + assert "_No open theses yet._" in text + assert "_No upcoming catalysts within the horizon._" in text + + def test_theses_and_catalysts_rendered_as_bullets(self) -> None: + doc = MemoryDocument( + auto_theses=["**AAPL** (conviction 8/10): ..."], + auto_catalysts=["AAPL: AI revenue growth — Q2 2026"], + ) + text = render_memory(doc) + assert "- **AAPL** (conviction 8/10): ..." in text + assert "- AAPL: AI revenue growth — Q2 2026" in text + + def test_user_content_appears_after_auto_region(self) -> None: + doc = MemoryDocument(user_content="## Notes\n\nHand-curated thoughts.") + text = render_memory(doc) + auto_idx = text.index(AUTO_END) + user_idx = text.index("Hand-curated thoughts.") + assert user_idx > auto_idx + + +class TestParseMemory: + def test_roundtrip_preserves_all_fields(self) -> None: + original = MemoryDocument( + auto_theses=["**AAPL** (conviction 8/10): summary."], + auto_catalysts=["AAPL: earnings — Q2 2026"], + user_content="## Watchpoints\n\nFed meeting.", + last_updated=datetime(2026, 4, 15, 12, 0, tzinfo=timezone.utc), + ) + parsed = parse_memory(render_memory(original)) + assert parsed.auto_theses == original.auto_theses + assert parsed.auto_catalysts == original.auto_catalysts + assert "Fed meeting." in parsed.user_content + assert parsed.last_updated == original.last_updated + + def test_empty_text_yields_default(self) -> None: + doc = parse_memory("") + assert doc.auto_theses == [] + assert doc.auto_catalysts == [] + assert "Watchpoints" in doc.user_content + + def test_missing_auto_region_preserves_user_body(self) -> None: + text = "# qracer MEMORY.md\n\n## My Notes\n\nArbitrary text.\n" + doc = parse_memory(text) + assert doc.auto_theses == [] + assert "Arbitrary text." in doc.user_content + # Header should not bleed into user content. + assert "# qracer MEMORY.md" not in doc.user_content + + def test_malformed_timestamp_falls_back_to_now(self) -> None: + text = ( + "# qracer MEMORY.md\n\n*Last updated: not-a-date*\n\n" + f"{AUTO_BEGIN}\n## Active Theses\n\n{AUTO_END}\n" + ) + before = datetime.now(timezone.utc) + doc = parse_memory(text) + after = datetime.now(timezone.utc) + assert before <= doc.last_updated <= after + + def test_bullets_outside_theses_heading_ignored(self) -> None: + text = ( + f"{AUTO_BEGIN}\n" + "## Active Theses\n\n- a\n- b\n\n" + "## Upcoming Catalysts\n\n- x\n\n" + f"{AUTO_END}\n" + ) + doc = parse_memory(text) + assert doc.auto_theses == ["a", "b"] + assert doc.auto_catalysts == ["x"] + + def test_user_content_between_auto_and_trailing_sections(self) -> None: + text = ( + "# qracer MEMORY.md\n\n" + f"{AUTO_BEGIN}\n## Active Theses\n\n- a\n\n## Upcoming Catalysts\n\n{AUTO_END}\n\n" + "## Watchpoints\n\nFed May.\n" + ) + doc = parse_memory(text) + assert doc.auto_theses == ["a"] + assert "Fed May." in doc.user_content + + +# ---------------------------------------------------------------------- +# Refresh from FactStore +# ---------------------------------------------------------------------- + + +class TestRefreshMemory: + def test_regenerates_auto_region_from_fact_store( + self, fact_store: FactStore + ) -> None: + fact_store.save_thesis(_thesis("AAPL"), session_id="s1") + fact_store.save_thesis(_thesis("NVDA", conviction=9), session_id="s1") + + doc = refresh_memory(MemoryDocument(), fact_store) + assert len(doc.auto_theses) == 2 + tickers_rendered = " ".join(doc.auto_theses) + assert "**AAPL**" in tickers_rendered + assert "**NVDA**" in tickers_rendered + assert "conviction 9/10" in tickers_rendered + + def test_preserves_user_content(self, fact_store: FactStore) -> None: + fact_store.save_thesis(_thesis("AAPL"), session_id="s1") + original = MemoryDocument(user_content="## Notes\n\nKeep me.") + refreshed = refresh_memory(original, fact_store) + assert refreshed.user_content == "## Notes\n\nKeep me." + + def test_upcoming_catalysts_within_horizon( + self, fact_store: FactStore + ) -> None: + from datetime import timedelta + + near = (datetime.now() + timedelta(days=5)).strftime("%Y-%m-%d") + fact_store.save_thesis(_thesis("AAPL", catalyst_date=near), session_id="s1") + doc = refresh_memory(MemoryDocument(), fact_store, catalyst_horizon_days=30) + assert any("AAPL" in line for line in doc.auto_catalysts) + + def test_catalyst_outside_horizon_excluded( + self, fact_store: FactStore + ) -> None: + from datetime import timedelta + + far = (datetime.now() + timedelta(days=120)).strftime("%Y-%m-%d") + fact_store.save_thesis(_thesis("AAPL", catalyst_date=far), session_id="s1") + doc = refresh_memory(MemoryDocument(), fact_store, catalyst_horizon_days=30) + assert doc.auto_catalysts == [] + + def test_no_theses_yields_empty_auto_lists( + self, fact_store: FactStore + ) -> None: + doc = refresh_memory(MemoryDocument(), fact_store) + assert doc.auto_theses == [] + assert doc.auto_catalysts == [] + + def test_last_updated_bumped(self, fact_store: FactStore) -> None: + old = MemoryDocument( + last_updated=datetime(2020, 1, 1, tzinfo=timezone.utc) + ) + refreshed = refresh_memory(old, fact_store) + assert refreshed.last_updated > old.last_updated + + +# ---------------------------------------------------------------------- +# Persistence +# ---------------------------------------------------------------------- + + +class TestPersistence: + def test_save_load_roundtrip(self, tmp_path: Path) -> None: + doc = MemoryDocument( + auto_theses=["**AAPL** (conviction 8/10): x."], + user_content="## Mine\n\nHello.", + ) + path = tmp_path / "MEMORY.md" + save_memory(doc, path) + loaded = load_memory(path) + assert loaded.auto_theses == doc.auto_theses + assert "Hello." in loaded.user_content + + def test_save_creates_parent_dirs(self, tmp_path: Path) -> None: + path = tmp_path / "nested" / "dir" / "MEMORY.md" + save_memory(MemoryDocument(), path) + assert path.exists() + + def test_load_missing_file_returns_default(self, tmp_path: Path) -> None: + doc = load_memory(tmp_path / "absent.md") + assert doc.auto_theses == [] + assert "Watchpoints" in doc.user_content + + def test_refresh_memory_file_preserves_user_section( + self, tmp_path: Path, fact_store: FactStore + ) -> None: + path = tmp_path / "MEMORY.md" + save_memory( + MemoryDocument(user_content="## Mine\n\nKeep this."), path + ) + fact_store.save_thesis(_thesis("AAPL"), session_id="s1") + refreshed = refresh_memory_file(path, fact_store) + assert "Keep this." in refreshed.user_content + on_disk = path.read_text(encoding="utf-8") + assert "Keep this." in on_disk + assert "**AAPL**" in on_disk + + def test_save_is_atomic(self, tmp_path: Path) -> None: + """``.tmp`` file should not linger after a successful save.""" + path = tmp_path / "MEMORY.md" + save_memory(MemoryDocument(), path) + assert not (tmp_path / "MEMORY.md.tmp").exists() + + +# ---------------------------------------------------------------------- +# BOOTSTRAP.md +# ---------------------------------------------------------------------- + + +class TestLoadBootstrap: + def test_returns_none_when_missing(self, tmp_path: Path) -> None: + assert load_bootstrap(tmp_path / "absent.md") is None + + def test_returns_none_for_empty_file(self, tmp_path: Path) -> None: + path = tmp_path / "BOOTSTRAP.md" + path.write_text(" \n\n", encoding="utf-8") + assert load_bootstrap(path) is None + + def test_returns_stripped_content(self, tmp_path: Path) -> None: + path = tmp_path / "BOOTSTRAP.md" + path.write_text("\nLong-term value investor.\n", encoding="utf-8") + assert load_bootstrap(path) == "Long-term value investor." + + +# ---------------------------------------------------------------------- +# Summary line +# ---------------------------------------------------------------------- + + +class TestSummaryLine: + def test_counts_reflected(self) -> None: + doc = MemoryDocument( + auto_theses=["a", "b", "c"], auto_catalysts=["x", "y"] + ) + assert "3 active theses" in doc.summary_line() + assert "2 upcoming catalysts" in doc.summary_line() From 301ace6cffa0f594711cc2c304fb18ca505e51be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 06:21:59 +0000 Subject: [PATCH 2/2] style: apply ruff format to new memory module + touched files --- qracer/cli.py | 4 +--- qracer/conversation/engine.py | 4 +--- qracer/memory/memory_file.py | 8 ++------ tests/conversation/test_engine.py | 28 +++++++--------------------- tests/memory/test_memory_file.py | 28 +++++++--------------------- 5 files changed, 18 insertions(+), 54 deletions(-) diff --git a/qracer/cli.py b/qracer/cli.py index 1795e5f..44738c6 100644 --- a/qracer/cli.py +++ b/qracer/cli.py @@ -787,9 +787,7 @@ def _handle_memory_edit(memory_path: Path | None) -> None: editor = os.environ.get("EDITOR") or shutil.which("nano") or shutil.which("vi") if not editor: - click.echo( - "No editor available. Set $EDITOR or install nano/vi to use 'memory edit'.\n" - ) + click.echo("No editor available. Set $EDITOR or install nano/vi to use 'memory edit'.\n") return try: subprocess.call([editor, str(memory_path)]) diff --git a/qracer/conversation/engine.py b/qracer/conversation/engine.py index 9e68710..d2d3a40 100644 --- a/qracer/conversation/engine.py +++ b/qracer/conversation/engine.py @@ -393,9 +393,7 @@ def _prime_long_term_memory(self) -> None: ): from qracer.memory.memory_file import render_memory - self._history.append( - {"role": "system", "content": render_memory(self._memory_doc)} - ) + self._history.append({"role": "system", "content": render_memory(self._memory_doc)}) def _refresh_memory_file(self) -> None: """Regenerate the MEMORY.md auto region from the fact store. diff --git a/qracer/memory/memory_file.py b/qracer/memory/memory_file.py index c983b2f..fefc635 100644 --- a/qracer/memory/memory_file.py +++ b/qracer/memory/memory_file.py @@ -87,9 +87,7 @@ class MemoryDocument: auto_theses: list[str] = field(default_factory=list) auto_catalysts: list[str] = field(default_factory=list) user_content: str = _DEFAULT_USER_CONTENT - last_updated: datetime = field( - default_factory=lambda: datetime.now(timezone.utc) - ) + last_updated: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def summary_line(self) -> str: """One-line summary for session briefings / status output.""" @@ -295,9 +293,7 @@ def refresh_memory_file( ) -> MemoryDocument: """Convenience: load MEMORY.md, refresh auto sections, write back.""" current = load_memory(path) - refreshed = refresh_memory( - current, fact_store, catalyst_horizon_days=catalyst_horizon_days - ) + refreshed = refresh_memory(current, fact_store, catalyst_horizon_days=catalyst_horizon_days) save_memory(refreshed, path) return refreshed diff --git a/tests/conversation/test_engine.py b/tests/conversation/test_engine.py index 0fdc9c8..484b770 100644 --- a/tests/conversation/test_engine.py +++ b/tests/conversation/test_engine.py @@ -950,9 +950,7 @@ def test_bootstrap_seeds_system_message(self, tmp_path) -> None: bootstrap.write_text("Long-term value investor.", encoding="utf-8") llm = _mock_llm_registry({}) - engine = ConversationEngine( - llm, DataRegistry(), bootstrap_path=bootstrap - ) + engine = ConversationEngine(llm, DataRegistry(), bootstrap_path=bootstrap) system_msgs = [h for h in engine.history if h["role"] == "system"] assert any("Long-term value investor." in h["content"] for h in system_msgs) @@ -960,9 +958,7 @@ def test_bootstrap_seeds_system_message(self, tmp_path) -> None: def test_empty_bootstrap_not_injected(self, tmp_path) -> None: """A missing BOOTSTRAP.md leaves history untouched.""" llm = _mock_llm_registry({}) - engine = ConversationEngine( - llm, DataRegistry(), bootstrap_path=tmp_path / "absent.md" - ) + engine = ConversationEngine(llm, DataRegistry(), bootstrap_path=tmp_path / "absent.md") assert engine.history == [] def test_memory_file_loaded_into_document(self, tmp_path) -> None: @@ -1007,15 +1003,11 @@ def test_empty_memory_not_injected(self, tmp_path) -> None: """A MEMORY.md with no auto entries should not leak the boilerplate system message.""" llm = _mock_llm_registry({}) - engine = ConversationEngine( - llm, DataRegistry(), memory_path=tmp_path / "absent.md" - ) + engine = ConversationEngine(llm, DataRegistry(), memory_path=tmp_path / "absent.md") # load_memory returns a default doc, but no auto content → no inject. assert engine.history == [] - async def test_memory_refreshed_after_thesis_persisted( - self, tmp_path - ) -> None: + async def test_memory_refreshed_after_thesis_persisted(self, tmp_path) -> None: """When a query persists a thesis, MEMORY.md's auto region should be regenerated in place with the new thesis visible.""" from qracer.conversation.handlers import HandlerResult @@ -1027,9 +1019,7 @@ async def test_memory_refreshed_after_thesis_persisted( memory_path = tmp_path / "MEMORY.md" intent_resp = json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}) - llm = _mock_llm_registry( - {Role.RESEARCHER: intent_resp, Role.STRATEGIST: "Response"} - ) + llm = _mock_llm_registry({Role.RESEARCHER: intent_resp, Role.STRATEGIST: "Response"}) engine = ConversationEngine( llm, DataRegistry(), @@ -1054,9 +1044,7 @@ async def test_memory_refreshed_after_thesis_persisted( iterations=1, trade_thesis=thesis, ) - with patch.object( - engine._standard_handler, "handle", new=AsyncMock() - ) as mh: + with patch.object(engine._standard_handler, "handle", new=AsyncMock()) as mh: mh.return_value = HandlerResult(text="Response", analysis=analysis) await engine.query("Analyze AAPL") @@ -1069,9 +1057,7 @@ async def test_memory_refreshed_after_thesis_persisted( fact_store.close() - def test_malformed_memory_file_does_not_crash_init( - self, tmp_path - ) -> None: + def test_malformed_memory_file_does_not_crash_init(self, tmp_path) -> None: """Broken MEMORY.md must not prevent engine construction.""" path = tmp_path / "MEMORY.md" path.write_bytes(b"\x00\x01\x02 not valid utf-8 anywhere \xff\xfe") diff --git a/tests/memory/test_memory_file.py b/tests/memory/test_memory_file.py index e132041..0e339a9 100644 --- a/tests/memory/test_memory_file.py +++ b/tests/memory/test_memory_file.py @@ -148,9 +148,7 @@ def test_user_content_between_auto_and_trailing_sections(self) -> None: class TestRefreshMemory: - def test_regenerates_auto_region_from_fact_store( - self, fact_store: FactStore - ) -> None: + def test_regenerates_auto_region_from_fact_store(self, fact_store: FactStore) -> None: fact_store.save_thesis(_thesis("AAPL"), session_id="s1") fact_store.save_thesis(_thesis("NVDA", conviction=9), session_id="s1") @@ -167,9 +165,7 @@ def test_preserves_user_content(self, fact_store: FactStore) -> None: refreshed = refresh_memory(original, fact_store) assert refreshed.user_content == "## Notes\n\nKeep me." - def test_upcoming_catalysts_within_horizon( - self, fact_store: FactStore - ) -> None: + def test_upcoming_catalysts_within_horizon(self, fact_store: FactStore) -> None: from datetime import timedelta near = (datetime.now() + timedelta(days=5)).strftime("%Y-%m-%d") @@ -177,9 +173,7 @@ def test_upcoming_catalysts_within_horizon( doc = refresh_memory(MemoryDocument(), fact_store, catalyst_horizon_days=30) assert any("AAPL" in line for line in doc.auto_catalysts) - def test_catalyst_outside_horizon_excluded( - self, fact_store: FactStore - ) -> None: + def test_catalyst_outside_horizon_excluded(self, fact_store: FactStore) -> None: from datetime import timedelta far = (datetime.now() + timedelta(days=120)).strftime("%Y-%m-%d") @@ -187,17 +181,13 @@ def test_catalyst_outside_horizon_excluded( doc = refresh_memory(MemoryDocument(), fact_store, catalyst_horizon_days=30) assert doc.auto_catalysts == [] - def test_no_theses_yields_empty_auto_lists( - self, fact_store: FactStore - ) -> None: + def test_no_theses_yields_empty_auto_lists(self, fact_store: FactStore) -> None: doc = refresh_memory(MemoryDocument(), fact_store) assert doc.auto_theses == [] assert doc.auto_catalysts == [] def test_last_updated_bumped(self, fact_store: FactStore) -> None: - old = MemoryDocument( - last_updated=datetime(2020, 1, 1, tzinfo=timezone.utc) - ) + old = MemoryDocument(last_updated=datetime(2020, 1, 1, tzinfo=timezone.utc)) refreshed = refresh_memory(old, fact_store) assert refreshed.last_updated > old.last_updated @@ -233,9 +223,7 @@ def test_refresh_memory_file_preserves_user_section( self, tmp_path: Path, fact_store: FactStore ) -> None: path = tmp_path / "MEMORY.md" - save_memory( - MemoryDocument(user_content="## Mine\n\nKeep this."), path - ) + save_memory(MemoryDocument(user_content="## Mine\n\nKeep this."), path) fact_store.save_thesis(_thesis("AAPL"), session_id="s1") refreshed = refresh_memory_file(path, fact_store) assert "Keep this." in refreshed.user_content @@ -277,8 +265,6 @@ def test_returns_stripped_content(self, tmp_path: Path) -> None: class TestSummaryLine: def test_counts_reflected(self) -> None: - doc = MemoryDocument( - auto_theses=["a", "b", "c"], auto_catalysts=["x", "y"] - ) + doc = MemoryDocument(auto_theses=["a", "b", "c"], auto_catalysts=["x", "y"]) assert "3 active theses" in doc.summary_line() assert "2 upcoming catalysts" in doc.summary_line()