From be3f820afb00dc21bdc20bbf0fe375c314138834 Mon Sep 17 00:00:00 2001 From: hugocool Date: Sun, 8 Mar 2026 23:28:02 +0100 Subject: [PATCH 1/4] Codex/issue-91-option3-session-constraints - Update .github/ chat modes (architect, ask, code, debug) to AGENTS.md-first pattern; remove MemoriPilot references - Update copilot-instructions.md: AGENTS.md-first directive + working-mode hints - Update blog post: add memory-bank-goes-stale argument + In-Context Learning subtitle - Fix defaults_memory.py: add "graphiti" to _TASK_DEFAULTS_BACKENDS - Fix preferences.py: remove dead delete-based prune_shared_constraints; restore archive-based (issue/81) as the single canonical impl - Fix agent.py: use duplicates_archived key consistent with archive impl - Fix test_planning_reminder_suppression: use date.today() not hardcoded 2026-03-07 - Fix test_task_defaults_memory: migrate mem0 build test to graphiti path - Fix test_timeboxing_constraint_store_canonicalization: direct DB inserts for dirty-state setup; assertions aligned to archive API - Add test_review_commit_template_includes_submit_button_guidance (merged from stash) - Update .env: TIMEBOXING_MEMORY_BACKEND=graphiti ## Linked issue - GitHub issue: - Issue branch: ## Acceptance criteria - [ ] Criterion 1: - [ ] Criterion 2: - [ ] Criterion 3: ## Notebook -> artifact mapping (required for notebook-driven work) - Primary notebook path: - Notebook lifecycle status (`WIP` / `Extraction complete` / `DONE` / `Reference` / `Archived`): - Extracted implementation files (`src/...`): - Extracted test files (`tests/...`): - Extracted docs (`README.md` / `docs/...`): - Intentionally retained notebook-only content (and why): ## Verification performed - [ ] Start-of-work cleanliness check recorded (`git status --porcelain`) - [ ] Pre-PR-close cleanliness check recorded (`git status --porcelain`) - [ ] Relevant automated tests passed - [ ] Notebook checkpoint passed (clean-kernel rerun or CI notebook check) Commands run: ```bash # paste commands used for verification ``` ## System-of-record sync - [ ] Issue status updated - [ ] PR description reflects current implementation status - [ ] Temporary `/tickets/` markdown removed or explicitly retained as durable documentation --- .../timeboxing/graphiti_constraint_memory.py | 136 ++++-------------- src/fateforger/core/README.md | 4 + src/fateforger/core/config.py | 42 ++++-- src/fateforger/core/runtime.py | 54 ++++++- tests/unit/test_runtime_mcp_startup_checks.py | 88 ++++++++++++ tests/unit/test_settings_mcp_endpoints.py | 12 +- 6 files changed, 210 insertions(+), 126 deletions(-) diff --git a/src/fateforger/agents/timeboxing/graphiti_constraint_memory.py b/src/fateforger/agents/timeboxing/graphiti_constraint_memory.py index 1eaf59c..7fc6db7 100644 --- a/src/fateforger/agents/timeboxing/graphiti_constraint_memory.py +++ b/src/fateforger/agents/timeboxing/graphiti_constraint_memory.py @@ -1,112 +1,19 @@ """Graphiti-backed durable constraint memory adapter. -Current implementation provides a local JSON-backed temporal memory substrate -with the same contract as the legacy mem0 adapter so orchestration can switch -backend paths without changing the durable store interface. +This adapter reuses the durable-memory contract from ``Mem0ConstraintMemoryClient`` +but does not provide a local JSON fallback. Runtime configuration must point to a +real Graphiti-compatible backend (cloud or local runtime config). """ from __future__ import annotations import json -from datetime import datetime, timezone -from pathlib import Path -from threading import Lock from typing import Any -from uuid import uuid4 - -from autogen_core.memory import MemoryContent, MemoryQueryResult from fateforger.core.config import settings from .mem0_constraint_memory import Mem0ConstraintMemoryClient - -class _GraphitiLocalMemoryBackend: - """Minimal local backend that mimics memory add/query behavior.""" - - def __init__(self, *, path: str, user_id: str, limit: int) -> None: - self._path = Path(path) - self._path.parent.mkdir(parents=True, exist_ok=True) - self._user_id = str(user_id or "").strip() or "timeboxing" - self._limit = max(1, int(limit)) - self._lock = Lock() - self._rows: list[dict[str, Any]] = [] - self._load() - - @property - def path(self) -> str: - return str(self._path) - - def _load(self) -> None: - if not self._path.exists(): - self._rows = [] - return - try: - payload = json.loads(self._path.read_text(encoding="utf-8")) - except Exception: - payload = [] - self._rows = payload if isinstance(payload, list) else [] - - def _persist(self) -> None: - self._path.write_text( - json.dumps(self._rows, ensure_ascii=True, sort_keys=True), - encoding="utf-8", - ) - - async def add(self, content: MemoryContent) -> None: - """Persist one memory row.""" - metadata = dict(content.metadata or {}) - if not metadata.get("memory_id"): - metadata["memory_id"] = f"graphiti:{uuid4().hex}" - now_iso = datetime.now(timezone.utc).isoformat() - metadata.setdefault("updated_at", now_iso) - metadata.setdefault("user_id", self._user_id) - - row = { - "content": str(content.content or ""), - "mime_type": str(content.mime_type or "text/plain"), - "metadata": metadata, - "created_at": now_iso, - } - with self._lock: - self._rows.append(row) - self._persist() - - async def query(self, query: str, *, limit: int = 50) -> MemoryQueryResult: - """Query memories with deterministic lexical ranking.""" - query_terms = [term for term in str(query or "").lower().split() if term] - row_limit = max(1, int(limit or self._limit)) - with self._lock: - rows = list(self._rows) - scored: list[tuple[int, str, dict[str, Any]]] = [] - for row in rows: - metadata = dict(row.get("metadata") or {}) - row_user = str(metadata.get("user_id") or "").strip() - if row_user and row_user != self._user_id: - continue - blob = ( - str(row.get("content") or "") - + " " - + json.dumps(metadata, ensure_ascii=True, sort_keys=True) - ).lower() - score = 0 - for term in query_terms: - if term in blob: - score += 1 - scored.append((score, str(metadata.get("updated_at") or ""), row)) - scored.sort(key=lambda item: (-item[0], item[1]), reverse=False) - results: list[MemoryContent] = [] - for _, _, row in scored[:row_limit]: - results.append( - MemoryContent( - content=str(row.get("content") or ""), - mime_type=str(row.get("mime_type") or "text/plain"), - metadata=dict(row.get("metadata") or {}), - ) - ) - return MemoryQueryResult(results=results) - - class GraphitiConstraintMemoryClient(Mem0ConstraintMemoryClient): """Graphiti backend using the durable constraint contract.""" @@ -115,32 +22,35 @@ def __init__( *, user_id: str, limit: int = 200, + is_cloud: bool = False, + api_key: str | None = None, local_config: dict[str, Any] | None = None, - memory_backend: Any | None = None, + cloud_url: str = "", ) -> None: - local = dict(local_config or {}) - path = str(local.get("path") or "./data/graphiti_memory.json") - backend = memory_backend or _GraphitiLocalMemoryBackend( - path=path, - user_id=user_id, - limit=limit, - ) super().__init__( user_id=user_id, limit=limit, - is_cloud=False, - local_config=None, - memory_backend=backend, + is_cloud=is_cloud, + api_key=api_key, + local_config=local_config, ) - self._graphiti_path = path + self._graphiti_is_cloud = bool(is_cloud) + self._graphiti_cloud_url = str(cloud_url or "").strip() + self._graphiti_local_config = dict(local_config or {}) async def get_store_info(self) -> dict[str, Any]: - return { + info = { "backend": "graphiti", "user_id": self._user_id, "limit": self._limit, - "path": self._graphiti_path, + "is_cloud": self._graphiti_is_cloud, } + if self._graphiti_is_cloud: + if self._graphiti_cloud_url: + info["cloud_url"] = self._graphiti_cloud_url + elif self._graphiti_local_config: + info["local_config_keys"] = sorted(self._graphiti_local_config.keys()) + return info async def upsert_constraint( self, @@ -159,10 +69,16 @@ def build_graphiti_client_from_settings(*, user_id: str) -> GraphitiConstraintMe getattr(settings, "graphiti_local_config_json", "") or "" ).strip() local_config = json.loads(local_config_raw) if local_config_raw else None + is_cloud = bool(getattr(settings, "graphiti_is_cloud", False)) + api_key = (getattr(settings, "graphiti_api_key", "") or "").strip() or None + cloud_url = (getattr(settings, "graphiti_cloud_url", "") or "").strip() return GraphitiConstraintMemoryClient( user_id=user_id, limit=int(getattr(settings, "graphiti_query_limit", 200) or 200), + is_cloud=is_cloud, + api_key=api_key, local_config=local_config, + cloud_url=cloud_url, ) diff --git a/src/fateforger/core/README.md b/src/fateforger/core/README.md index 92639dc..6617c8e 100644 --- a/src/fateforger/core/README.md +++ b/src/fateforger/core/README.md @@ -1,3 +1,7 @@ # core Runtime startup logs include git provenance fields (`branch`, `commit`, `tag`, `dirty`) to help correlate observed behavior with the exact running code revision. + +Startup now performs explicit dependency gates before agent runtime creation: +- required MCP endpoint discovery (`calendar-mcp` mandatory; optional endpoints logged as warnings), and +- Graphiti runtime readiness when any configured backend path requires Graphiti. diff --git a/src/fateforger/core/config.py b/src/fateforger/core/config.py index c842cf2..f7a7f20 100644 --- a/src/fateforger/core/config.py +++ b/src/fateforger/core/config.py @@ -1,3 +1,4 @@ +import json import os import sys from urllib.parse import urlparse @@ -389,15 +390,40 @@ def _validate_runtime_invariants(self) -> "Settings": "Mem0 backend requires MEM0_LOCAL_CONFIG_JSON or " "(MEM0_IS_CLOUD=1 + MEM0_API_KEY)." ) - if self.timeboxing_memory_backend == "graphiti" and self.graphiti_is_cloud: - has_cloud_runtime = bool( - self.graphiti_api_key.strip() and self.graphiti_cloud_url.strip() - ) - if not has_cloud_runtime: - raise ValueError( - "Graphiti cloud backend requires GRAPHITI_API_KEY and " - "GRAPHITI_CLOUD_URL when GRAPHITI_IS_CLOUD=1." + if self.timeboxing_memory_backend == "graphiti": + if self.graphiti_is_cloud: + has_cloud_runtime = bool( + self.graphiti_api_key.strip() and self.graphiti_cloud_url.strip() ) + if not has_cloud_runtime: + raise ValueError( + "Graphiti cloud backend requires GRAPHITI_API_KEY and " + "GRAPHITI_CLOUD_URL when GRAPHITI_IS_CLOUD=1." + ) + else: + local_config_raw = (self.graphiti_local_config_json or "").strip() + if not local_config_raw: + raise ValueError( + "Graphiti local backend requires GRAPHITI_LOCAL_CONFIG_JSON " + "to point at a DB-backed runtime config." + ) + try: + local_config = json.loads(local_config_raw) + except Exception as exc: + raise ValueError( + "GRAPHITI_LOCAL_CONFIG_JSON must be valid JSON." + ) from exc + if not isinstance(local_config, dict): + raise ValueError( + "GRAPHITI_LOCAL_CONFIG_JSON must decode to a JSON object." + ) + # Guard against the deprecated JSON file fallback artifact mode. + local_path = str(local_config.get("path") or "").strip().lower() + if local_path.endswith(".json"): + raise ValueError( + "Graphiti local config path must not target a JSON file " + "(for example graphiti_memory.json). Use a DB-backed runtime." + ) return self diff --git a/src/fateforger/core/runtime.py b/src/fateforger/core/runtime.py index dac4107..643c88f 100644 --- a/src/fateforger/core/runtime.py +++ b/src/fateforger/core/runtime.py @@ -1,20 +1,13 @@ import asyncio import logging -import os import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Callable from apscheduler.schedulers.asyncio import AsyncIOScheduler from autogen_core import ( AgentId, - DefaultTopicId, - MessageContext, - RoutedAgent, SingleThreadedAgentRuntime, - default_subscription, - message_handler, ) from autogen_core.tool_agent import ToolAgent from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -265,6 +258,52 @@ async def _assert_mcp_servers_available() -> None: logger.info("MCP startup dependency checks passed (%s)", summary) +def _graphiti_backend_required() -> bool: + """Return whether any configured runtime path requires Graphiti availability.""" + timeboxing_backend = str( + getattr(settings, "timeboxing_memory_backend", "") or "" + ).strip().lower() + tasks_backend = str( + getattr(settings, "tasks_defaults_memory_backend", "") or "" + ).strip().lower() + if tasks_backend == "inherit_timeboxing": + tasks_backend = timeboxing_backend + return timeboxing_backend == "graphiti" or tasks_backend == "graphiti" + + +async def _assert_graphiti_runtime_available() -> None: + """Fail fast when Graphiti is configured but runtime dependencies are unavailable.""" + if not _graphiti_backend_required(): + return + try: + from fateforger.agents.timeboxing.durable_constraint_store import ( + build_durable_constraint_store, + ) + from fateforger.agents.timeboxing.graphiti_constraint_memory import ( + build_graphiti_client_from_settings, + ) + + user_id = ( + str(getattr(settings, "graphiti_user_id", "") or "").strip() + or "timeboxing" + ) + client = build_graphiti_client_from_settings(user_id=user_id) + store = build_durable_constraint_store(client) + info = await store.get_store_info() + await store.query_constraints(filters={}, limit=1) + except Exception as exc: + raise RuntimeError( + "Graphiti startup dependency check failed. " + "Resolve Graphiti runtime configuration before starting the app. " + f"{type(exc).__name__}: {exc}" + ) from exc + logger.info( + "Graphiti startup dependency check passed (backend=%s, is_cloud=%s)", + str(info.get("backend") or "unknown"), + bool(info.get("is_cloud")), + ) + + async def _run_initial_planning_reconcile( *, planning_guardian: PlanningGuardian, @@ -311,6 +350,7 @@ async def _create_runtime() -> SingleThreadedAgentRuntime: git_identity.dirty, ) await _assert_mcp_servers_available() + await _assert_graphiti_runtime_available() scheduler = _create_scheduler(settings.database_url) scheduler.start() diff --git a/tests/unit/test_runtime_mcp_startup_checks.py b/tests/unit/test_runtime_mcp_startup_checks.py index e4054ec..9d9b769 100644 --- a/tests/unit/test_runtime_mcp_startup_checks.py +++ b/tests/unit/test_runtime_mcp_startup_checks.py @@ -226,3 +226,91 @@ async def _fake_discover(*, url: str, headers, timeout_s: float) -> list: assert "calendar-mcp" in message # optional server should NOT appear in the hard-failure message assert "notion-mcp" not in message + + +def test_graphiti_backend_required_when_timeboxing_backend_is_graphiti( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + runtime_module.settings, "timeboxing_memory_backend", "graphiti", raising=False + ) + monkeypatch.setattr( + runtime_module.settings, + "tasks_defaults_memory_backend", + "constraint_mcp", + raising=False, + ) + assert runtime_module._graphiti_backend_required() is True # noqa: SLF001 + + +def test_graphiti_backend_required_when_tasks_inherit_graphiti( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + runtime_module.settings, "timeboxing_memory_backend", "graphiti", raising=False + ) + monkeypatch.setattr( + runtime_module.settings, + "tasks_defaults_memory_backend", + "inherit_timeboxing", + raising=False, + ) + assert runtime_module._graphiti_backend_required() is True # noqa: SLF001 + + +async def test_assert_graphiti_runtime_available_noops_when_not_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runtime_module, "_graphiti_backend_required", lambda: False) + await runtime_module._assert_graphiti_runtime_available() # noqa: SLF001 + + +async def test_assert_graphiti_runtime_available_passes_with_queryable_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runtime_module, "_graphiti_backend_required", lambda: True) + + import fateforger.agents.timeboxing.durable_constraint_store as durable_store_mod + import fateforger.agents.timeboxing.graphiti_constraint_memory as graphiti_mod + + class _Store: + async def get_store_info(self) -> dict[str, object]: + return {"backend": "graphiti", "is_cloud": False} + + async def query_constraints(self, *, filters, limit): # noqa: ANN001 + assert isinstance(filters, dict) + assert limit == 1 + return [] + + monkeypatch.setattr( + graphiti_mod, + "build_graphiti_client_from_settings", + lambda *, user_id: object(), + ) + monkeypatch.setattr( + durable_store_mod, + "build_durable_constraint_store", + lambda _client: _Store(), + ) + + await runtime_module._assert_graphiti_runtime_available() # noqa: SLF001 + + +async def test_assert_graphiti_runtime_available_raises_with_clear_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runtime_module, "_graphiti_backend_required", lambda: True) + + import fateforger.agents.timeboxing.graphiti_constraint_memory as graphiti_mod + + def _boom(*, user_id: str) -> object: + raise RuntimeError("graphiti runtime unavailable") + + monkeypatch.setattr(graphiti_mod, "build_graphiti_client_from_settings", _boom) + + with pytest.raises(RuntimeError) as exc_info: + await runtime_module._assert_graphiti_runtime_available() # noqa: SLF001 + + message = str(exc_info.value) + assert "Graphiti startup dependency check failed" in message + assert "graphiti runtime unavailable" in message diff --git a/tests/unit/test_settings_mcp_endpoints.py b/tests/unit/test_settings_mcp_endpoints.py index 1f44da4..aefc5fa 100644 --- a/tests/unit/test_settings_mcp_endpoints.py +++ b/tests/unit/test_settings_mcp_endpoints.py @@ -82,12 +82,22 @@ def test_settings_accepts_graphiti_with_local_runtime_config(monkeypatch) -> Non monkeypatch.setenv("TIMEBOXING_MEMORY_BACKEND", "graphiti") monkeypatch.setenv("GRAPHITI_IS_CLOUD", "false") monkeypatch.setenv( - "GRAPHITI_LOCAL_CONFIG_JSON", "{\"path\":\"./data/graphiti_memory.json\"}" + "GRAPHITI_LOCAL_CONFIG_JSON", "{\"path\":\"./data/graphiti\"}" ) settings = Settings() assert settings.timeboxing_memory_backend == "graphiti" +def test_settings_rejects_graphiti_local_json_file_fallback(monkeypatch) -> None: + monkeypatch.setenv("TIMEBOXING_MEMORY_BACKEND", "graphiti") + monkeypatch.setenv("GRAPHITI_IS_CLOUD", "false") + monkeypatch.setenv( + "GRAPHITI_LOCAL_CONFIG_JSON", "{\"path\":\"./data/graphiti_memory.json\"}" + ) + with pytest.raises(ValueError): + Settings() + + def test_settings_accepts_graphiti_cloud_with_required_fields( monkeypatch, ) -> None: From 63d267e9c0786d114decf279a3d8e9f0428fa262 Mon Sep 17 00:00:00 2001 From: hugocool Date: Tue, 10 Mar 2026 16:51:56 +0100 Subject: [PATCH 2/4] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 785c5ea..8b6847e 100644 --- a/.gitignore +++ b/.gitignore @@ -238,3 +238,4 @@ data/*.db # Logs logs/ *.log +.worktrees/ From 569c442fb41561e093d8a2c9612f7a5415596064 Mon Sep 17 00:00:00 2001 From: hugocool Date: Tue, 10 Mar 2026 17:02:03 +0100 Subject: [PATCH 3/4] feat: Implement session constraint replacement and enhance Graphiti backend integration - Added `replace_session_constraints` method to `ConstraintStore` for replacing session-scope constraints. - Updated `Settings` to enforce Graphiti as the sole memory backend, removing legacy options. - Modified runtime checks to always require Graphiti availability. - Refactored tests to validate new Graphiti backend requirements and ensure proper constraint handling. - Enhanced constraint extraction logic to prioritize session constraints over profile defaults. - Added tests for session constraint replacement and validation of existing constraints in agent payloads. --- .env.template | 28 +- docker-compose.yml | 19 + ...xing-session-constraint-override-design.md | 111 ++ ...-timeboxing-session-constraint-override.md | 199 +++ docs/plans/2026-03-10-d2-story-viewer.md | 1199 +++++++++++++++++ src/fateforger/agents/tasks/README.md | 2 +- .../agents/tasks/defaults_memory.py | 44 +- src/fateforger/agents/timeboxing/README.md | 3 +- src/fateforger/agents/timeboxing/agent.py | 298 +++- .../timeboxing/durable_constraint_store.py | 3 +- .../timeboxing/graphiti_constraint_memory.py | 277 +++- src/fateforger/agents/timeboxing/nlu.py | 6 + .../agents/timeboxing/preferences.py | 44 + src/fateforger/core/config.py | 204 +-- src/fateforger/core/runtime.py | 16 +- tests/unit/test_runtime_mcp_startup_checks.py | 11 +- tests/unit/test_settings_mcp_endpoints.py | 56 +- tests/unit/test_task_defaults_memory.py | 56 +- ...boxing_constraint_extraction_background.py | 9 + .../test_timeboxing_constraint_selection.py | 306 ++++- ...oxing_constraint_store_canonicalization.py | 50 + .../test_timeboxing_durable_constraints.py | 2 +- ...est_timeboxing_memory_backend_selection.py | 22 +- tests/unit/test_timeboxing_stage_actions.py | 1 + .../unit/test_timeboxing_stateless_agents.py | 59 + 25 files changed, 2663 insertions(+), 362 deletions(-) create mode 100644 docs/plans/2026-03-08-timeboxing-session-constraint-override-design.md create mode 100644 docs/plans/2026-03-08-timeboxing-session-constraint-override.md create mode 100644 docs/plans/2026-03-10-d2-story-viewer.md diff --git a/.env.template b/.env.template index c1c93d5..f72143a 100644 --- a/.env.template +++ b/.env.template @@ -36,9 +36,9 @@ OPENAI_MODEL=gpt-4o-mini OPENAI_BASE_URL= # LLM Provider (OpenAI-compatible) -# - openai: uses https://api.openai.com/v1 (default) +# - openai: uses https://api.openai.com/v1 # - openrouter: uses https://openrouter.ai/api/v1 -LLM_PROVIDER=openai +LLM_PROVIDER=openrouter # OpenRouter Configuration (only used when LLM_PROVIDER=openrouter) OPENROUTER_API_KEY=or-your-openrouter-api-key-here @@ -96,19 +96,23 @@ DEVELOPMENT=true SCHEDULER_TIMEZONE=UTC # Durable memory backend selection -# - TIMEBOXING_MEMORY_BACKEND: constraint_mcp|mem0 -# - TASKS_DEFAULTS_MEMORY_BACKEND: constraint_mcp|mem0|disabled|inherit_timeboxing -TIMEBOXING_MEMORY_BACKEND=constraint_mcp -TASKS_DEFAULTS_MEMORY_BACKEND=constraint_mcp +# Single durable memory backend (Graphiti only) +TIMEBOXING_MEMORY_BACKEND=graphiti +TASKS_DEFAULTS_MEMORY_BACKEND=graphiti # Optional fallback cache path when durable task defaults backend is unavailable. TASKS_DEFAULTS_CACHE_PATH=logs/taskmarshal_defaults_cache.json -# Mem0 backend settings (required only when a selected backend is mem0) -MEM0_USER_ID=timeboxing -MEM0_IS_CLOUD=false -MEM0_API_KEY= -MEM0_LOCAL_CONFIG_JSON= -MEM0_QUERY_LIMIT=200 +# Graphiti MCP backend settings (required when a selected backend is graphiti) +GRAPHITI_USER_ID=timeboxing +# Host-run app default. Docker compose overrides this inside the slack-bot container +# to http://graphiti-mcp:8000/mcp. +GRAPHITI_MCP_SERVER_URL=http://localhost:8005/mcp +GRAPHITI_MCP_GROUP_ID=timeboxing +GRAPHITI_MCP_TIMEOUT_SECONDS=15 +GRAPHITI_QUERY_LIMIT=200 +GRAPHITI_MCP_HOST_PORT=8005 +GRAPHITI_LLM_MODEL=google/gemini-3-flash-preview +GRAPHITI_EMBEDDER_MODEL=openai/text-embedding-3-small # AutoGen event logging controls # summary|full|off diff --git a/docker-compose.yml b/docker-compose.yml index ec49312..2bc2dcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,8 @@ services: # Single source of truth: define MCP_CALENDAR_SERVER_URL_DOCKER in `.env` # and pass it through to the app container as MCP_CALENDAR_SERVER_URL. MCP_CALENDAR_SERVER_URL: "${MCP_CALENDAR_SERVER_URL_DOCKER:?Set MCP_CALENDAR_SERVER_URL_DOCKER in .env}" + # Graphiti MCP is reachable via docker service name on the compose network. + GRAPHITI_MCP_SERVER_URL: "http://graphiti-mcp:8000/mcp" volumes: - app_data:/app/data - poetry-cache:/root/.cache/pypoetry @@ -149,6 +151,23 @@ services: restart: "no" networks: - admonish-network + + graphiti-mcp: + container_name: graphiti-mcp + image: zepai/knowledge-graph-mcp:latest + env_file: .env + environment: + # Graphiti uses an OpenAI-compatible client; route it via OpenRouter. + OPENAI_API_KEY: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY in .env for graphiti-mcp}" + OPENAI_API_URL: "${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}" + LLM__MODEL: "${GRAPHITI_LLM_MODEL:-google/gemini-3-flash-preview}" + EMBEDDER__MODEL: "${GRAPHITI_EMBEDDER_MODEL:-openai/text-embedding-3-small}" + ports: + - "${GRAPHITI_MCP_HOST_PORT:-8005}:8000" + networks: + - admonish-network + restart: unless-stopped + ticktick-mcp-auth: <<: *common profiles: ["ticktick", "auth"] # ← only runs when you ask for it diff --git a/docs/plans/2026-03-08-timeboxing-session-constraint-override-design.md b/docs/plans/2026-03-08-timeboxing-session-constraint-override-design.md new file mode 100644 index 0000000..4cd5678 --- /dev/null +++ b/docs/plans/2026-03-08-timeboxing-session-constraint-override-design.md @@ -0,0 +1,111 @@ +# Timeboxing Session Constraint Override Design + +## Goal + +Make same-day explicit session facts override conflicting profile/default memory, including conditional defaults such as "go to the gym unless another sport is already planned or explicitly stated for that day." + +## Problem + +The current Graphiti-backed baseline can canonicalize and filter durable constraints, but it still selects defaults too aggressively when same-day context should suppress them. In practice, a profile/default activity preference can remain active even after the user has stated a conflicting same-day activity or the calendar already anchors that day around another activity. + +## Design Summary + +Keep the fix inside the timeboxing memory-selection path. Reuse existing `hints.aspect_classification` metadata and teach stage-context reconciliation to evaluate conditional applicability before family-level selection. + +This keeps the planner from seeing avoidable noise and avoids introducing any new regex or substring heuristics. The storage model from `#81` remains intact; this is a selection-time precedence and suppression fix. + +## Existing Seams + +- `src/fateforger/agents/timeboxing/nlu.py` + - The interpreter prompt already defines: + - `is_conditional` + - `conditional_on_absent` + - `conditional_on_present` + - `excludes_aspect_ids` +- `src/fateforger/agents/timeboxing/agent.py` + - `_collect_constraints()` computes: + - raw active constraints + - applicable active constraints + - selected active constraints + - `_reconcile_constraints_for_stage_context()` groups constraints into relevance families and currently picks a winner without evaluating cross-aspect suppression. + - `_collect_session_aspect_ids()` already extracts explicit same-session aspect IDs. +- `src/fateforger/agents/timeboxing/constraint_reconciliation.py` + - Canonicalizes durable rows and applies date/stage applicability. + - Not the right place for session-only precedence logic. + +## Recommended Approach + +### 1. Build same-day aspect context + +Before family reconciliation, derive a set of active aspect IDs that represent stronger same-day facts. This set should include: + +- session-scoped constraint aspect IDs +- aspect IDs from already applicable same-day constraints +- explicit blockers surfaced through `excludes_aspect_ids` + +This context is only for the current turn/session and should not mutate stored durable records. + +### 2. Evaluate conditional applicability before reconciliation + +Add a conditional-applicability filter that checks `hints.aspect_classification`: + +- if `conditional_on_present` is non-empty, require one of those aspect IDs to be active +- if `conditional_on_absent` is non-empty, suppress the candidate when any listed aspect ID is active +- if `excludes_aspect_ids` overlaps the active aspect set, suppress the candidate unless the candidate itself is the stronger session-scoped fact + +This should happen before selecting one winner from a relevance family. + +### 3. Preserve family reconciliation, but only among eligible candidates + +Keep `_constraint_relevance_family_key()` and `_constraint_rank_for_stage_reconciliation()`, but apply them only to constraints that remain eligible after conditional suppression. + +If an entire family is suppressed, it should disappear from `session.active_constraints`. + +### 4. Make suppression observable + +Extend the existing `constraints_active_snapshot` debug event with deterministic fields: + +- `active_suppressed_count` +- `active_suppressed_reasons` +- optional preview of suppressed names/reasons for the first few candidates + +This preserves auditability without changing user-facing Slack copy in this ticket. + +## Ownership Boundaries + +- `agent.py` + - owns session-time precedence, suppression, and active-constraint selection +- `constraint_reconciliation.py` + - continues to own durable-row canonicalization and basic day/stage applicability +- `nlu.py` + - already defines the metadata contract; only prompt wording changes are needed if tests show coverage gaps + +## Non-goals + +- deduplicating dual extraction per Refine turn (`#104`) +- Graphiti deployment/runtime DB audit (`#90`) +- broader cross-agent memory redesign (`#64`) +- changing durable storage schema or adding migrations + +## Testing Strategy + +Add targeted regressions around selection semantics: + +1. Session override +- A session-scoped explicit activity beats a conflicting profile/default activity. + +2. Conditional suppression +- A profile/default `gym_training` preference is suppressed when another same-day sport is explicit. + +3. Non-conflict preservation +- The same gym preference remains eligible when no blocker exists. + +4. Observability +- The session debug snapshot exposes suppression counts and reasons deterministically. + +## Risks + +- Over-suppressing constraints if family/category rules are too broad. +- Hidden coupling between current family keys and the new active-aspect context. + +The mitigation is to keep the first pass narrow: drive suppression only from explicit `aspect_classification` metadata already present on candidates, and prove behavior with focused unit tests before broadening the model. diff --git a/docs/plans/2026-03-08-timeboxing-session-constraint-override.md b/docs/plans/2026-03-08-timeboxing-session-constraint-override.md new file mode 100644 index 0000000..51e4f17 --- /dev/null +++ b/docs/plans/2026-03-08-timeboxing-session-constraint-override.md @@ -0,0 +1,199 @@ +# Timeboxing Session Constraint Override Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make same-day explicit session constraints suppress conflicting default/profile memory in the timeboxing agent. + +**Architecture:** Keep the fix in the timeboxing constraint-selection path. Reuse existing `aspect_classification` metadata to compute same-day active aspect context, suppress conditionally inapplicable defaults, then run the existing family-reconciliation ranking on the remaining candidates. + +**Tech Stack:** Python 3.11, pytest, SQLModel, Pydantic, AutoGen timeboxing agent + +--- + +### Task 1: Add Failing Regressions For Session Override And Conditional Suppression + +**Files:** +- Modify: `tests/unit/test_timeboxing_constraint_selection.py` +- Reference: `src/fateforger/agents/timeboxing/agent.py` + +**Step 1: Write the failing tests** + +Add tests that cover: + +```python +async def test_session_activity_overrides_conflicting_profile_default() -> None: + ... + +async def test_conditional_default_is_suppressed_by_same_day_activity() -> None: + ... + +async def test_conditional_default_remains_when_no_blocker_exists() -> None: + ... +``` + +Each test should build `Constraint` objects with `hints["aspect_classification"]` metadata and call `_collect_constraints()` or `_reconcile_constraints_for_stage_context()` through a minimal `TimeboxingFlowAgent` fixture pattern already used in this test module. + +**Step 2: Run test to verify it fails** + +Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q` + +Expected: FAIL on the new override/suppression assertions because current reconciliation does not honor conditional activity blockers. + +**Step 3: Write minimal implementation** + +Do not implement yet; move to Task 2. + +**Step 4: Run test to verify it still isolates the gap** + +Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q` + +Expected: the new tests fail and existing tests remain meaningful. + +**Step 5: Commit** + +Do not commit in this repo unless explicitly requested by the user. + +### Task 2: Implement Same-Day Conditional Suppression In Constraint Selection + +**Files:** +- Modify: `src/fateforger/agents/timeboxing/agent.py` +- Reference: `src/fateforger/agents/timeboxing/preferences.py` +- Reference: `src/fateforger/agents/timeboxing/nlu.py` + +**Step 1: Add helper functions for active aspect context** + +Add focused helpers in `agent.py` for: + +```python +def _constraint_excluded_aspect_ids(constraint: Constraint) -> set[str]: + ... + +def _constraint_conditional_present_ids(constraint: Constraint) -> set[str]: + ... + +def _constraint_conditional_absent_ids(constraint: Constraint) -> set[str]: + ... + +def _collect_active_aspect_ids(constraints: list[Constraint]) -> set[str]: + ... +``` + +These helpers should read only structured metadata from `hints["aspect_classification"]`. + +**Step 2: Apply conditional suppression before family reconciliation** + +Update `_reconcile_constraints_for_stage_context()` so it: + +```python +active_aspect_ids = self._collect_active_aspect_ids(constraints) +eligible, suppressed = self._suppress_conditionally_inapplicable_constraints( + session=session, + constraints=constraints, + active_aspect_ids=active_aspect_ids, +) +``` + +Then reconcile `eligible` by family/rank. + +**Step 3: Preserve stronger session facts** + +Ensure session-scoped explicit constraints are not suppressed by weaker profile/default constraints. Scope precedence should remain: + +- `SESSION` +- `DATESPAN` +- `PROFILE` + +When a conflict exists, the lower-precedence candidate should be suppressed or lose reconciliation. + +**Step 4: Run targeted tests** + +Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q` + +Expected: PASS for the new regressions and the existing selection tests. + +**Step 5: Commit** + +Do not commit in this repo unless explicitly requested by the user. + +### Task 3: Add Deterministic Debug Evidence For Suppression + +**Files:** +- Modify: `src/fateforger/agents/timeboxing/agent.py` +- Test: `tests/unit/test_timeboxing_constraint_selection.py` + +**Step 1: Write the failing observability assertion** + +Add or extend a test asserting that the `constraints_active_snapshot` event contains suppression evidence, for example: + +```python +assert snapshots[-1]["active_suppressed_count"] == 1 +assert snapshots[-1]["active_suppressed_reasons"] == ["excluded_by_aspect"] +``` + +**Step 2: Run test to verify it fails** + +Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q` + +Expected: FAIL because the snapshot does not yet expose suppression fields. + +**Step 3: Implement the minimal debug fields** + +Add deterministic suppression fields to `_collect_constraints()` / reconciliation helpers and keep values bounded and structured. + +**Step 4: Run test to verify it passes** + +Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q` + +Expected: PASS. + +**Step 5: Commit** + +Do not commit in this repo unless explicitly requested by the user. + +### Task 4: Run The Broader Targeted Validation Subset + +**Files:** +- Test: `tests/unit/test_timeboxing_constraint_selection.py` +- Test: `tests/unit/test_timeboxing_durable_constraints.py` +- Test: `tests/unit/test_constraint_retriever.py` +- Test: `tests/unit/test_timeboxing_memory_backend_selection.py` + +**Step 1: Run the focused regression suite** + +Run: + +```bash +poetry run pytest \ + tests/unit/test_timeboxing_constraint_selection.py \ + tests/unit/test_timeboxing_durable_constraints.py \ + tests/unit/test_constraint_retriever.py \ + tests/unit/test_timeboxing_memory_backend_selection.py \ + -q +``` + +Expected: PASS. + +**Step 2: Inspect for unintended behavioral drift** + +Review failures or changed assertions for: +- active count semantics +- durable stage prefetch behavior +- Graphiti backend selection + +**Step 3: Update issue checkpoint** + +Post a progress checkpoint to issue `#91` with: +- tests run +- current status +- remaining risks +- `Open Items` + +**Step 4: Re-run cleanliness check** + +Run: `git status --porcelain` + +Expected: only intended files for `#91` are modified. + +**Step 5: Commit** + +Do not commit in this repo unless explicitly requested by the user. diff --git a/docs/plans/2026-03-10-d2-story-viewer.md b/docs/plans/2026-03-10-d2-story-viewer.md new file mode 100644 index 0000000..d9c371a --- /dev/null +++ b/docs/plans/2026-03-10-d2-story-viewer.md @@ -0,0 +1,1199 @@ +# d2-story-viewer Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a self-contained npm package that turns a D2 diagram + a YAML narration sidecar into an interactive step-by-step story — as a browser-embeddable JS lib, and as a CLI that emits static HTML. + +**Architecture:** The viewer library (already exists as compiled JS in `logic-to-visual/prototype/`) is ported to TypeScript source in a new `logic-to-visual/package/` directory. A sidecar `.story.yaml` file holds all narration content; D2 files are never modified, but may optionally contain `# @step` comments that `d2story init` can extract to scaffold the sidecar. The CLI shells out to `d2` to render SVG, then inlines it into an HTML template with the viewer and narration embedded. + +**Tech Stack:** TypeScript, `js-yaml`, `commander`, `vitest`, `esbuild` (browser bundle), `tsc` (lib types) + +--- + +## Chunk 1: Package scaffold + viewer TypeScript source + +### Task 1: Package scaffold + +**Files:** +- Create: `logic-to-visual/package/package.json` +- Create: `logic-to-visual/package/tsconfig.json` +- Create: `logic-to-visual/package/.gitignore` + +- [ ] **Step 1: Create directory structure** + +```bash +mkdir -p logic-to-visual/package/src/viewer +mkdir -p logic-to-visual/package/src/story +mkdir -p logic-to-visual/package/src/cli +mkdir -p logic-to-visual/package/templates +mkdir -p logic-to-visual/package/tests +``` + +- [ ] **Step 2: Write `package.json`** + +```json +{ + "name": "d2-story-viewer", + "version": "0.1.0", + "description": "Turn D2 diagrams into narrated interactive stories", + "type": "module", + "main": "./dist/viewer/index.js", + "types": "./dist/viewer/index.d.ts", + "exports": { + ".": { + "import": "./dist/viewer/index.js", + "types": "./dist/viewer/index.d.ts" + }, + "./story": { + "import": "./dist/story/index.js", + "types": "./dist/story/index.d.ts" + } + }, + "bin": { + "d2story": "./dist/cli/index.js" + }, + "scripts": { + "build": "tsc && node esbuild.mjs", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "commander": "^12.0.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "esbuild": "^0.21.0", + "typescript": "^5.4.0", + "vitest": "^1.5.0" + } +} +``` + +- [ ] **Step 3: Write `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} +``` + +- [ ] **Step 4: Write `esbuild.mjs`** (bundles viewer for browser ` + + + +``` + +**Note:** For the fully self-contained `--inline` mode, `{{VIEWER_BUNDLE_PATH}}` is replaced with an inline `