Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions docs/memory-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- BEGIN:auto -->` / `<!-- END:auto -->`) 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*

<!-- BEGIN:auto -->
## 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

<!-- END:auto -->

## 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.
96 changes: 95 additions & 1 deletion qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -710,6 +727,74 @@ 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
Expand Down Expand Up @@ -968,6 +1053,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,
Expand All @@ -979,8 +1067,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(
Expand All @@ -993,6 +1086,7 @@ def repl() -> None:
sessions_dir=sessions_dir,
current_session=session_logger.path,
fact_store=fact_store,
memory_path=memory_path,
)
)

Expand Down
75 changes: 75 additions & 0 deletions qracer/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -336,3 +352,62 @@ 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)
Loading
Loading