From 53015bf8ea0c1ab8c21dbf02eb66df817cd5f6bd Mon Sep 17 00:00:00 2001 From: Dave Robertson Date: Wed, 26 Aug 2026 05:14:49 -0400 Subject: [PATCH] feat: implement project context homepage and BRAIN --- .gitignore | 1 + CLAUDE.md | 11 +- INTEGRATION.md | 16 +- README.md | 31 +- app.py | 828 ++++++++++++++++-- docs/EXECUTABLE_PROMPT_SERIES.md | 22 +- project_context.py | 1207 +++++++++++++++++++++++++++ scripts/project_context_cli.py | 162 ++++ static/app.js | 848 ++++++++++++++++++- static/index.html | 128 ++- static/style.css | 620 +++++++++++++- static/vendor/gsap/NOTICE.md | 9 + static/vendor/gsap/gsap.min.js | 10 + static/vendor/lucide/LICENSE | 41 + static/vendor/lucide/lucide.svg | 28 + tests/test_api_contracts.py | 2 + tests/test_project_context.py | 367 ++++++++ tests/test_project_context_cli.py | 56 ++ tests/test_security_and_frontend.py | 31 + 19 files changed, 4309 insertions(+), 109 deletions(-) create mode 100644 project_context.py create mode 100755 scripts/project_context_cli.py create mode 100644 static/vendor/gsap/NOTICE.md create mode 100644 static/vendor/gsap/gsap.min.js create mode 100644 static/vendor/lucide/LICENSE create mode 100644 static/vendor/lucide/lucide.svg create mode 100644 tests/test_project_context.py create mode 100644 tests/test_project_context_cli.py diff --git a/.gitignore b/.gitignore index de3b742..d1911ab 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules/ dist/ .DS_Store tmp_audio_* +project_uploads/ diff --git a/CLAUDE.md b/CLAUDE.md index 76ad5be..0120202 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,15 @@ ## Product -DaveLLM is a FastAPI router with an Electron and browser UI for authenticated chat against configured Ollama nodes. The repository preserves conversation and project JSON semantics, SQLite vector/feedback/performance stores, streamed chat, attachments, export, monitoring, and optional local tools. +DaveLLM is a FastAPI router with an Electron and browser UI for authenticated chat against configured Ollama nodes. The repository preserves conversation and core project JSON semantics, normalized SQLite project context plus vector/feedback/performance stores, streamed chat, attachments, export, monitoring, and optional local tools. ## Source layout - `app.py`: FastAPI routes, persistence, inventory, chat, tools, monitoring +- `project_context.py`: normalized Project Homepage storage, BRAIN revisions, file/artifact retrieval, and bounded request assembly - `tool_executor.py`: runtime tool registry, schema validation, timing, approval boundaries, and bounded executor loop - `static/`: runtime HTML, CSS, JavaScript, monitoring, favicon +- `static/vendor/`: pinned browser-only Lucide and GSAP assets with their license notices; no CDN runtime path - `desktop/`: Electron main process and preload bridge - `scripts/macos/`: Keychain-backed launcher and local app installer; node addresses are resolved from live Tailscale state - `tests/`: source-aligned FastAPI, mocked Ollama transport, security, and renderer contract tests @@ -27,6 +29,7 @@ DaveLLM is a FastAPI router with an Electron and browser UI for authenticated ch - The agent loop defaults to eight model steps, returns partial transcripts, and requires per-run approval for mutating or execution tools. - Global, project, and session instruction layers are visible in the UI and resolve into one exact primary system message. - Project notepads are plain text in project persistence. Do not add rich text, history, collaboration, or browser note storage. +- Existing-chat project changes must use the explicit attachment endpoint and apply only to future messages. ## Ollama integration @@ -39,10 +42,12 @@ Every runtime persistence path is based on `BASE_DIR`, which is derived from `DA - `dave_conversations.json` - `dave_projects.json` - `dave_settings.json` +- `dave_project_context.db` - `dave_vectors.db` - `feedback.db` - `performance.db` - `cost_log.jsonl` +- `project_uploads/` Do not import, move, or infer legacy model or data locations. @@ -64,7 +69,7 @@ GitHub Actions enforces these checks on every push to `main` and every pull requ Required checks after relevant changes: ```bash -python -m py_compile app.py +python -m py_compile app.py project_context.py scripts/project_context_cli.py python -m pytest -q node --check static/app.js static/prompt-contract.js desktop/main.js desktop/preload.js bash -n deploy/check-cluster.sh scripts/verify-cluster.sh @@ -78,4 +83,4 @@ git diff --check - Real node reachability, installed model inventory, inference quality, Whisper execution, and hardware performance require cluster access and are not proven by repository tests. - Electron is pinned to `^44.0.0` (upgraded from `^30.0.0` per audit finding H-1, 2026-08-26). npm audit reports no known vulnerabilities at this line; keep the pin on a supported major. - FastAPI startup/shutdown event deprecation warnings are known; a lifespan migration is deferred because it is outside the P0 stabilization scope. -- JSON conversation/project persistence is preserved. A SQLite migration is deferred. +- Conversation JSON and core project metadata remain compatible. Project Instructions, BRAIN revisions, uploaded-file indexes, and artifact history are normalized in `dave_project_context.db`; a full conversation migration remains deferred. diff --git a/INTEGRATION.md b/INTEGRATION.md index 10876c1..0bf761e 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -116,12 +116,24 @@ Accepts a message array, inventory-backed node and model, step ceiling, error bu `GET /projects/{project_id}/notepad` returns the project-scoped plain text. `PUT` autosaves a bounded `content` string. The notepad does not create artifact history, rich text, or browser-persisted note copies. +### Project Homepage and context contracts + +`GET /projects/{project_id}/homepage` returns the project record, attached conversation IDs, exact baseline quotas, usage, and four independently owned components: Project Instructions, File Context Uploads, Artifact History, and BRAIN. + +- `POST /projects/{project_id}/files`, plus file `PUT`, `reindex`, and `DELETE` routes, own local upload, status, attach/detach, reindex, and deletion. Only attached, successfully indexed UTF-8 text chunks are eligible for requests. +- Artifact list/get/update/delete routes own retained assistant outputs. Attached-chat responses are captured automatically; pinning affects retrieval priority and archiving removes an artifact from request context. +- BRAIN get/update/delete, compact, revisions, and restore routes own tiered durable context. Updates use optimistic revision checks; deletion is soft until the configured recovery window expires. +- `POST /projects/{project_id}/context-preview` returns the exact assembled next-request messages and budget without calling a model or mutating the conversation. +- `PUT /conversations/{conversation_id}/project` is the only route that reattaches an existing chat. It records a future-only context event. A `null` project creates a General chat context. + +The request allocator reserves model output, a five-percent safety margin, and non-project history first. Its baseline project split is 25 percent instructions, 25 percent BRAIN, 30 percent files, and 20 percent artifacts. Unused tokens roll forward to BRAIN, files, then artifacts. The payload order is one exact primary system prompt containing global, project, and session instruction layers; BRAIN; ranked file context; ranked artifact history; bounded conversation history; and the current user message. + ## Persistence and static serving -All persistence artifacts, including `dave_settings.json`, resolve under `DAVE_DATA_DIR`, with the current directory retained as the unset default. Runtime UI files are served only from `static/`. Requests for source, `.git`, JSON, SQLite, and log paths return `404` unless a separately declared API route owns the path. +All persistence artifacts, including `dave_settings.json`, `dave_project_context.db`, and `project_uploads/`, resolve under `DAVE_DATA_DIR`, with the current directory retained as the unset default. Runtime UI files are served only from `static/`. Requests for source, `.git`, JSON, SQLite, and log paths return `404` unless a separately declared API route owns the path. ## Verified versus runtime-dependent -Automated tests verify auth states, static isolation, Ollama-compatible inventory and chat transports, stream success and failure events, templates, export, tools default-off behavior, title generation, raw embedding indexes, exact effective-prompt construction, and data-directory containment. +Automated tests verify auth states, static isolation, Ollama-compatible inventory and chat transports, stream success and failure events, templates, export, tools default-off behavior, title generation, raw embedding indexes, exact effective-prompt construction, Project Homepage lifecycles, request-context order and preview, BRAIN compaction/recovery, token rollover, and data-directory containment. Real cluster reachability, actual model inventory, Whisper binaries, inference performance, and end-to-end hardware behavior remain runtime-dependent and require an authorized cluster check. diff --git a/README.md b/README.md index a3c065b..fb03bba 100644 --- a/README.md +++ b/README.md @@ -84,15 +84,17 @@ The repository intentionally contains no real node addresses or verified model i ## Data and tools -`DAVE_DATA_DIR` relocates all seven persistence artifacts. When unset, the current working directory remains the default. +`DAVE_DATA_DIR` relocates all eight persistence files and the project-upload directory. When unset, the current working directory remains the default. - `dave_conversations.json` - `dave_projects.json` - `dave_settings.json` +- `dave_project_context.db` - `dave_vectors.db` - `feedback.db` - `performance.db` - `cost_log.jsonl` +- `project_uploads/` Tools are disabled by default. To enable them, set `DAVE_ENABLE_TOOLS=true` and provide `DAVE_TOOL_ROOTS` as a JSON array of absolute paths. File read, write, and append operations share the same containment check. `shell.exec` remains disabled unless `DAVE_ENABLE_SHELL_TOOL=true` is also set. `web.fetch` accepts only bounded public HTTP/HTTPS responses and validates DNS plus each redirect target. @@ -104,6 +106,32 @@ The Chat header opens a layered instruction editor. It shows the global default, Completed user and assistant messages expose keyboard-reachable Copy and Add to notepad actions. Copy preserves raw message source. The plain-text notepad persists per project, autosaves, stays inside Chat, can accept a selection from either message role, and can send its full contents as one user message. +## Project Homepage and BRAIN + +Project Home is the inspectable owner for exactly four request-context components: Project Instructions, File Context Uploads, Artifact History, and BRAIN. Its baseline budget is deterministic: 25 percent instructions, 25 percent BRAIN, 30 percent files, and 20 percent artifacts. Unused capacity rolls forward to BRAIN, then files, then artifacts; instructions and protected BRAIN text are rejected instead of silently truncated. Preview context assembles the exact next-request messages without calling a model. + +BRAIN stores pinned facts, active work, and compactable recent context in `dave_project_context.db`. Threshold and explicit compaction create immutable revisions; duplicate, resolved, superseded, and raw tool-log lines can be removed while pinned and active tiers remain verbatim. Delete is recoverable for `DAVE_BRAIN_RECOVERY_DAYS`, after which the daily worker permanently clears old content and starts a fresh revision history. + +The authenticated local CLI uses the running router and `DAVE_API_KEY`: + +```bash +python scripts/project_context_cli.py show +python scripts/project_context_cli.py pin --text 'Decision: verify before release.' +python scripts/project_context_cli.py compact +python scripts/project_context_cli.py revisions +python scripts/project_context_cli.py restore 2 +``` + +Project-context configuration: + +- `DAVE_MODEL_CONTEXT_DEFAULT` — fallback model window, default `32768`. +- `DAVE_MODEL_CONTEXT_WINDOWS` — JSON object of model IDs to context-window tokens. +- `DAVE_PROJECT_CONTEXT_TOKENS` — new-project context budget, default `16384`. +- `DAVE_BRAIN_COMPACT_TOKENS` — new-project compaction threshold, default `3072`. +- `DAVE_BRAIN_RECOVERY_DAYS` — soft-delete recovery window, default `30`. + +The Project Homepage uses a vendored GSAP 3.15.0 core timeline for its precision-control-deck reveal and context-preview feedback. It animates transform and opacity only, switches directly to final states under `prefers-reduced-motion: reduce`, and performs no CDN request. The vendored notice is in `static/vendor/gsap/NOTICE.md`. + ## Local suggestions and mobile navigation The browser client derives at most three deterministic Suggested next actions from the current composer, attachment type, validated project/node/model selection, and response shape. Prediction generation lives in `static/anticipation.js`; it performs no network, DOM, or storage work, and suggestion chips never submit or change context without a visible user action. Valid last-used selections may be restored only on the empty startup state after inventory and node-health checks, with a visible status and immediate Undo. The client no longer calls `/route/decision` while sending a message, so the selected model remains under manual control. @@ -115,6 +143,7 @@ Suggestion preferences use the versioned `davellm_anticipation_v1` local-storage ```bash source venv/bin/activate python -m py_compile app.py +python -m py_compile project_context.py scripts/project_context_cli.py python -m py_compile tool_executor.py python -m pytest -q node --check static/app.js diff --git a/app.py b/app.py index 43d3ab7..efee740 100644 --- a/app.py +++ b/app.py @@ -40,6 +40,14 @@ run_executor_loop, run_tool, ) +from project_context import ( + ContextBudgetError, + ProjectContextError, + ProjectContextStore, + RevisionConflictError, + component_quotas, + estimate_tokens as estimate_project_tokens, +) # ============================================================ # CONSTANTS @@ -55,6 +63,8 @@ PROJECTS_FILE = BASE_DIR / "dave_projects.json" SETTINGS_FILE = BASE_DIR / "dave_settings.json" COST_LOG = BASE_DIR / "cost_log.jsonl" +PROJECT_CONTEXT_DB = BASE_DIR / "dave_project_context.db" +PROJECT_UPLOADS_DIR = BASE_DIR / "project_uploads" BUDGET_DEFAULT = float(os.getenv("DAVE_BUDGET_DEFAULT", "100")) USER_BUDGETS = {} if os.getenv("DAVE_USER_BUDGETS"): @@ -68,6 +78,13 @@ MAX_WEB_FETCH_REDIRECTS = 5 MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB base64 ≈ 3.75MB binary MAX_AUDIO_SIZE = 20 * 1024 * 1024 # 20MB +try: + DEFAULT_MODEL_CONTEXT_WINDOW = int(os.getenv("DAVE_MODEL_CONTEXT_DEFAULT", "32768")) + DEFAULT_PROJECT_CONTEXT_TOKENS = int(os.getenv("DAVE_PROJECT_CONTEXT_TOKENS", "16384")) + DEFAULT_BRAIN_COMPACT_TOKENS = int(os.getenv("DAVE_BRAIN_COMPACT_TOKENS", "3072")) + BRAIN_RECOVERY_DAYS = int(os.getenv("DAVE_BRAIN_RECOVERY_DAYS", "30")) +except ValueError as exc: + raise RuntimeError("DaveLLM token and recovery settings must be integers") from exc WHISPER_BIN = Path(os.getenv("DAVE_WHISPER_BIN", "./whisper.cpp/build/bin/whisper-cli")) WHISPER_MODEL = Path(os.getenv("DAVE_WHISPER_MODEL", "./whisper.cpp/models/ggml-tiny.en.bin")) FFMPEG_BIN = os.getenv("FFMPEG_BIN") or shutil.which("ffmpeg") or "/opt/homebrew/bin/ffmpeg" @@ -124,9 +141,28 @@ def _load_tool_roots() -> List[Path]: return [] +def _load_model_context_windows() -> Dict[str, int]: + raw = os.getenv("DAVE_MODEL_CONTEXT_WINDOWS", "{}") + try: + values = json.loads(raw) + if not isinstance(values, dict): + raise ValueError("must be a JSON object") + windows = {str(key): int(value) for key, value in values.items()} + if any(value < 4_096 for value in windows.values()): + raise ValueError("every model context window must be at least 4,096 tokens") + return windows + except (TypeError, ValueError, json.JSONDecodeError) as exc: + import logging as _logging + _logging.getLogger("dave_llm").warning( + "Ignoring invalid DAVE_MODEL_CONTEXT_WINDOWS configuration: %s", exc + ) + return {} + + TOOLS_ENABLED = _env_flag("DAVE_ENABLE_TOOLS") SHELL_TOOL_ENABLED = _env_flag("DAVE_ENABLE_SHELL_TOOL") TOOL_ROOTS = _load_tool_roots() +MODEL_CONTEXT_WINDOWS = _load_model_context_windows() def track_model_failure(model_id: str, error_type: str): if model_id not in MODEL_HEALTH: @@ -374,6 +410,18 @@ def save_settings(settings: Dict[str, str]): CONVERSATIONS: Dict[str, dict] = load_conversations() PROJECTS: Dict[str, dict] = load_projects() SETTINGS: Dict[str, str] = load_settings() +PROJECT_CONTEXT = ProjectContextStore( + PROJECT_CONTEXT_DB, + PROJECT_UPLOADS_DIR, + default_context_budget=DEFAULT_PROJECT_CONTEXT_TOKENS, + default_brain_threshold=DEFAULT_BRAIN_COMPACT_TOKENS, +) +for _project_id, _project in PROJECTS.items(): + PROJECT_CONTEXT.ensure_project( + _project_id, + instructions=str(_project.get("system_prompt") or ""), + context_budget_tokens=_project.get("context_budget_tokens"), + ) # ============================================================ # FASTAPI SETUP @@ -584,6 +632,14 @@ def get_project(project_id: str, user_id: str) -> dict: raise HTTPException(404, f"Project '{project_id}' not found") if proj.get("user_id", "default") != user_id: raise HTTPException(403, "Forbidden: project not owned by user") + PROJECT_CONTEXT.ensure_project( + project_id, + instructions=str(proj.get("system_prompt") or ""), + context_budget_tokens=proj.get("context_budget_tokens"), + ) + profile = PROJECT_CONTEXT.get_profile(project_id) + proj["system_prompt"] = profile["instructions"] + proj["context_budget_tokens"] = profile["context_budget_tokens"] return proj def list_projects_for_user(user_id: str) -> List[dict]: @@ -593,6 +649,22 @@ def estimate_tokens(text: str) -> int: # Rough heuristic: 1 token ~ 4 chars return max(1, len(text) // 4) + +def get_model_context_window(model_id: str) -> int: + return int(MODEL_CONTEXT_WINDOWS.get(model_id, DEFAULT_MODEL_CONTEXT_WINDOW)) + + +def content_token_count(content) -> int: + if isinstance(content, str): + return estimate_project_tokens(content) + if isinstance(content, list): + return sum( + estimate_project_tokens(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + return estimate_project_tokens(str(content or "")) + def get_model_meta(model_id: str) -> dict: return MODEL_CATALOG.get(model_id, {}) @@ -1515,13 +1587,60 @@ def prepare_history_for_prompt(messages: List[dict]) -> List[dict]: return prune_conversation_history(messages[legacy_count:]) -def build_messages_for_node(system_prompt: str, history: List[dict]) -> List[dict]: - """Build an Ollama payload with exactly one canonical primary system prompt.""" +def build_messages_for_node( + system_prompt: str, + history: List[dict], + project_context: Optional[List[dict]] = None, +) -> List[dict]: + """Build an Ollama payload with instructions before bounded project evidence.""" return [ {"role": "system", "content": system_prompt}, + *[dict(message) for message in (project_context or [])], *[dict(message) for message in history], ] + +def build_project_messages_for_node( + *, + project_id: Optional[str], + project: dict, + model_id: str, + output_reserve: int, + query: str, + system_prompt: str, + history: List[dict], +) -> tuple[List[dict], dict]: + """Assemble the P4 order and reserve exact room for all four components.""" + if not project_id: + return build_messages_for_node(system_prompt, history), {} + base_messages = build_messages_for_node(system_prompt, history) + base_tokens = sum(content_token_count(message.get("content")) for message in base_messages) + project_instruction_tokens = estimate_project_tokens(project.get("system_prompt") or "") + non_project_tokens = max(0, base_tokens - project_instruction_tokens) + context_window = get_model_context_window(model_id) + safety_margin = max(512, round(context_window * 0.05)) + available_project_tokens = ( + context_window + - max(1, int(output_reserve or 2048)) + - safety_margin + - non_project_tokens + ) + assembled = PROJECT_CONTEXT.build_context_messages( + project_id, + query=query, + available_tokens=available_project_tokens, + ) + return ( + build_messages_for_node(system_prompt, history, assembled["messages"]), + { + **assembled["budget"], + "model_context_window": context_window, + "output_reserve": max(1, int(output_reserve or 2048)), + "safety_margin": safety_margin, + "non_project_tokens": non_project_tokens, + }, + ) + def generate_conversation_summary(older_messages: List[dict]) -> str: """Use a cheap local model to summarize older turns.""" if not older_messages: @@ -1652,26 +1771,35 @@ class ProjectCreate(BaseModel): name: str system_prompt: Optional[str] = None preferred_model: Optional[str] = None + preferred_node: Optional[str] = None max_budget: Optional[float] = None description: Optional[str] = None + context_budget_tokens: Optional[int] = Field(default=16_384, ge=1_024, le=262_144) + archived: bool = False class ProjectResponse(BaseModel): project_id: str name: str system_prompt: Optional[str] = None preferred_model: Optional[str] = None + preferred_node: Optional[str] = None max_budget: Optional[float] = None created_at: Optional[str] = None user_id: Optional[str] = None description: Optional[str] = None notepad: str = "" + context_budget_tokens: int = 16_384 + archived: bool = False class ProjectUpdate(BaseModel): name: Optional[str] = None system_prompt: Optional[str] = None preferred_model: Optional[str] = None + preferred_node: Optional[str] = None max_budget: Optional[float] = None description: Optional[str] = None + context_budget_tokens: Optional[int] = Field(default=None, ge=1_024, le=262_144) + archived: Optional[bool] = None class ResyncRequest(BaseModel): project_id: Optional[str] = None @@ -1701,6 +1829,42 @@ def validate_notepad_size(cls, value: str) -> str: return value +class BrainUpdate(BaseModel): + pinned_text: Optional[str] = None + active_text: Optional[str] = None + recent_text: Optional[str] = None + compact_threshold: Optional[int] = Field(default=None, ge=128, le=262_144) + expected_revision: Optional[int] = Field(default=None, ge=1) + + @field_validator("pinned_text", "active_text", "recent_text") + @classmethod + def validate_brain_text(cls, value: Optional[str]) -> Optional[str]: + if value is not None and len(value) > 1_000_000: + raise ValueError("A BRAIN tier exceeds 1,000,000 characters") + return value + + +class ArtifactUpdate(BaseModel): + pinned: Optional[bool] = None + archived: Optional[bool] = None + title: Optional[str] = Field(default=None, max_length=300) + + +class ProjectFileUpdate(BaseModel): + attached: bool + + +class ProjectAttachmentUpdate(BaseModel): + project_id: Optional[str] = None + + +class ProjectContextPreviewRequest(BaseModel): + query: str = Field(default="", max_length=1_000_000) + conversation_id: Optional[str] = None + model: Optional[str] = None + max_tokens: int = Field(default=2_048, ge=1, le=262_144) + + class AgentRunRequest(BaseModel): messages: List[Dict] node_id: str @@ -1744,6 +1908,114 @@ def validate_error_budget(cls, value: int) -> int: raise ValueError("error_budget must be between 1 and 8") return value + +BRAIN_COMPACTION_QUEUE: asyncio.Queue[str] = asyncio.Queue() +BRAIN_COMPACTION_PENDING: set[str] = set() +BACKGROUND_TASKS: list[asyncio.Task] = [] + + +def context_http_error(exc: ProjectContextError) -> HTTPException: + if isinstance(exc, RevisionConflictError): + return HTTPException(409, str(exc)) + if isinstance(exc, ContextBudgetError): + return HTTPException(422, str(exc)) + return HTTPException(400, str(exc)) + + +def backfill_project_artifacts(project_id: str, user_id: str) -> None: + """Make existing assistant outputs retrievable without rewriting conversations.""" + get_project(project_id, user_id) + for conversation_id, conversation in CONVERSATIONS.items(): + if conversation.get("user_id", "default") != user_id: + continue + if conversation.get("project_id") != project_id: + continue + for index, message in enumerate(conversation.get("messages", [])): + if message.get("role") != "assistant" or not isinstance(message.get("content"), str): + continue + PROJECT_CONTEXT.add_artifact( + project_id, + title=conversation.get("title") or "Assistant output", + body=message["content"], + conversation_id=conversation_id, + source_message_index=index, + ) + + +def capture_project_artifact( + project_id: Optional[str], + conversation_id: str, + message_index: int, + body: str, +) -> None: + if not project_id or not body: + return + try: + conversation = CONVERSATIONS.get(conversation_id, {}) + PROJECT_CONTEXT.add_artifact( + project_id, + title=conversation.get("title") or "Assistant output", + body=body, + conversation_id=conversation_id, + source_message_index=message_index, + ) + except ProjectContextError as exc: + record_error("artifact_capture", str(exc)) + + +def enqueue_brain_compaction(project_id: str) -> bool: + if project_id in BRAIN_COMPACTION_PENDING: + return False + BRAIN_COMPACTION_PENDING.add(project_id) + BRAIN_COMPACTION_QUEUE.put_nowait(project_id) + return True + + +async def brain_compaction_worker() -> None: + """Compact queued projects and reconcile threshold crossings once per day.""" + reconciliation_interval = 86_400 + next_reconciliation = time.monotonic() + reconciliation_interval + while True: + queued_project: Optional[str] = None + try: + timeout = max(0.0, next_reconciliation - time.monotonic()) + queued_project = await asyncio.wait_for( + BRAIN_COMPACTION_QUEUE.get(), + timeout=timeout, + ) + await asyncio.to_thread(PROJECT_CONTEXT.compact_brain, queued_project) + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + raise + except ProjectContextError as exc: + record_error("brain_compaction", str(exc)) + except Exception as exc: + record_error("brain_compaction", str(exc)) + finally: + if queued_project is not None: + BRAIN_COMPACTION_PENDING.discard(queued_project) + BRAIN_COMPACTION_QUEUE.task_done() + if time.monotonic() >= next_reconciliation: + try: + await asyncio.to_thread( + PROJECT_CONTEXT.purge_expired_brains, + BRAIN_RECOVERY_DAYS, + ) + for project_id in list(PROJECTS): + try: + brain = PROJECT_CONTEXT.get_brain(project_id) + if brain["should_compact"]: + enqueue_brain_compaction(project_id) + except ProjectContextError as exc: + record_error("brain_reconcile", str(exc)) + except asyncio.CancelledError: + raise + except Exception as exc: + record_error("brain_reconcile", str(exc)) + finally: + next_reconciliation = time.monotonic() + reconciliation_interval + # ============================================================ # ROUTES # ============================================================ @@ -1973,24 +2245,32 @@ async def transcribe_audio(file: UploadFile = File(...), user_id: str = Depends( except Exception: pass +def project_response(project_id: str, project: dict) -> ProjectResponse: + return ProjectResponse( + project_id=project_id, + name=project.get("name", ""), + system_prompt=project.get("system_prompt"), + preferred_model=project.get("preferred_model"), + preferred_node=project.get("preferred_node"), + max_budget=project.get("max_budget"), + created_at=project.get("created_at"), + user_id=project.get("user_id"), + description=project.get("description"), + notepad=project.get("notepad", ""), + context_budget_tokens=int(project.get("context_budget_tokens") or 16_384), + archived=bool(project.get("archived", False)), + ) + + @app.get("/projects") def list_projects(user_id: str = Depends(get_current_user)): """List projects owned by the current user.""" - projects = [ - ProjectResponse( - project_id=pid, - name=p.get("name", ""), - system_prompt=p.get("system_prompt"), - preferred_model=p.get("preferred_model"), - max_budget=p.get("max_budget"), - created_at=p.get("created_at"), - user_id=p.get("user_id"), - description=p.get("description"), - notepad=p.get("notepad", ""), - ).model_dump() - for pid, p in PROJECTS.items() - if p.get("user_id", "default") == user_id - ] + projects = [] + for project_id, project in PROJECTS.items(): + if project.get("user_id", "default") != user_id: + continue + project = get_project(project_id, user_id) + projects.append(project_response(project_id, project).model_dump()) return {"projects": projects} @@ -2026,62 +2306,346 @@ def create_project(req: ProjectCreate, user_id: str = Depends(get_current_user)) "name": req.name.strip(), "system_prompt": req.system_prompt or "", "preferred_model": req.preferred_model, + "preferred_node": req.preferred_node, "max_budget": req.max_budget, "created_at": datetime.now().isoformat(), "user_id": user_id, "description": req.description or "", "notepad": "", + "context_budget_tokens": req.context_budget_tokens or 16_384, + "archived": req.archived, } PROJECTS[project_id] = project + try: + PROJECT_CONTEXT.ensure_project( + project_id, + instructions=project["system_prompt"], + context_budget_tokens=project["context_budget_tokens"], + ) + PROJECT_CONTEXT.update_profile( + project_id, + instructions=project["system_prompt"], + context_budget_tokens=project["context_budget_tokens"], + ) + except ProjectContextError as exc: + PROJECTS.pop(project_id, None) + PROJECT_CONTEXT.delete_project(project_id) + raise context_http_error(exc) save_projects(PROJECTS) - return ProjectResponse( - project_id=project_id, - name=project["name"], - system_prompt=project["system_prompt"], - preferred_model=project["preferred_model"], - max_budget=project["max_budget"], - created_at=project["created_at"], - user_id=user_id, - description=project["description"], - notepad=project["notepad"], - ) + return project_response(project_id, project) @app.get("/projects/{project_id}", response_model=ProjectResponse) def get_project_endpoint(project_id: str, user_id: str = Depends(get_current_user)): proj = get_project(project_id, user_id) - return ProjectResponse( - project_id=project_id, - name=proj.get("name", ""), - system_prompt=proj.get("system_prompt"), - preferred_model=proj.get("preferred_model"), - max_budget=proj.get("max_budget"), - created_at=proj.get("created_at"), - user_id=proj.get("user_id"), - description=proj.get("description"), - notepad=proj.get("notepad", ""), - ) + return project_response(project_id, proj) @app.put("/projects/{project_id}", response_model=ProjectResponse) def update_project(project_id: str, req: ProjectUpdate, user_id: str = Depends(get_current_user)): proj = get_project(project_id, user_id) updates = req.model_dump(exclude_unset=True) + try: + profile = PROJECT_CONTEXT.update_profile( + project_id, + instructions=updates.get("system_prompt"), + context_budget_tokens=updates.get("context_budget_tokens"), + ) + except ProjectContextError as exc: + raise context_http_error(exc) for key, val in updates.items(): if val is not None: proj[key] = val + proj["system_prompt"] = profile["instructions"] + proj["context_budget_tokens"] = profile["context_budget_tokens"] proj["updated_at"] = datetime.now().isoformat() PROJECTS[project_id] = proj save_projects(PROJECTS) - return ProjectResponse( - project_id=project_id, - name=proj.get("name", ""), - system_prompt=proj.get("system_prompt"), - preferred_model=proj.get("preferred_model"), - max_budget=proj.get("max_budget"), - created_at=proj.get("created_at"), - user_id=proj.get("user_id"), - description=proj.get("description"), - notepad=proj.get("notepad", ""), + return project_response(project_id, proj) + + +@app.get("/projects/{project_id}/homepage") +def get_project_homepage(project_id: str, user_id: str = Depends(get_current_user)): + """Return one inspectable surface for the four project-context components.""" + project = get_project(project_id, user_id) + backfill_project_artifacts(project_id, user_id) + try: + homepage = PROJECT_CONTEXT.homepage(project_id) + except ProjectContextError as exc: + raise context_http_error(exc) + return { + "project": project_response(project_id, project).model_dump(), + "attached_conversation_ids": [ + conversation_id + for conversation_id, conversation in CONVERSATIONS.items() + if conversation.get("user_id", "default") == user_id + and conversation.get("project_id") == project_id + ], + **homepage, + } + + +@app.post("/projects/{project_id}/context-preview") +def preview_project_context( + project_id: str, + req: ProjectContextPreviewRequest, + user_id: str = Depends(get_current_user), +): + """Assemble the exact next-request messages without calling a model.""" + project = get_project(project_id, user_id) + backfill_project_artifacts(project_id, user_id) + history: List[dict] = [] + if req.conversation_id: + assert_convo_owner(req.conversation_id, user_id) + stored_conversation = CONVERSATIONS[req.conversation_id] + if stored_conversation.get("project_id") != project_id: + raise HTTPException( + 409, + "Context preview requires a conversation explicitly attached to this project", + ) + conversation = dict(stored_conversation) + history = [dict(message) for message in stored_conversation.get("messages", [])] + else: + conversation = { + "session_override": "", + "instruction_mode": "layered", + "messages": [], + } + history.append({"role": "user", "content": req.query or "[empty prompt]"}) + prepared_history = prepare_history_for_prompt(history) + system_prompt = resolve_conversation_system_prompt(conversation, project) + model_id = req.model or project.get("preferred_model") or "" + try: + messages, budget = build_project_messages_for_node( + project_id=project_id, + project=project, + model_id=model_id, + output_reserve=req.max_tokens, + query=req.query, + system_prompt=system_prompt, + history=prepared_history, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + return { + "project_id": project_id, + "conversation_id": req.conversation_id, + "model": model_id or None, + "messages": messages, + "budget": budget, + } + + +@app.post("/projects/{project_id}/files") +async def upload_project_file( + project_id: str, + file: UploadFile = File(...), + user_id: str = Depends(get_current_user), +): + """Store a project reference and index supported UTF-8 text locally.""" + get_project(project_id, user_id) + content = await file.read(MAX_FILE_SIZE + 1) + if len(content) > MAX_FILE_SIZE: + raise HTTPException(413, f"Project file exceeds {MAX_FILE_SIZE // 1024 // 1024}MB") + try: + return PROJECT_CONTEXT.add_file( + project_id, + display_name=file.filename or "reference", + media_type=file.content_type, + content=content, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.get("/projects/{project_id}/files") +def list_project_files(project_id: str, user_id: str = Depends(get_current_user)): + get_project(project_id, user_id) + return {"files": PROJECT_CONTEXT.list_files(project_id)} + + +@app.put("/projects/{project_id}/files/{file_id}") +def update_project_file( + project_id: str, + file_id: str, + req: ProjectFileUpdate, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return PROJECT_CONTEXT.set_file_attached( + project_id, + file_id, + req.attached, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.post("/projects/{project_id}/files/{file_id}/reindex") +def reindex_project_file( + project_id: str, + file_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return PROJECT_CONTEXT.reindex_file(project_id, file_id) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.delete("/projects/{project_id}/files/{file_id}") +def delete_project_file( + project_id: str, + file_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + PROJECT_CONTEXT.delete_file(project_id, file_id) + except ProjectContextError as exc: + raise context_http_error(exc) + return {"status": "deleted", "project_id": project_id, "file_id": file_id} + + +@app.get("/projects/{project_id}/artifacts") +def list_project_artifacts( + project_id: str, + include_archived: bool = False, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + backfill_project_artifacts(project_id, user_id) + return { + "artifacts": PROJECT_CONTEXT.list_artifacts( + project_id, + include_archived=include_archived, + ) + } + + +@app.get("/projects/{project_id}/artifacts/{artifact_id}") +def get_project_artifact( + project_id: str, + artifact_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return PROJECT_CONTEXT.get_artifact(project_id, artifact_id) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.put("/projects/{project_id}/artifacts/{artifact_id}") +def update_project_artifact( + project_id: str, + artifact_id: str, + req: ArtifactUpdate, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return PROJECT_CONTEXT.update_artifact( + project_id, + artifact_id, + **req.model_dump(exclude_unset=True), + ) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.delete("/projects/{project_id}/artifacts/{artifact_id}") +def delete_project_artifact( + project_id: str, + artifact_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + PROJECT_CONTEXT.delete_artifact(project_id, artifact_id) + except ProjectContextError as exc: + raise context_http_error(exc) + return {"status": "deleted", "project_id": project_id, "artifact_id": artifact_id} + + +@app.get("/projects/{project_id}/brain") +def get_project_brain(project_id: str, user_id: str = Depends(get_current_user)): + get_project(project_id, user_id) + return PROJECT_CONTEXT.get_brain(project_id) + + +@app.put("/projects/{project_id}/brain") +async def update_project_brain( + project_id: str, + req: BrainUpdate, + user_id: str = Depends(get_current_user), +): + project = get_project(project_id, user_id) + current = PROJECT_CONTEXT.get_brain(project_id) + values = req.model_dump(exclude_unset=True) + pinned = values.get("pinned_text", current["pinned_text"]) + active = values.get("active_text", current["active_text"]) + quotas = component_quotas(project["context_budget_tokens"]) + instruction_tokens = estimate_project_tokens(project.get("system_prompt") or "") + brain_allowance = quotas["brain"] + max( + 0, + quotas["project_instructions"] - instruction_tokens, ) + if estimate_project_tokens(f"{pinned}\n{active}") > brain_allowance: + raise HTTPException(422, "Pinned and active BRAIN content exceed the protected allocation") + if values.get("compact_threshold", current["compact_threshold"]) > brain_allowance: + raise HTTPException(422, "BRAIN compaction threshold exceeds its available allocation") + try: + brain = PROJECT_CONTEXT.update_brain(project_id, **values) + except ProjectContextError as exc: + raise context_http_error(exc) + queued = enqueue_brain_compaction(project_id) if brain["should_compact"] else False + return {**brain, "compaction_queued": queued} + + +@app.post("/projects/{project_id}/brain/compact") +async def compact_project_brain( + project_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return await asyncio.to_thread( + PROJECT_CONTEXT.compact_brain, + project_id, + force=True, + reason="explicit", + ) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.get("/projects/{project_id}/brain/revisions") +def list_project_brain_revisions( + project_id: str, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + return {"revisions": PROJECT_CONTEXT.list_brain_revisions(project_id)} + + +@app.post("/projects/{project_id}/brain/revisions/{revision}/restore") +def restore_project_brain( + project_id: str, + revision: int, + user_id: str = Depends(get_current_user), +): + get_project(project_id, user_id) + try: + return PROJECT_CONTEXT.restore_brain(project_id, revision) + except ProjectContextError as exc: + raise context_http_error(exc) + + +@app.delete("/projects/{project_id}/brain") +def delete_project_brain(project_id: str, user_id: str = Depends(get_current_user)): + get_project(project_id, user_id) + return PROJECT_CONTEXT.soft_delete_brain(project_id) @app.get("/projects/{project_id}/notepad") @@ -2125,6 +2689,16 @@ def delete_project(project_id: str, user_id: str = Depends(get_current_user)): for cid, convo in CONVERSATIONS.items(): if convo.get("project_id") == project_id and convo.get("user_id", "default") == user_id: convo["project_id"] = None + convo.setdefault("context_events", []).append( + { + "type": "project_detached", + "project_id": project_id, + "timestamp": datetime.now().isoformat(), + "reason": "project_deleted", + "applies_to": "future_messages_only", + } + ) + PROJECT_CONTEXT.delete_project(project_id) save_projects(PROJECTS) save_conversations(CONVERSATIONS) return {"status": "deleted", "project_id": project_id} @@ -2374,6 +2948,7 @@ def get_conversation(conversation_id: str, user_id: str = Depends(get_current_us "created_at": convo.get("created_at"), "updated_at": convo.get("updated_at"), "project_id": convo.get("project_id"), + "context_events": convo.get("context_events", []), "system_prompt": convo.get("system_prompt"), "session_override": normalize_session_instructions( convo, @@ -2424,7 +2999,16 @@ def update_conversation_instructions( if "project_instructions" in updates: if not project_id: raise HTTPException(400, "This conversation is not attached to a project") - project["system_prompt"] = str(updates["project_instructions"] or "") + project_instructions = str(updates["project_instructions"] or "") + try: + profile = PROJECT_CONTEXT.update_profile( + project_id, + instructions=project_instructions, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + project["system_prompt"] = profile["instructions"] + project["context_budget_tokens"] = profile["context_budget_tokens"] project["updated_at"] = datetime.now().isoformat() save_projects(PROJECTS) @@ -2534,6 +3118,59 @@ def rename_conversation(conversation_id: str, req: RenameRequest, user_id: str = "title": new_title } +def set_conversation_project( + conversation_id: str, + conversation: dict, + project_id: Optional[str], + user_id: str, + *, + event_type: str = "project_attachment_changed", +) -> dict: + old_project_id = conversation.get("project_id") + old_project = get_project(old_project_id, user_id) if old_project_id else {} + session_override, mode = normalize_session_instructions(conversation, old_project) + project = get_project(project_id, user_id) if project_id else {} + conversation["project_id"] = project_id + conversation["session_override"] = session_override + conversation["instruction_mode"] = mode + conversation["system_prompt"] = resolve_conversation_system_prompt(conversation, project) + conversation["updated_at"] = datetime.now().isoformat() + if old_project_id != project_id or event_type == "project_resynced": + conversation.setdefault("context_events", []).append( + { + "type": event_type, + "from_project_id": old_project_id, + "project_id": project_id, + "timestamp": conversation["updated_at"], + "applies_to": "future_messages_only", + } + ) + save_conversations(CONVERSATIONS) + return { + "conversation_id": conversation_id, + "project_id": project_id, + "project_name": project.get("name"), + "system_prompt": conversation["system_prompt"], + "context_events": conversation.get("context_events", []), + } + + +@app.put("/conversations/{conversation_id}/project") +def update_conversation_project( + conversation_id: str, + req: ProjectAttachmentUpdate, + user_id: str = Depends(get_current_user), +): + """Explicitly attach or detach future chat turns without rewriting history.""" + assert_convo_owner(conversation_id, user_id) + return set_conversation_project( + conversation_id, + CONVERSATIONS[conversation_id], + req.project_id, + user_id, + ) + + @app.post("/conversations/{conversation_id}/resync_project") def resync_conversation_project(conversation_id: str, req: ResyncRequest, user_id: str = Depends(get_current_user)): """Re-apply project instructions to a conversation and persist them.""" @@ -2544,20 +3181,19 @@ def resync_conversation_project(conversation_id: str, req: ResyncRequest, user_i if not project_id: raise HTTPException(400, "No project linked to conversation") - proj = get_project(project_id, user_id) - convo["project_id"] = project_id - session_override, _mode = normalize_session_instructions(convo, proj) - convo["session_override"] = session_override - convo["instruction_mode"] = "layered" - convo["system_prompt"] = resolve_conversation_system_prompt(convo, proj) - convo["updated_at"] = datetime.now().isoformat() - save_conversations(CONVERSATIONS) + result = set_conversation_project( + conversation_id, + convo, + project_id, + user_id, + event_type="project_resynced", + ) return { "status": "updated", "conversation_id": conversation_id, "project_id": project_id, - "system_prompt": convo["system_prompt"], + "system_prompt": result["system_prompt"], } @app.post("/chat", response_model=ChatResponse) @@ -2585,7 +3221,15 @@ def chat(req: ChatRequest, request: Request = None, user_id: str = Depends(get_c existing_convo = CONVERSATIONS.get(req.conversation_id) if existing_convo: assert_convo_owner(req.conversation_id, user_id) - project_id = req.project_id or (existing_convo.get("project_id") if existing_convo else None) + attached_project_id = existing_convo.get("project_id") + if req.project_id is not None and req.project_id != attached_project_id: + raise HTTPException( + 409, + "Project changes require the explicit conversation project endpoint", + ) + project_id = attached_project_id + else: + project_id = req.project_id project_cfg = get_project(project_id, user_id) if project_id else {} if not req.node_id or not req.model: @@ -2612,7 +3256,19 @@ def chat(req: ChatRequest, request: Request = None, user_id: str = Depends(get_c conversation["system_prompt"] = system_prompt # Build messages copy so we can adjust content shape for vision models without # mutating persisted history. - messages_for_node = build_messages_for_node(system_prompt, history) + try: + messages_for_node, context_budget = build_project_messages_for_node( + project_id=project_id, + project=project_cfg, + model_id=preferred_model, + output_reserve=req.max_tokens or 2048, + query=user_text, + system_prompt=system_prompt, + history=history, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + conversation["last_context_budget"] = context_budget if req.images: multimodal_content = [] @@ -2689,7 +3345,6 @@ def chat(req: ChatRequest, request: Request = None, user_id: str = Depends(get_c # Save assistant response to history using persisted raw-history indexes. assistant_msg_idx = len(raw_history) raw_history.append({"role": "assistant", "content": assistant_msg}) - # Log approximate cost try: actual_tokens = estimate_tokens(assistant_msg) + (estimate_tokens(user_text) if user_text else 0) @@ -2719,6 +3374,12 @@ def chat(req: ChatRequest, request: Request = None, user_id: str = Depends(get_c title_text = user_text.strip() if title_text and is_first_exchange and convo.get("title") == DEFAULT_CONVO_TITLE: convo["title"] = title_text[:30] + ("..." if len(title_text) > 30 else "") + capture_project_artifact( + project_id, + req.conversation_id, + assistant_msg_idx, + assistant_msg, + ) save_conversations(CONVERSATIONS) @@ -2764,7 +3425,15 @@ async def chat_stream(req: ChatRequest, request: Request = None, user_id: str = existing_convo = CONVERSATIONS.get(req.conversation_id) if existing_convo: assert_convo_owner(req.conversation_id, user_id) - project_id = req.project_id or (existing_convo.get("project_id") if existing_convo else None) + attached_project_id = existing_convo.get("project_id") + if req.project_id is not None and req.project_id != attached_project_id: + raise HTTPException( + 409, + "Project changes require the explicit conversation project endpoint", + ) + project_id = attached_project_id + else: + project_id = req.project_id project_cfg = get_project(project_id, user_id) if project_id else {} if not req.node_id or not req.model: @@ -2789,7 +3458,19 @@ async def chat_stream(req: ChatRequest, request: Request = None, user_id: str = conversation = CONVERSATIONS[req.conversation_id] system_prompt = resolve_conversation_system_prompt(conversation, project_cfg) conversation["system_prompt"] = system_prompt - messages_for_node = build_messages_for_node(system_prompt, history) + try: + messages_for_node, context_budget = build_project_messages_for_node( + project_id=project_id, + project=project_cfg, + model_id=preferred_model, + output_reserve=req.max_tokens or 2048, + query=user_text, + system_prompt=system_prompt, + history=history, + ) + except ProjectContextError as exc: + raise context_http_error(exc) + conversation["last_context_budget"] = context_budget if req.images: multimodal_content = [] @@ -2887,7 +3568,6 @@ async def stream_generator(): convo_history = get_history(req.conversation_id, user_id=user_id) assistant_msg_idx = len(convo_history) convo_history.append({"role": "assistant", "content": full_response}) - if user_text: store_message_embedding(req.conversation_id, user_msg_idx, "user", user_text) store_message_embedding(req.conversation_id, assistant_msg_idx, "assistant", full_response) @@ -2897,6 +3577,12 @@ async def stream_generator(): title_text = user_text.strip() if title_text and is_first_exchange and convo.get("title") == DEFAULT_CONVO_TITLE: convo["title"] = title_text[:30] + ("..." if len(title_text) > 30 else "") + capture_project_artifact( + project_id, + req.conversation_id, + assistant_msg_idx, + full_response, + ) save_conversations(CONVERSATIONS) @@ -3000,11 +3686,21 @@ async def startup_event(): print(f"✅ DaveLLM Router v2.1 started") print(f"📁 Loaded {len(CONVERSATIONS)} conversations from disk") print(f"🖥️ Active nodes: {len(NODE_CONFIGS)}") - asyncio.create_task(background_summarizer()) + BACKGROUND_TASKS.extend( + [ + asyncio.create_task(background_summarizer()), + asyncio.create_task(brain_compaction_worker()), + ] + ) @app.on_event("shutdown") async def shutdown_event(): """Save conversations on shutdown.""" + for task in BACKGROUND_TASKS: + task.cancel() + if BACKGROUND_TASKS: + await asyncio.gather(*BACKGROUND_TASKS, return_exceptions=True) + BACKGROUND_TASKS.clear() save_conversations(CONVERSATIONS) print("💾 Conversations saved to disk") async def background_summarizer(): diff --git a/docs/EXECUTABLE_PROMPT_SERIES.md b/docs/EXECUTABLE_PROMPT_SERIES.md index df77d0b..e0d19ad 100644 --- a/docs/EXECUTABLE_PROMPT_SERIES.md +++ b/docs/EXECUTABLE_PROMPT_SERIES.md @@ -1,5 +1,19 @@ # Execute the Dave LLM prompt series +## Completion ledger + +| Prompt | Required contract | Completion evidence | +|---|---|---| +| P0 | Shared context | Verified repo, runtime, model, and deployment boundaries below | +| P1 | Harness decision | Decision, tradeoffs, flip conditions, and confidence recorded below | +| P2 | Executor design and code | Bounded registry-driven loop, approvals, error budget, and tests implemented | +| P3 | BRAIN plan | Plan delivered, then fully implemented with revisions, compaction, recovery, API, UI, and CLI | +| P4 | Project Homepage plan | Plan delivered, then fully implemented with four components, allocator, preview, and explicit attachment | +| P5 | Instruction UI code | Implemented and covered by exact-payload tests | +| P6 | Copy actions code | Implemented for both message roles with raw-source preservation | +| P7 | Lightweight notepad code | Implemented with scoped persistence and autosave | +| P8 | Icon review | Review delivered, then the recommended licensed Lucide replacement implemented | + ## Establish shared context | Slot | Verified value | @@ -135,6 +149,8 @@ Checkpoint: The allowlist test refuses a sibling path, and the ceiling test retu Checkpoint: BRAIN drops duplicate chatter, superseded drafts, resolved transient troubleshooting, and raw tool logs after a recoverable snapshot. It never drops pinned facts or open decisions automatically. +Implementation closeout (2026-08-26): Completed in `project_context.py`, authenticated FastAPI routes, the Project Homepage BRAIN panel, the daily/queued compaction worker, and `scripts/project_context_cli.py`. Automated coverage proves protected-tier preservation, deterministic transient-line removal, optimistic revisions, explicit compaction, soft delete, restore, and recovery-window expiry. + ## Specify Project Homepage container ### PLAN @@ -187,6 +203,8 @@ Project Instructions cannot silently overflow their share. The editor shows the Checkpoint: At a 16,384-token project budget, 4,096 + 4,096 + 4,915 + 3,277 equals 16,384. +Implementation closeout (2026-08-26): Completed with normalized stores for all four components, exact baseline arithmetic, unused-token rollover in BRAIN/files/artifacts order, ranked bounded request injection, automatic assistant-output capture, explicit future-only chat attachment events, and an authenticated no-send context preview. The responsive Project Homepage renders all four empty states and their independent lifecycle controls. A locally vendored GSAP timeline adds staged control-deck motion while `prefers-reduced-motion` bypasses it completely. + ## Expose system instructions in UI ### CODE @@ -211,7 +229,7 @@ python -m pytest -q tests/test_api_contracts.py Open Instructions, edit all available layers, confirm the effective preview, choose Save and apply, send one message, and inspect that the first Ollama message matches the preview exactly. -5. Not done: This does not implement the broader Project Homepage from P4 or change model context windows. +5. Follow-on note: The broader Project Homepage and configurable model-context windows were subsequently implemented by the P3/P4 closeout above. Checkpoint: The API test saves all three layers, sends the next message, and proves the first Ollama message equals the on-screen precedence result. @@ -285,4 +303,4 @@ Nice to have: Apply the same family later to project, template, history, search, | Reveal attachments | Attach text | Keep Attach text | A generic menu or plus icon would hide the control's purpose. | | Send message | Send text | Keep Send text | The explicit verb is clearer than a direction-dependent arrow. | -5. Prioritized next action: Vendor only `Image`, `FileAudio`, `Captions`, `Mic`, `Square`, and `Paperclip` SVGs plus their required license notices, then replace the composer emoji without adding a runtime package or CDN request. +5. Implementation closeout (2026-08-26): Vendored the six-icon Lucide sprite and ISC/Feather MIT notices under `static/vendor/lucide/`, replaced the audited composer emoji, retained visible Image, Audio, Transcribe, and File text, and kept accessible names and 44-pixel mobile targets without a runtime package or CDN request. diff --git a/project_context.py b/project_context.py new file mode 100644 index 0000000..59697c0 --- /dev/null +++ b/project_context.py @@ -0,0 +1,1207 @@ +"""Durable project context storage and bounded request assembly for DaveLLM.""" + +from __future__ import annotations + +import hashlib +import json +import mimetypes +import re +import sqlite3 +import threading +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + + +DEFAULT_CONTEXT_BUDGET = 16_384 +DEFAULT_BRAIN_COMPACT_TOKENS = 3_072 +TEXT_EXTENSIONS = { + ".c", + ".cc", + ".cpp", + ".css", + ".csv", + ".go", + ".h", + ".hpp", + ".html", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".py", + ".rs", + ".sh", + ".sql", + ".svg", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +} +DROP_RECENT_PREFIXES = ( + "[resolved]", + "[superseded]", + "[tool]", + "tool call:", + "tool result:", +) + + +class ProjectContextError(ValueError): + """Base error for a rejected project-context operation.""" + + +class ContextBudgetError(ProjectContextError): + """Raised when a protected component cannot fit without truncation.""" + + +class RevisionConflictError(ProjectContextError): + """Raised when a stale editor attempts to replace a newer BRAIN revision.""" + + +def estimate_tokens(text: str) -> int: + """Return the same conservative character estimate used by the router.""" + return max(0, (len(str(text or "")) + 3) // 4) + + +def component_quotas(total_tokens: int) -> dict[str, int]: + """Allocate the exact P4 25/25/30/20 project-context budget.""" + total = max(0, int(total_tokens)) + instructions = total // 4 + brain = total // 4 + files = round(total * 0.30) + artifacts = total - instructions - brain - files + return { + "project_instructions": instructions, + "brain": brain, + "file_context": files, + "artifact_history": artifacts, + } + + +def _timestamp() -> str: + return datetime.now().isoformat() + + +def _query_terms(query: str) -> set[str]: + return { + term + for term in re.findall(r"[a-z0-9_]{3,}", str(query or "").lower()) + if term not in {"and", "for", "from", "that", "the", "this", "with"} + } + + +def _rank_text(text: str, terms: set[str]) -> int: + lowered = str(text or "").lower() + return sum(lowered.count(term) for term in terms) + + +def _bounded_text(text: str, token_limit: int, *, keep_tail: bool = False) -> str: + character_limit = max(0, int(token_limit)) * 4 + if len(text) <= character_limit: + return text + if character_limit <= 32: + return "" + marker = "[Earlier compacted text omitted]\n" if keep_tail else "\n[Remaining text omitted]" + available = character_limit - len(marker) + if available <= 0: + return "" + return f"{marker}{text[-available:]}" if keep_tail else f"{text[:available]}{marker}" + + +class ProjectContextStore: + """Own normalized project components while legacy project metadata stays JSON.""" + + def __init__( + self, + database_path: Path, + uploads_root: Path, + *, + default_context_budget: int = DEFAULT_CONTEXT_BUDGET, + default_brain_threshold: int = DEFAULT_BRAIN_COMPACT_TOKENS, + ) -> None: + self.database_path = Path(database_path) + self.uploads_root = Path(uploads_root) + self.default_context_budget = max(1_024, int(default_context_budget)) + self.default_brain_threshold = max(128, int(default_brain_threshold)) + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self.uploads_root.mkdir(parents=True, exist_ok=True) + self._locks_guard = threading.Lock() + self._project_locks: dict[str, threading.Lock] = {} + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(str(self.database_path), timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA journal_mode = WAL") + return connection + + def _initialize(self) -> None: + with self._connect() as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS project_profiles ( + project_id TEXT PRIMARY KEY, + instructions TEXT NOT NULL DEFAULT '', + context_budget_tokens INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS brain_states ( + project_id TEXT PRIMARY KEY REFERENCES project_profiles(project_id) ON DELETE CASCADE, + pinned_text TEXT NOT NULL DEFAULT '', + active_text TEXT NOT NULL DEFAULT '', + recent_text TEXT NOT NULL DEFAULT '', + compact_threshold INTEGER NOT NULL, + token_count INTEGER NOT NULL DEFAULT 0, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + last_compacted_at TEXT, + deleted_at TEXT + ); + + CREATE TABLE IF NOT EXISTS brain_revisions ( + project_id TEXT NOT NULL REFERENCES project_profiles(project_id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (project_id, revision) + ); + + CREATE TABLE IF NOT EXISTS project_files ( + file_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project_profiles(project_id) ON DELETE CASCADE, + display_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + media_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + status TEXT NOT NULL, + extracted_text TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + attached INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS project_file_chunks ( + file_id TEXT NOT NULL REFERENCES project_files(file_id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + content TEXT NOT NULL, + token_count INTEGER NOT NULL, + PRIMARY KEY (file_id, chunk_index) + ); + + CREATE TABLE IF NOT EXISTS project_artifacts ( + artifact_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project_profiles(project_id) ON DELETE CASCADE, + conversation_id TEXT, + source_message_index INTEGER, + title TEXT NOT NULL, + kind TEXT NOT NULL, + body TEXT NOT NULL, + token_count INTEGER NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_artifact_source + ON project_artifacts(project_id, conversation_id, source_message_index) + WHERE conversation_id IS NOT NULL AND source_message_index IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_project_files_project + ON project_files(project_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_project_artifacts_project + ON project_artifacts(project_id, pinned DESC, created_at DESC); + """ + ) + file_columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(project_files)").fetchall() + } + if "attached" not in file_columns: + connection.execute( + "ALTER TABLE project_files ADD COLUMN attached INTEGER NOT NULL DEFAULT 1" + ) + + def _lock_for(self, project_id: str) -> threading.Lock: + with self._locks_guard: + return self._project_locks.setdefault(project_id, threading.Lock()) + + def ensure_project( + self, + project_id: str, + *, + instructions: str = "", + context_budget_tokens: int | None = None, + ) -> None: + now = _timestamp() + budget = int(context_budget_tokens or self.default_context_budget) + quotas = component_quotas(budget) + threshold = min(self.default_brain_threshold, max(128, quotas["brain"] - 64)) + with self._connect() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO project_profiles + (project_id, instructions, context_budget_tokens, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + (project_id, instructions, budget, now, now), + ) + row = connection.execute( + "SELECT instructions FROM project_profiles WHERE project_id = ?", + (project_id,), + ).fetchone() + if row and not row["instructions"] and instructions: + connection.execute( + "UPDATE project_profiles SET instructions = ?, updated_at = ? WHERE project_id = ?", + (instructions, now, project_id), + ) + connection.execute( + """ + INSERT OR IGNORE INTO brain_states + (project_id, compact_threshold, token_count, revision, updated_at) + VALUES (?, ?, 0, 1, ?) + """, + (project_id, threshold, now), + ) + brain = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if brain: + connection.execute( + """ + INSERT OR IGNORE INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, ?, ?, 'created', ?) + """, + (project_id, brain["revision"], json.dumps(self._brain_snapshot(brain)), now), + ) + + def delete_project(self, project_id: str) -> None: + with self._connect() as connection: + stored_names = [ + row["stored_name"] + for row in connection.execute( + "SELECT stored_name FROM project_files WHERE project_id = ?", + (project_id,), + ).fetchall() + ] + connection.execute("DELETE FROM project_profiles WHERE project_id = ?", (project_id,)) + project_root = self.uploads_root / project_id + for stored_name in stored_names: + try: + (project_root / stored_name).unlink(missing_ok=True) + except OSError: + pass + try: + project_root.rmdir() + except OSError: + pass + + def get_profile(self, project_id: str) -> dict[str, Any]: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM project_profiles WHERE project_id = ?", + (project_id,), + ).fetchone() + if not row: + raise ProjectContextError(f"Project context for '{project_id}' was not initialized") + return dict(row) + + def get_instructions(self, project_id: str) -> str: + return str(self.get_profile(project_id)["instructions"] or "") + + def update_profile( + self, + project_id: str, + *, + instructions: str | None = None, + context_budget_tokens: int | None = None, + ) -> dict[str, Any]: + profile = self.get_profile(project_id) + budget = int(context_budget_tokens or profile["context_budget_tokens"]) + if not 1_024 <= budget <= 262_144: + raise ProjectContextError("context_budget_tokens must be between 1,024 and 262,144") + updated_instructions = profile["instructions"] if instructions is None else instructions + quotas = component_quotas(budget) + if estimate_tokens(updated_instructions) > quotas["project_instructions"]: + raise ContextBudgetError( + "Project instructions exceed their 25 percent context allocation" + ) + with self._connect() as connection: + brain = connection.execute( + "SELECT pinned_text, active_text, compact_threshold FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if brain: + protected_tokens = estimate_tokens( + f"{brain['pinned_text']}\n{brain['active_text']}" + ) + brain_allowance = quotas["brain"] + max( + 0, + quotas["project_instructions"] + - estimate_tokens(updated_instructions), + ) + if protected_tokens > brain_allowance: + raise ContextBudgetError( + "The smaller budget cannot fit protected BRAIN content" + ) + if brain["compact_threshold"] > brain_allowance: + raise ContextBudgetError( + "The smaller budget is below the current BRAIN compaction threshold" + ) + connection.execute( + """ + UPDATE project_profiles + SET instructions = ?, context_budget_tokens = ?, updated_at = ? + WHERE project_id = ? + """, + (updated_instructions, budget, _timestamp(), project_id), + ) + return self.get_profile(project_id) + + @staticmethod + def _brain_snapshot(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + return { + "pinned_text": row["pinned_text"], + "active_text": row["active_text"], + "recent_text": row["recent_text"], + "compact_threshold": row["compact_threshold"], + "token_count": row["token_count"], + "revision": row["revision"], + "updated_at": row["updated_at"], + "last_compacted_at": row["last_compacted_at"], + "deleted_at": row["deleted_at"], + } + + def _brain_response(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + value = self._brain_snapshot(row) + value["project_id"] = row["project_id"] + value["should_compact"] = ( + not value["deleted_at"] + and value["token_count"] >= value["compact_threshold"] + ) + return value + + def get_brain(self, project_id: str) -> dict[str, Any]: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if not row: + raise ProjectContextError(f"BRAIN for '{project_id}' was not initialized") + return self._brain_response(row) + + def update_brain( + self, + project_id: str, + *, + pinned_text: str | None = None, + active_text: str | None = None, + recent_text: str | None = None, + compact_threshold: int | None = None, + expected_revision: int | None = None, + ) -> dict[str, Any]: + with self._lock_for(project_id), self._connect() as connection: + row = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if not row: + raise ProjectContextError(f"BRAIN for '{project_id}' was not initialized") + if expected_revision is not None and expected_revision != row["revision"]: + raise RevisionConflictError( + f"BRAIN changed from revision {expected_revision} to {row['revision']}" + ) + pinned = row["pinned_text"] if pinned_text is None else pinned_text + active = row["active_text"] if active_text is None else active_text + recent = row["recent_text"] if recent_text is None else recent_text + threshold = row["compact_threshold"] if compact_threshold is None else int(compact_threshold) + if not 128 <= threshold <= 262_144: + raise ProjectContextError("compact_threshold must be between 128 and 262,144") + token_count = estimate_tokens("\n".join((pinned, active, recent))) + revision = int(row["revision"]) + 1 + now = _timestamp() + connection.execute( + """ + UPDATE brain_states + SET pinned_text = ?, active_text = ?, recent_text = ?, compact_threshold = ?, + token_count = ?, revision = ?, updated_at = ?, deleted_at = NULL + WHERE project_id = ? + """, + (pinned, active, recent, threshold, token_count, revision, now, project_id), + ) + updated = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, ?, ?, 'edited', ?) + """, + (project_id, revision, json.dumps(self._brain_snapshot(updated)), now), + ) + return self._brain_response(updated) + + @staticmethod + def _compact_recent(recent_text: str, token_limit: int) -> str: + seen: set[str] = set() + retained: list[str] = [] + for raw_line in str(recent_text or "").splitlines(): + line = raw_line.strip() + if not line: + continue + normalized = re.sub(r"\s+", " ", line).casefold() + if normalized in seen or normalized.startswith(DROP_RECENT_PREFIXES): + continue + seen.add(normalized) + retained.append(line) + if not retained: + return "" + summary = "RECENT CONTEXT, COMPACTED\n" + "\n".join(f"- {line}" for line in retained) + return _bounded_text(summary, token_limit, keep_tail=True) + + def compact_brain( + self, + project_id: str, + *, + force: bool = False, + reason: str = "threshold", + ) -> dict[str, Any]: + with self._lock_for(project_id), self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if not row: + raise ProjectContextError(f"BRAIN for '{project_id}' was not initialized") + if row["deleted_at"]: + raise ProjectContextError("Deleted BRAIN content cannot be compacted") + if not force and row["token_count"] < row["compact_threshold"]: + response = self._brain_response(row) + response["compacted"] = False + return response + + protected_tokens = estimate_tokens(row["pinned_text"]) + estimate_tokens(row["active_text"]) + available_recent = max(0, int(row["compact_threshold"]) - protected_tokens - 48) + compacted_recent = self._compact_recent(row["recent_text"], available_recent) + token_count = estimate_tokens( + "\n".join((row["pinned_text"], row["active_text"], compacted_recent)) + ) + revision = int(row["revision"]) + 1 + now = _timestamp() + updated_count = connection.execute( + """ + UPDATE brain_states + SET recent_text = ?, token_count = ?, revision = ?, updated_at = ?, + last_compacted_at = ? + WHERE project_id = ? AND revision = ? + """, + ( + compacted_recent, + token_count, + revision, + now, + now, + project_id, + row["revision"], + ), + ).rowcount + if updated_count != 1: + raise RevisionConflictError("BRAIN changed while compaction was running") + updated = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (project_id, revision, json.dumps(self._brain_snapshot(updated)), reason, now), + ) + response = self._brain_response(updated) + response["compacted"] = True + return response + + def list_brain_revisions(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT revision, reason, created_at, snapshot_json + FROM brain_revisions + WHERE project_id = ? + ORDER BY revision DESC + """, + (project_id,), + ).fetchall() + return [ + { + "revision": row["revision"], + "reason": row["reason"], + "created_at": row["created_at"], + "token_count": json.loads(row["snapshot_json"]).get("token_count", 0), + } + for row in rows + ] + + def restore_brain(self, project_id: str, revision: int) -> dict[str, Any]: + with self._lock_for(project_id), self._connect() as connection: + current = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + target = connection.execute( + "SELECT snapshot_json FROM brain_revisions WHERE project_id = ? AND revision = ?", + (project_id, int(revision)), + ).fetchone() + if not current or not target: + raise ProjectContextError(f"BRAIN revision {revision} was not found") + snapshot = json.loads(target["snapshot_json"]) + new_revision = int(current["revision"]) + 1 + now = _timestamp() + connection.execute( + """ + UPDATE brain_states + SET pinned_text = ?, active_text = ?, recent_text = ?, compact_threshold = ?, + token_count = ?, revision = ?, updated_at = ?, last_compacted_at = ?, + deleted_at = NULL + WHERE project_id = ? + """, + ( + snapshot.get("pinned_text", ""), + snapshot.get("active_text", ""), + snapshot.get("recent_text", ""), + snapshot.get("compact_threshold", self.default_brain_threshold), + snapshot.get("token_count", 0), + new_revision, + now, + snapshot.get("last_compacted_at"), + project_id, + ), + ) + updated = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + project_id, + new_revision, + json.dumps(self._brain_snapshot(updated)), + f"restored revision {revision}", + now, + ), + ) + return self._brain_response(updated) + + def soft_delete_brain(self, project_id: str) -> dict[str, Any]: + with self._lock_for(project_id), self._connect() as connection: + row = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + if not row: + raise ProjectContextError(f"BRAIN for '{project_id}' was not initialized") + revision = int(row["revision"]) + 1 + now = _timestamp() + connection.execute( + "UPDATE brain_states SET revision = ?, updated_at = ?, deleted_at = ? WHERE project_id = ?", + (revision, now, now, project_id), + ) + updated = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, ?, ?, 'soft deleted', ?) + """, + (project_id, revision, json.dumps(self._brain_snapshot(updated)), now), + ) + return self._brain_response(updated) + + def purge_expired_brains(self, recovery_days: int) -> list[str]: + """Permanently clear soft-deleted content after its recovery window.""" + cutoff = (datetime.now() - timedelta(days=max(1, recovery_days))).isoformat() + purged: list[str] = [] + with self._connect() as connection: + rows = connection.execute( + "SELECT project_id FROM brain_states WHERE deleted_at IS NOT NULL AND deleted_at < ?", + (cutoff,), + ).fetchall() + for row in rows: + project_id = row["project_id"] + now = _timestamp() + connection.execute( + "DELETE FROM brain_revisions WHERE project_id = ?", + (project_id,), + ) + connection.execute( + """ + UPDATE brain_states + SET pinned_text = '', active_text = '', recent_text = '', token_count = 0, + revision = 1, updated_at = ?, last_compacted_at = NULL, deleted_at = NULL + WHERE project_id = ? + """, + (now, project_id), + ) + reset = connection.execute( + "SELECT * FROM brain_states WHERE project_id = ?", + (project_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO brain_revisions + (project_id, revision, snapshot_json, reason, created_at) + VALUES (?, 1, ?, 'recovery window expired', ?) + """, + (project_id, json.dumps(self._brain_snapshot(reset)), now), + ) + purged.append(project_id) + return purged + + def _is_text_upload(self, display_name: str, media_type: str) -> bool: + return media_type.startswith("text/") or Path(display_name).suffix.lower() in TEXT_EXTENSIONS + + @staticmethod + def _chunks(text: str, size: int = 6_000) -> list[str]: + if not text: + return [] + return [text[index : index + size] for index in range(0, len(text), size)] + + def add_file( + self, + project_id: str, + *, + display_name: str, + media_type: str | None, + content: bytes, + ) -> dict[str, Any]: + safe_name = Path(display_name or "reference").name.replace("\x00", "").strip() + if not safe_name: + raise ProjectContextError("Upload filename is required") + detected_type = media_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream" + extracted = "" + status = "stored_unindexed" + if self._is_text_upload(safe_name, detected_type): + try: + extracted = content.decode("utf-8") + status = "indexed" if extracted.strip() else "empty" + except UnicodeDecodeError: + status = "decode_failed" + file_id = f"file_{uuid.uuid4().hex}" + suffix = Path(safe_name).suffix[:16] + stored_name = f"{file_id}{suffix}" + project_root = self.uploads_root / project_id + project_root.mkdir(parents=True, exist_ok=True) + destination = project_root / stored_name + temporary = destination.with_suffix(f"{destination.suffix}.tmp") + temporary.write_bytes(content) + temporary.replace(destination) + now = _timestamp() + sha256 = hashlib.sha256(content).hexdigest() + chunks = self._chunks(extracted) + try: + with self._connect() as connection: + connection.execute( + """ + INSERT INTO project_files + (file_id, project_id, display_name, stored_name, media_type, + size_bytes, sha256, status, extracted_text, token_count, attached, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + """, + ( + file_id, + project_id, + safe_name, + stored_name, + detected_type, + len(content), + sha256, + status, + extracted, + estimate_tokens(extracted), + now, + now, + ), + ) + connection.executemany( + """ + INSERT INTO project_file_chunks (file_id, chunk_index, content, token_count) + VALUES (?, ?, ?, ?) + """, + [ + (file_id, index, chunk, estimate_tokens(chunk)) + for index, chunk in enumerate(chunks) + ], + ) + except Exception: + destination.unlink(missing_ok=True) + raise + return self.get_file(project_id, file_id) + + def _file_response(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + return { + "file_id": row["file_id"], + "project_id": row["project_id"], + "display_name": row["display_name"], + "media_type": row["media_type"], + "size_bytes": row["size_bytes"], + "sha256": row["sha256"], + "status": row["status"], + "token_count": row["token_count"], + "attached": bool(row["attached"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def get_file(self, project_id: str, file_id: str) -> dict[str, Any]: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM project_files WHERE project_id = ? AND file_id = ?", + (project_id, file_id), + ).fetchone() + if not row: + raise ProjectContextError(f"Project file '{file_id}' was not found") + return self._file_response(row) + + def list_files(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + "SELECT * FROM project_files WHERE project_id = ? ORDER BY created_at DESC", + (project_id,), + ).fetchall() + return [self._file_response(row) for row in rows] + + def delete_file(self, project_id: str, file_id: str) -> None: + with self._connect() as connection: + row = connection.execute( + "SELECT stored_name FROM project_files WHERE project_id = ? AND file_id = ?", + (project_id, file_id), + ).fetchone() + if not row: + raise ProjectContextError(f"Project file '{file_id}' was not found") + connection.execute( + "DELETE FROM project_files WHERE project_id = ? AND file_id = ?", + (project_id, file_id), + ) + (self.uploads_root / project_id / row["stored_name"]).unlink(missing_ok=True) + + def set_file_attached( + self, + project_id: str, + file_id: str, + attached: bool, + ) -> dict[str, Any]: + with self._connect() as connection: + updated = connection.execute( + """ + UPDATE project_files SET attached = ?, updated_at = ? + WHERE project_id = ? AND file_id = ? + """, + (int(attached), _timestamp(), project_id, file_id), + ).rowcount + if not updated: + raise ProjectContextError(f"Project file '{file_id}' was not found") + return self.get_file(project_id, file_id) + + def reindex_file(self, project_id: str, file_id: str) -> dict[str, Any]: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM project_files WHERE project_id = ? AND file_id = ?", + (project_id, file_id), + ).fetchone() + if not row: + raise ProjectContextError(f"Project file '{file_id}' was not found") + source = self.uploads_root / project_id / row["stored_name"] + try: + content = source.read_bytes() + except OSError as exc: + raise ProjectContextError(f"Stored project file cannot be read: {exc}") from exc + extracted = "" + status = "stored_unindexed" + if self._is_text_upload(row["display_name"], row["media_type"]): + try: + extracted = content.decode("utf-8") + status = "indexed" if extracted.strip() else "empty" + except UnicodeDecodeError: + status = "decode_failed" + chunks = self._chunks(extracted) + with self._connect() as connection: + connection.execute( + """ + UPDATE project_files + SET status = ?, extracted_text = ?, token_count = ?, updated_at = ? + WHERE project_id = ? AND file_id = ? + """, + ( + status, + extracted, + estimate_tokens(extracted), + _timestamp(), + project_id, + file_id, + ), + ) + connection.execute( + "DELETE FROM project_file_chunks WHERE file_id = ?", + (file_id,), + ) + connection.executemany( + """ + INSERT INTO project_file_chunks (file_id, chunk_index, content, token_count) + VALUES (?, ?, ?, ?) + """, + [ + (file_id, index, chunk, estimate_tokens(chunk)) + for index, chunk in enumerate(chunks) + ], + ) + return self.get_file(project_id, file_id) + + def add_artifact( + self, + project_id: str, + *, + title: str, + body: str, + kind: str = "assistant_output", + conversation_id: str | None = None, + source_message_index: int | None = None, + pinned: bool = False, + ) -> dict[str, Any]: + now = _timestamp() + artifact_id = f"artifact_{uuid.uuid4().hex}" + with self._connect() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO project_artifacts + (artifact_id, project_id, conversation_id, source_message_index, + title, kind, body, token_count, pinned, archived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + """, + ( + artifact_id, + project_id, + conversation_id, + source_message_index, + title.strip() or "Assistant output", + kind, + body, + estimate_tokens(body), + int(pinned), + now, + now, + ), + ) + if conversation_id is not None and source_message_index is not None: + row = connection.execute( + """ + SELECT * FROM project_artifacts + WHERE project_id = ? AND conversation_id = ? AND source_message_index = ? + """, + (project_id, conversation_id, source_message_index), + ).fetchone() + else: + row = connection.execute( + "SELECT * FROM project_artifacts WHERE artifact_id = ?", + (artifact_id,), + ).fetchone() + return self._artifact_response(row, include_body=True) + + @staticmethod + def _artifact_response( + row: sqlite3.Row | dict[str, Any], + *, + include_body: bool = False, + ) -> dict[str, Any]: + body = str(row["body"] or "") + result = { + "artifact_id": row["artifact_id"], + "project_id": row["project_id"], + "conversation_id": row["conversation_id"], + "source_message_index": row["source_message_index"], + "title": row["title"], + "kind": row["kind"], + "token_count": row["token_count"], + "pinned": bool(row["pinned"]), + "archived": bool(row["archived"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "preview": body[:240], + } + if include_body: + result["body"] = body + return result + + def list_artifacts( + self, + project_id: str, + *, + include_archived: bool = False, + ) -> list[dict[str, Any]]: + where = "project_id = ?" if include_archived else "project_id = ? AND archived = 0" + with self._connect() as connection: + rows = connection.execute( + f"SELECT * FROM project_artifacts WHERE {where} ORDER BY pinned DESC, created_at DESC", + (project_id,), + ).fetchall() + return [self._artifact_response(row) for row in rows] + + def get_artifact(self, project_id: str, artifact_id: str) -> dict[str, Any]: + with self._connect() as connection: + row = connection.execute( + "SELECT * FROM project_artifacts WHERE project_id = ? AND artifact_id = ?", + (project_id, artifact_id), + ).fetchone() + if not row: + raise ProjectContextError(f"Project artifact '{artifact_id}' was not found") + return self._artifact_response(row, include_body=True) + + def update_artifact( + self, + project_id: str, + artifact_id: str, + *, + pinned: bool | None = None, + archived: bool | None = None, + title: str | None = None, + ) -> dict[str, Any]: + existing = self.get_artifact(project_id, artifact_id) + with self._connect() as connection: + connection.execute( + """ + UPDATE project_artifacts + SET pinned = ?, archived = ?, title = ?, updated_at = ? + WHERE project_id = ? AND artifact_id = ? + """, + ( + int(existing["pinned"] if pinned is None else pinned), + int(existing["archived"] if archived is None else archived), + existing["title"] if title is None else (title.strip() or existing["title"]), + _timestamp(), + project_id, + artifact_id, + ), + ) + return self.get_artifact(project_id, artifact_id) + + def delete_artifact(self, project_id: str, artifact_id: str) -> None: + with self._connect() as connection: + deleted = connection.execute( + "DELETE FROM project_artifacts WHERE project_id = ? AND artifact_id = ?", + (project_id, artifact_id), + ).rowcount + if not deleted: + raise ProjectContextError(f"Project artifact '{artifact_id}' was not found") + + def _brain_context(self, project_id: str, token_limit: int) -> tuple[str, int]: + brain = self.get_brain(project_id) + if brain["deleted_at"]: + return "", 0 + pinned = brain["pinned_text"] + active = brain["active_text"] + protected = "" + if pinned.strip(): + protected += f"PINNED FACTS AND DECISIONS, VERBATIM\n{pinned.strip()}" + if active.strip(): + protected += ("\n\n" if protected else "") + f"ACTIVE GOALS, CONTRACTS, AND RISKS\n{active.strip()}" + protected_tokens = estimate_tokens(protected) + if protected_tokens > token_limit: + raise ContextBudgetError( + "Pinned and active BRAIN content exceed the protected BRAIN allocation" + ) + recent_limit = max(0, token_limit - protected_tokens - 12) + recent = _bounded_text(brain["recent_text"].strip(), recent_limit, keep_tail=True) + content = protected + if recent: + content += ("\n\n" if content else "") + f"RECENT PROJECT CONTEXT\n{recent}" + return content, estimate_tokens(content) + + def _file_context(self, project_id: str, query: str, token_limit: int) -> tuple[str, int]: + terms = _query_terms(query) + with self._connect() as connection: + rows = connection.execute( + """ + SELECT c.content, c.token_count, c.chunk_index, f.display_name, f.created_at + FROM project_file_chunks c + JOIN project_files f ON f.file_id = c.file_id + WHERE f.project_id = ? AND f.status = 'indexed' AND f.attached = 1 + """, + (project_id,), + ).fetchall() + ranked = sorted( + rows, + key=lambda row: (_rank_text(row["content"], terms), row["created_at"]), + reverse=True, + ) + blocks: list[str] = [] + remaining = token_limit + for row in ranked: + label = f"[File: {row['display_name']}#{row['chunk_index'] + 1}]\n" + label_tokens = estimate_tokens(label) + if remaining <= label_tokens: + break + body = _bounded_text(row["content"], remaining - label_tokens) + if not body: + continue + blocks.append(f"{label}{body}") + remaining -= estimate_tokens(blocks[-1]) + if remaining <= 0: + break + content = "\n\n".join(blocks) + return content, estimate_tokens(content) + + def _artifact_context(self, project_id: str, query: str, token_limit: int) -> tuple[str, int]: + terms = _query_terms(query) + with self._connect() as connection: + rows = connection.execute( + "SELECT * FROM project_artifacts WHERE project_id = ? AND archived = 0", + (project_id,), + ).fetchall() + ranked = sorted( + rows, + key=lambda row: ( + int(row["pinned"]), + _rank_text(f"{row['title']}\n{row['body']}", terms), + row["created_at"], + ), + reverse=True, + ) + blocks: list[str] = [] + remaining = token_limit + for row in ranked: + label = f"[Artifact: {row['title']}]\n" + label_tokens = estimate_tokens(label) + if remaining <= label_tokens: + break + body = _bounded_text(row["body"], remaining - label_tokens) + if not body: + continue + blocks.append(f"{label}{body}") + remaining -= estimate_tokens(blocks[-1]) + if remaining <= 0: + break + content = "\n\n".join(blocks) + return content, estimate_tokens(content) + + def build_context_messages( + self, + project_id: str, + *, + query: str, + available_tokens: int | None = None, + ) -> dict[str, Any]: + profile = self.get_profile(project_id) + configured_budget = int(profile["context_budget_tokens"]) + total_budget = min(configured_budget, int(available_tokens or configured_budget)) + if total_budget < 1_024: + raise ContextBudgetError("The selected model has too little room for project context") + quotas = component_quotas(total_budget) + instruction_tokens = estimate_tokens(profile["instructions"]) + if instruction_tokens > quotas["project_instructions"]: + raise ContextBudgetError( + "Project instructions exceed their 25 percent context allocation for this request" + ) + brain_limit = quotas["brain"] + max( + 0, + quotas["project_instructions"] - instruction_tokens, + ) + brain, brain_tokens = self._brain_context(project_id, brain_limit) + file_limit = quotas["file_context"] + max(0, brain_limit - brain_tokens) + files, file_tokens = self._file_context(project_id, query, file_limit) + artifact_limit = quotas["artifact_history"] + max( + 0, + file_limit - file_tokens, + ) + artifacts, artifact_tokens = self._artifact_context( + project_id, query, artifact_limit + ) + messages = [] + if brain: + messages.append({"role": "system", "content": f"BRAIN PROJECT CONTEXT\n{brain}"}) + if files: + messages.append({"role": "system", "content": f"PROJECT FILE CONTEXT\n{files}"}) + if artifacts: + messages.append({"role": "system", "content": f"PROJECT ARTIFACT HISTORY\n{artifacts}"}) + return { + "messages": messages, + "budget": { + "configured_total": configured_budget, + "request_total": total_budget, + "quotas": quotas, + "available_limits": { + "project_instructions": quotas["project_instructions"], + "brain": brain_limit, + "file_context": file_limit, + "artifact_history": artifact_limit, + }, + "usage": { + "project_instructions": instruction_tokens, + "brain": brain_tokens, + "file_context": file_tokens, + "artifact_history": artifact_tokens, + }, + "unused_tokens": max(0, artifact_limit - artifact_tokens), + }, + } + + def homepage(self, project_id: str) -> dict[str, Any]: + profile = self.get_profile(project_id) + budget = int(profile["context_budget_tokens"]) + quotas = component_quotas(budget) + brain = self.get_brain(project_id) + files = self.list_files(project_id) + artifacts = self.list_artifacts(project_id) + return { + "project_id": project_id, + "context_budget": { + "total": budget, + "quotas": quotas, + "usage": { + "project_instructions": estimate_tokens(profile["instructions"]), + "brain": brain["token_count"], + "file_context": sum( + item["token_count"] for item in files if item["attached"] + ), + "artifact_history": sum(item["token_count"] for item in artifacts), + }, + }, + "components": { + "project_instructions": { + "content": profile["instructions"], + "token_count": estimate_tokens(profile["instructions"]), + "updated_at": profile["updated_at"], + }, + "file_context_uploads": files, + "artifact_history": artifacts, + "brain": brain, + }, + } diff --git a/scripts/project_context_cli.py b/scripts/project_context_cli.py new file mode 100755 index 0000000..af510ac --- /dev/null +++ b/scripts/project_context_cli.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Authenticated local CLI for inspecting and operating project BRAIN state.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +def request_json( + api_base: str, + api_key: str, + method: str, + path: str, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + body = json.dumps(payload).encode("utf-8") if payload is not None else None + headers = {"X-API-Key": api_key, "Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + request = Request( + f"{api_base.rstrip('/')}{path}", + data=body, + headers=headers, + method=method, + ) + try: + with urlopen(request, timeout=30) as response: + data = response.read().decode("utf-8") + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise RuntimeError(f"DaveLLM API unavailable: {exc.reason}") from exc + return json.loads(data) if data else {} + + +def read_value(value: str | None, file_path: str | None) -> str | None: + if file_path: + return Path(file_path).read_text(encoding="utf-8") + return value + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Read, edit, pin, compact, delete, and restore project BRAIN state.", + ) + parser.add_argument( + "--api-base", + default=os.getenv("DAVE_API_BASE", "http://127.0.0.1:8000"), + help="DaveLLM router origin (default: %(default)s)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + for name in ("show", "compact", "delete", "revisions"): + command = subparsers.add_parser(name) + command.add_argument("project_id") + + restore = subparsers.add_parser("restore") + restore.add_argument("project_id") + restore.add_argument("revision", type=int) + + edit = subparsers.add_parser("edit") + edit.add_argument("project_id") + pinned_source = edit.add_mutually_exclusive_group() + pinned_source.add_argument("--pinned") + pinned_source.add_argument("--pinned-file") + active_source = edit.add_mutually_exclusive_group() + active_source.add_argument("--active") + active_source.add_argument("--active-file") + recent_source = edit.add_mutually_exclusive_group() + recent_source.add_argument("--recent") + recent_source.add_argument("--recent-file") + edit.add_argument("--compact-threshold", type=int) + edit.add_argument("--expected-revision", type=int) + + pin = subparsers.add_parser("pin") + pin.add_argument("project_id") + pin_source = pin.add_mutually_exclusive_group(required=True) + pin_source.add_argument("--text") + pin_source.add_argument("--file") + return parser + + +def run(args: argparse.Namespace, api_key: str) -> dict[str, Any]: + project = quote(args.project_id, safe="") + brain_path = f"/projects/{project}/brain" + if args.command == "show": + return request_json(args.api_base, api_key, "GET", brain_path) + if args.command == "compact": + return request_json(args.api_base, api_key, "POST", f"{brain_path}/compact") + if args.command == "delete": + return request_json(args.api_base, api_key, "DELETE", brain_path) + if args.command == "revisions": + return request_json(args.api_base, api_key, "GET", f"{brain_path}/revisions") + if args.command == "restore": + return request_json( + args.api_base, + api_key, + "POST", + f"{brain_path}/revisions/{args.revision}/restore", + ) + if args.command == "pin": + current = request_json(args.api_base, api_key, "GET", brain_path) + addition = read_value(args.text, args.file) or "" + prior = str(current.get("pinned_text") or "").rstrip() + pinned = f"{prior}\n{addition.strip()}".strip() + return request_json( + args.api_base, + api_key, + "PUT", + brain_path, + { + "pinned_text": pinned, + "expected_revision": current["revision"], + }, + ) + if args.command == "edit": + payload: dict[str, Any] = {} + field_sources = ( + ("pinned_text", args.pinned, args.pinned_file), + ("active_text", args.active, args.active_file), + ("recent_text", args.recent, args.recent_file), + ) + for field, value, file_path in field_sources: + resolved = read_value(value, file_path) + if resolved is not None: + payload[field] = resolved + if args.compact_threshold is not None: + payload["compact_threshold"] = args.compact_threshold + if args.expected_revision is not None: + payload["expected_revision"] = args.expected_revision + if not payload: + raise RuntimeError("edit requires at least one value or threshold") + return request_json(args.api_base, api_key, "PUT", brain_path, payload) + raise RuntimeError(f"Unsupported command: {args.command}") + + +def main() -> int: + args = build_parser().parse_args() + api_key = os.getenv("DAVE_API_KEY", "") + if not api_key: + print("DAVE_API_KEY is required", file=sys.stderr) + return 2 + try: + result = run(args, api_key) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + print(str(exc), file=sys.stderr) + return 1 + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/app.js b/static/app.js index 06ce5a2..02fc2f4 100644 --- a/static/app.js +++ b/static/app.js @@ -31,7 +31,8 @@ const state = { // --------------------------------------------- // CONSTANTS // --------------------------------------------- -const ROUTER_BASE = (typeof window !== "undefined" && window.__API_BASE__) || "http://127.0.0.1:8000"; +const ROUTER_BASE = (typeof window !== "undefined" && window.__API_BASE__) + || (typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:8000"); const LOCAL_STORAGE_KEY = "dave_convos"; const LAST_SESSION_KEY = "dave_last_session"; const API_KEY_SESSION_KEY = "dave_api_key_session"; @@ -71,6 +72,9 @@ let notepadSaveTimer = null; let notepadLoadedProjectId = null; let notepadLoading = false; let instructionPreviewMode = "layered"; +let projectHomeData = null; +let projectHomeProjectId = null; +let projectHomeMotion = null; function authHeaders(extra = {}) { const headers = { ...extra }; @@ -276,6 +280,40 @@ const notepadInput = document.getElementById("notepadInput"); const notepadStatus = document.getElementById("notepadStatus"); const notepadProjectName = document.getElementById("notepadProjectName"); const sendNotepadBtn = document.getElementById("sendNotepadBtn"); +const projectHomeDialog = document.getElementById("projectHomeDialog"); +const projectHomeClose = document.getElementById("projectHomeClose"); +const projectHomeHeading = document.getElementById("projectHomeHeading"); +const projectHomeDescription = document.getElementById("projectHomeDescription"); +const projectAttachmentState = document.getElementById("projectAttachmentState"); +const attachProjectChatBtn = document.getElementById("attachProjectChat"); +const startProjectChatBtn = document.getElementById("startProjectChat"); +const projectContextBudget = document.getElementById("projectContextBudget"); +const contextBudgetMeter = document.getElementById("contextBudgetMeter"); +const projectHomeInstructions = document.getElementById("projectHomeInstructions"); +const projectHomeInstructionsCount = document.getElementById("projectHomeInstructionsCount"); +const saveProjectInstructionsBtn = document.getElementById("saveProjectInstructions"); +const projectFileForm = document.getElementById("projectFileForm"); +const projectFileInput = document.getElementById("projectFileInput"); +const projectFilesStatus = document.getElementById("projectFilesStatus"); +const projectFilesList = document.getElementById("projectFilesList"); +const projectArtifactsStatus = document.getElementById("projectArtifactsStatus"); +const projectArtifactsList = document.getElementById("projectArtifactsList"); +const projectArtifactPreview = document.getElementById("projectArtifactPreview"); +const brainPinned = document.getElementById("brainPinned"); +const brainActive = document.getElementById("brainActive"); +const brainRecent = document.getElementById("brainRecent"); +const brainThreshold = document.getElementById("brainThreshold"); +const brainRevision = document.getElementById("brainRevision"); +const brainRevisionSelect = document.getElementById("brainRevisionSelect"); +const projectBrainStatus = document.getElementById("projectBrainStatus"); +const saveProjectBrainBtn = document.getElementById("saveProjectBrain"); +const compactProjectBrainBtn = document.getElementById("compactProjectBrain"); +const restoreProjectBrainBtn = document.getElementById("restoreProjectBrain"); +const deleteProjectBrainBtn = document.getElementById("deleteProjectBrain"); +const previewProjectContextBtn = document.getElementById("previewProjectContext"); +const projectContextPreviewStatus = document.getElementById("projectContextPreviewStatus"); +const projectContextPreviewOutput = document.getElementById("projectContextPreviewOutput"); +const projectHomeStatus = document.getElementById("projectHomeStatus"); const mobileTabButtons = Array.from(document.querySelectorAll("[data-mobile-tab]")); const mobilePanels = Array.from(document.querySelectorAll("[data-mobile-panel]")); @@ -286,6 +324,20 @@ function routerEndpoint(path) { return `${ROUTER_BASE}${path}`; } +function setLucideIcon(button, iconId) { + if (!button) return; + let svg = button.querySelector("svg"); + let use = svg?.querySelector("use"); + if (!svg || !use) { + svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("aria-hidden", "true"); + use = document.createElementNS("http://www.w3.org/2000/svg", "use"); + svg.appendChild(use); + button.replaceChildren(svg); + } + use.setAttribute("href", `vendor/lucide/lucide.svg#${iconId}`); +} + function readAnticipationRecord() { try { const raw = localStorage.getItem(ANTICIPATION_STORAGE_KEY); @@ -774,7 +826,7 @@ function initDictation() { isDictating = true; setDictationStatus("Listening…"); if (dictateBtn) { - dictateBtn.textContent = "⏹️"; + setLucideIcon(dictateBtn, "square"); dictateBtn.setAttribute("aria-label", "Stop dictation"); } }; @@ -810,7 +862,7 @@ function initDictation() { speechRecognition.onend = () => { isDictating = false; if (dictateBtn) { - dictateBtn.textContent = "🎙️"; + setLucideIcon(dictateBtn, "mic"); dictateBtn.setAttribute("aria-label", "Dictate using microphone"); } if (!dictateStatus || !dictateStatus.textContent.includes("error")) { @@ -950,7 +1002,7 @@ function startFallbackRecording() { mediaRecorder.onstart = () => { isRecordingFallback = true; if (dictateBtn) { - dictateBtn.textContent = "⏹️"; + setLucideIcon(dictateBtn, "square"); dictateBtn.setAttribute("aria-label", "Stop recording"); } setDictationStatus("Recording… tap again to stop"); @@ -988,7 +1040,7 @@ function startFallbackRecording() { function stopFallbackStream() { isRecordingFallback = false; if (dictateBtn) { - dictateBtn.textContent = "🎙️"; + setLucideIcon(dictateBtn, "mic"); dictateBtn.setAttribute("aria-label", "Dictate using microphone"); } if (mediaStream) { @@ -1516,31 +1568,710 @@ async function resyncConversationInstructions() { } } -async function editProjectInstructions() { - const targetProject = selectedProjectId; - if (!targetProject) { - alert("Select a project to edit its instructions."); +async function apiError(response) { + const text = await response.text(); + try { + const parsed = JSON.parse(text); + return parsed.detail || text || `HTTP ${response.status}`; + } catch (_error) { + return text || `HTTP ${response.status}`; + } +} + +function projectBudgetQuotas(totalValue) { + const total = Math.max(0, Number.parseInt(totalValue, 10) || 0); + const instructions = Math.floor(total * 0.25); + const brain = Math.floor(total * 0.25); + const files = Math.round(total * 0.30); + return { + project_instructions: instructions, + brain, + file_context: files, + artifact_history: total - instructions - brain - files + }; +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function createProjectEmptyState(message) { + const empty = document.createElement("div"); + empty.className = "project-empty-state"; + empty.textContent = message; + return empty; +} + +function createRecordButton(label, action, className = "text-btn") { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.addEventListener("click", action); + return button; +} + +function resetProjectHomeMotion() { + if (projectHomeMotion) { + projectHomeMotion.revert(); + projectHomeMotion = null; + } +} + +function animateProjectHomepageIn() { + if (!window.gsap || !projectHomeDialog?.open) return; + resetProjectHomeMotion(); + projectHomeMotion = window.gsap.matchMedia(); + const targets = projectHomeDialog.querySelectorAll( + ".project-home-header, .project-home-toolbar, .context-budget-panel, .project-component, .project-context-preview, .project-home-ambient span", + ); + projectHomeMotion.add("(prefers-reduced-motion: no-preference)", () => { + const timeline = window.gsap.timeline({ + defaults: { ease: "power3.out" }, + }); + timeline + .fromTo( + projectHomeDialog.querySelectorAll(".project-home-ambient span"), + { autoAlpha: 0, scale: 0.72 }, + { autoAlpha: 0.28, scale: 1, duration: 0.9, stagger: 0.1 }, + 0, + ) + .fromTo( + projectHomeDialog.querySelector(".project-home-header"), + { autoAlpha: 0, y: -14 }, + { autoAlpha: 1, y: 0, duration: 0.34 }, + 0.03, + ) + .fromTo( + projectHomeDialog.querySelectorAll(".project-home-toolbar, .context-budget-panel"), + { autoAlpha: 0, y: 12 }, + { autoAlpha: 1, y: 0, duration: 0.38, stagger: 0.06 }, + 0.12, + ) + .fromTo( + projectHomeDialog.querySelectorAll(".project-component"), + { autoAlpha: 0, y: 22, scale: 0.985 }, + { + autoAlpha: 1, + y: 0, + scale: 1, + duration: 0.46, + stagger: 0.065, + clearProps: "transform,opacity,visibility", + }, + 0.2, + ) + .fromTo( + projectHomeDialog.querySelectorAll(".component-number"), + { autoAlpha: 0, scale: 0.65, rotation: -16 }, + { + autoAlpha: 1, + scale: 1, + rotation: 0, + duration: 0.3, + stagger: 0.055, + ease: "back.out(1.45)", + clearProps: "transform,opacity,visibility", + }, + 0.28, + ) + .fromTo( + projectHomeDialog.querySelectorAll(".budget-segment"), + { autoAlpha: 0, scaleX: 0.15, transformOrigin: "left center" }, + { + autoAlpha: 1, + scaleX: 1, + duration: 0.34, + stagger: 0.045, + clearProps: "transform,opacity,visibility", + }, + 0.3, + ) + .fromTo( + projectHomeDialog.querySelector(".project-context-preview"), + { autoAlpha: 0, y: 10 }, + { + autoAlpha: 1, + y: 0, + duration: 0.3, + clearProps: "transform,opacity,visibility", + }, + 0.46, + ); + return () => timeline.kill(); + }); + projectHomeMotion.add("(prefers-reduced-motion: reduce)", () => { + window.gsap.set(targets, { clearProps: "all" }); + }); +} + +function animateProjectContextPreview() { + if (!window.gsap || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + window.gsap.fromTo( + projectContextPreviewOutput, + { autoAlpha: 0, y: 12 }, + { + autoAlpha: 1, + y: 0, + duration: 0.3, + ease: "power3.out", + clearProps: "transform,opacity,visibility", + }, + ); +} + +function renderProjectBudget() { + if (!projectHomeData) return; + const total = Number.parseInt(projectContextBudget.value, 10) + || projectHomeData.context_budget.total; + const quotas = projectBudgetQuotas(total); + const usage = projectHomeData.context_budget.usage; + const segments = [ + ["Instructions", "project_instructions", 25], + ["BRAIN", "brain", 25], + ["Files", "file_context", 30], + ["Artifacts", "artifact_history", 20] + ]; + contextBudgetMeter.replaceChildren(); + segments.forEach(([label, key, percentage]) => { + const segment = document.createElement("div"); + segment.className = "budget-segment"; + segment.style.flexBasis = `${percentage}%`; + segment.textContent = `${label} ${percentage}%\n${usage[key].toLocaleString()} / ${quotas[key].toLocaleString()}`; + segment.title = `${label}: ${usage[key].toLocaleString()} used of ${quotas[key].toLocaleString()} tokens`; + contextBudgetMeter.appendChild(segment); + }); + const instructionTokens = estimateInstructionTokens(projectHomeInstructions.value); + projectHomeInstructionsCount.textContent = `${instructionTokens.toLocaleString()} of ${quotas.project_instructions.toLocaleString()} tokens`; + projectHomeInstructionsCount.classList.toggle("over-budget", instructionTokens > quotas.project_instructions); + brainThreshold.max = quotas.brain + Math.max(0, quotas.project_instructions - instructionTokens); +} + +function renderProjectAttachmentState() { + const conversation = currentConversation(); + if (!conversation) { + projectAttachmentState.textContent = "No active chat. Start one here or attach an existing chat later."; + attachProjectChatBtn.disabled = true; + attachProjectChatBtn.textContent = "Attach active chat"; + return; + } + attachProjectChatBtn.disabled = false; + if (conversation.project_id === projectHomeProjectId) { + projectAttachmentState.textContent = `“${conversation.title || "Current chat"}” is attached. Project context applies to future messages.`; + attachProjectChatBtn.textContent = "Detach active chat"; + } else { + const attached = projects.find((item) => item.project_id === conversation.project_id); + projectAttachmentState.textContent = conversation.project_id + ? `“${conversation.title || "Current chat"}” is attached to ${attached?.name || "another project"}.` + : `“${conversation.title || "Current chat"}” is a General chat with no project.`; + attachProjectChatBtn.textContent = "Attach active chat"; + } +} + +function renderProjectFiles(files) { + projectFilesList.replaceChildren(); + if (!files.length) { + projectFilesList.appendChild(createProjectEmptyState( + "No references yet. Upload UTF-8 text or code to make it eligible for request context. Other files remain stored but unindexed." + )); return; } - const proj = projects.find(p => p.project_id === targetProject); - const newPrompt = prompt("Project system instructions:", proj?.system_prompt || ""); - if (newPrompt === null) return; - const newModel = prompt("Preferred model (optional):", proj?.preferred_model || "") || undefined; + files.forEach((file) => { + const record = document.createElement("div"); + record.className = "project-record"; + const copy = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = file.display_name; + const meta = document.createElement("small"); + meta.textContent = `${file.attached ? "attached" : "detached"} · ${file.status.replaceAll("_", " ")} · ${formatBytes(file.size_bytes)} · ${file.token_count.toLocaleString()} tokens`; + copy.appendChild(title); + copy.appendChild(meta); + const actions = document.createElement("div"); + actions.className = "project-record-actions"; + actions.appendChild(createRecordButton("Reindex", () => reindexProjectReference(file))); + actions.appendChild(createRecordButton( + file.attached ? "Detach" : "Attach", + () => setProjectReferenceAttached(file, !file.attached) + )); + actions.appendChild(createRecordButton("Delete", () => deleteProjectReference(file), "text-btn danger-text")); + record.appendChild(copy); + record.appendChild(actions); + projectFilesList.appendChild(record); + }); +} + +function renderProjectArtifacts(artifacts) { + projectArtifactsList.replaceChildren(); + projectArtifactPreview.hidden = true; + if (!artifacts.length) { + projectArtifactsList.appendChild(createProjectEmptyState( + "No outputs yet. Assistant responses from attached chats appear here automatically." + )); + return; + } + artifacts.forEach((artifact) => { + const record = document.createElement("div"); + record.className = "project-record"; + const copy = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = `${artifact.pinned ? "Pinned · " : ""}${artifact.title}`; + const meta = document.createElement("small"); + meta.textContent = `${artifact.kind.replaceAll("_", " ")} · ${artifact.token_count.toLocaleString()} tokens`; + copy.appendChild(title); + copy.appendChild(meta); + const actions = document.createElement("div"); + actions.className = "project-record-actions"; + actions.appendChild(createRecordButton("Open", () => openProjectArtifact(artifact.artifact_id))); + actions.appendChild(createRecordButton( + artifact.pinned ? "Unpin" : "Pin", + () => updateProjectArtifact(artifact.artifact_id, { pinned: !artifact.pinned }) + )); + actions.appendChild(createRecordButton( + "Archive", + () => updateProjectArtifact(artifact.artifact_id, { archived: true }) + )); + actions.appendChild(createRecordButton( + "Delete", + () => deleteProjectArtifact(artifact), + "text-btn danger-text" + )); + record.appendChild(copy); + record.appendChild(actions); + projectArtifactsList.appendChild(record); + }); +} + +async function loadBrainRevisions() { + if (!projectHomeProjectId) return; try { - const res = await fetch(routerEndpoint(`/projects/${targetProject}`), { - method: "PUT", - headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ - system_prompt: newPrompt, - preferred_model: newModel && newModel.trim() ? newModel.trim() : undefined - }) + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/brain/revisions`), + { headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + const data = await response.json(); + brainRevisionSelect.replaceChildren(); + data.revisions.forEach((revision) => { + const option = document.createElement("option"); + option.value = revision.revision; + option.textContent = `Revision ${revision.revision} · ${revision.reason} · ${revision.token_count} tokens`; + brainRevisionSelect.appendChild(option); }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + const prior = data.revisions.find((item) => item.revision < projectHomeData.components.brain.revision); + if (prior) brainRevisionSelect.value = prior.revision; + restoreProjectBrainBtn.disabled = !prior; + } catch (error) { + projectBrainStatus.textContent = `Revision load failed: ${error.message}`; + } +} + +function populateProjectHomepage(data) { + projectHomeData = data; + projectHomeProjectId = data.project_id; + projectHomeHeading.textContent = data.project.name; + projectHomeDescription.textContent = data.project.description + || "One source of truth for the context added to every attached chat."; + projectContextBudget.value = data.context_budget.total; + projectHomeInstructions.value = data.components.project_instructions.content || ""; + renderProjectBudget(); + renderProjectAttachmentState(); + renderProjectFiles(data.components.file_context_uploads || []); + renderProjectArtifacts(data.components.artifact_history || []); + const brain = data.components.brain; + const brainDeleted = Boolean(brain.deleted_at); + brainPinned.value = brainDeleted ? "" : (brain.pinned_text || ""); + brainActive.value = brainDeleted ? "" : (brain.active_text || ""); + brainRecent.value = brainDeleted ? "" : (brain.recent_text || ""); + brainThreshold.value = brain.compact_threshold; + [brainPinned, brainActive, brainRecent, brainThreshold].forEach((field) => { + field.disabled = brainDeleted; + }); + saveProjectBrainBtn.disabled = brainDeleted; + compactProjectBrainBtn.disabled = brainDeleted; + deleteProjectBrainBtn.disabled = brainDeleted; + brainRevision.textContent = `Revision ${brain.revision} · ${brain.token_count.toLocaleString()} tokens`; + projectBrainStatus.textContent = brainDeleted + ? "Soft deleted. Restore a prior revision to reactivate it." + : (brain.should_compact ? "Compaction threshold reached." : "Ready"); + loadBrainRevisions(); +} + +async function refreshProjectHomepage(message = "") { + if (!projectHomeProjectId) return; + if (message) projectHomeStatus.textContent = message; + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/homepage`), + { headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + populateProjectHomepage(await response.json()); +} + +async function openProjectHomepage() { + if (!selectedProjectId) { + alert("Select a project before opening its homepage."); + projectSelect?.focus(); + return; + } + projectHomeProjectId = selectedProjectId; + projectHomeStatus.textContent = "Loading project context..."; + if (!projectHomeDialog.open) projectHomeDialog.showModal(); + try { + await refreshProjectHomepage(); + projectHomeStatus.textContent = "All four project components are loaded."; + animateProjectHomepageIn(); + requestAnimationFrame(() => projectHomeInstructions.focus()); + } catch (error) { + console.error("Failed to load Project Homepage:", error); + projectHomeStatus.textContent = `Load failed: ${error.message}`; + } +} + +async function saveProjectInstructions() { + if (!projectHomeProjectId) return; + saveProjectInstructionsBtn.disabled = true; + projectHomeStatus.textContent = "Saving project instructions and budget..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}`), + { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + system_prompt: projectHomeInstructions.value, + context_budget_tokens: Number.parseInt(projectContextBudget.value, 10) + }) + } + ); + if (!response.ok) throw new Error(await apiError(response)); await loadProjects(); - alert("Project instructions updated. Use 🔄 to resync the active conversation."); - } catch (e) { - console.error("Failed to update project:", e); - alert("Failed to update project"); + await refreshProjectHomepage(); + projectHomeStatus.textContent = "Instructions saved. Attached chats use them on the next message."; + } catch (error) { + projectHomeStatus.textContent = `Save failed: ${error.message}`; + } finally { + saveProjectInstructionsBtn.disabled = false; + } +} + +async function uploadProjectReference(event) { + event.preventDefault(); + const file = projectFileInput.files?.[0]; + if (!file || !projectHomeProjectId) { + projectFilesStatus.textContent = "Choose a reference first."; + return; + } + projectFilesStatus.textContent = `Uploading ${file.name}...`; + const form = new FormData(); + form.append("file", file); + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/files`), + { method: "POST", headers: authHeaders(), body: form } + ); + if (!response.ok) throw new Error(await apiError(response)); + const uploaded = await response.json(); + projectFileInput.value = ""; + const label = projectFileForm.querySelector(".project-file-drop span"); + if (label) label.textContent = "Choose a local reference"; + projectFilesStatus.textContent = uploaded.status === "indexed" + ? `${uploaded.display_name} indexed for project context.` + : `${uploaded.display_name} stored with status: ${uploaded.status.replaceAll("_", " ")}.`; + await refreshProjectHomepage(); + } catch (error) { + projectFilesStatus.textContent = `Upload failed: ${error.message}`; + } +} + +async function deleteProjectReference(file) { + if (!confirm(`Delete project reference “${file.display_name}”?`)) return; + projectFilesStatus.textContent = `Deleting ${file.display_name}...`; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/files/${encodeURIComponent(file.file_id)}`), + { method: "DELETE", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectFilesStatus.textContent = `${file.display_name} deleted.`; + } catch (error) { + projectFilesStatus.textContent = `Delete failed: ${error.message}`; + } +} + +async function reindexProjectReference(file) { + projectFilesStatus.textContent = `Reindexing ${file.display_name}...`; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/files/${encodeURIComponent(file.file_id)}/reindex`), + { method: "POST", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + const updated = await response.json(); + await refreshProjectHomepage(); + projectFilesStatus.textContent = `${updated.display_name} reindexed with status: ${updated.status.replaceAll("_", " ")}.`; + } catch (error) { + projectFilesStatus.textContent = `Reindex failed: ${error.message}`; + } +} + +async function setProjectReferenceAttached(file, attached) { + projectFilesStatus.textContent = `${attached ? "Attaching" : "Detaching"} ${file.display_name}...`; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/files/${encodeURIComponent(file.file_id)}`), + { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ attached }) + } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectFilesStatus.textContent = `${file.display_name} ${attached ? "attached" : "detached"}.`; + } catch (error) { + projectFilesStatus.textContent = `Update failed: ${error.message}`; + } +} + +async function openProjectArtifact(artifactId) { + projectArtifactsStatus.textContent = "Loading artifact..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/artifacts/${encodeURIComponent(artifactId)}`), + { headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + const artifact = await response.json(); + projectArtifactPreview.textContent = artifact.body || ""; + projectArtifactPreview.hidden = false; + projectArtifactsStatus.textContent = `Viewing ${artifact.title}.`; + } catch (error) { + projectArtifactsStatus.textContent = `Open failed: ${error.message}`; + } +} + +async function updateProjectArtifact(artifactId, updates) { + projectArtifactsStatus.textContent = "Updating artifact..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/artifacts/${encodeURIComponent(artifactId)}`), + { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(updates) + } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectArtifactsStatus.textContent = "Artifact updated."; + } catch (error) { + projectArtifactsStatus.textContent = `Update failed: ${error.message}`; + } +} + +async function deleteProjectArtifact(artifact) { + if (!confirm(`Delete artifact “${artifact.title}”?`)) return; + projectArtifactsStatus.textContent = "Deleting artifact..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/artifacts/${encodeURIComponent(artifact.artifact_id)}`), + { method: "DELETE", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectArtifactsStatus.textContent = "Artifact deleted."; + } catch (error) { + projectArtifactsStatus.textContent = `Delete failed: ${error.message}`; + } +} + +async function saveProjectBrain() { + if (!projectHomeData || !projectHomeProjectId) return; + saveProjectBrainBtn.disabled = true; + projectBrainStatus.textContent = "Saving BRAIN..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/brain`), + { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + pinned_text: brainPinned.value, + active_text: brainActive.value, + recent_text: brainRecent.value, + compact_threshold: Number.parseInt(brainThreshold.value, 10), + expected_revision: projectHomeData.components.brain.revision + }) + } + ); + if (!response.ok) throw new Error(await apiError(response)); + const brain = await response.json(); + await refreshProjectHomepage(); + projectBrainStatus.textContent = brain.compaction_queued + ? "Saved. Threshold compaction queued in FastAPI." + : "BRAIN saved."; + } catch (error) { + projectBrainStatus.textContent = `Save failed: ${error.message}`; + } finally { + saveProjectBrainBtn.disabled = false; + } +} + +async function compactProjectBrain() { + compactProjectBrainBtn.disabled = true; + projectBrainStatus.textContent = "Creating a recoverable compaction revision..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/brain/compact`), + { method: "POST", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectBrainStatus.textContent = "Compacted. Pinned and active tiers were preserved verbatim."; + } catch (error) { + projectBrainStatus.textContent = `Compaction failed: ${error.message}`; + } finally { + compactProjectBrainBtn.disabled = false; + } +} + +async function restoreProjectBrain() { + const revision = Number.parseInt(brainRevisionSelect.value, 10); + if (!revision || !confirm(`Restore BRAIN revision ${revision}? The current revision remains recoverable.`)) return; + projectBrainStatus.textContent = `Restoring revision ${revision}...`; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/brain/revisions/${revision}/restore`), + { method: "POST", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectBrainStatus.textContent = `Revision ${revision} restored as a new revision.`; + } catch (error) { + projectBrainStatus.textContent = `Restore failed: ${error.message}`; + } +} + +async function deleteProjectBrain() { + if (!confirm("Soft delete active BRAIN content? Every revision remains available for restore.")) return; + projectBrainStatus.textContent = "Soft deleting BRAIN..."; + try { + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/brain`), + { method: "DELETE", headers: authHeaders() } + ); + if (!response.ok) throw new Error(await apiError(response)); + await refreshProjectHomepage(); + projectBrainStatus.textContent = "BRAIN soft deleted. Restore a revision to reactivate it."; + } catch (error) { + projectBrainStatus.textContent = `Delete failed: ${error.message}`; + } +} + +async function previewProjectRequestContext() { + if (!projectHomeProjectId) return; + previewProjectContextBtn.disabled = true; + projectContextPreviewStatus.textContent = "Assembling exact request context..."; + projectContextPreviewOutput.hidden = true; + try { + let attachedFileText = ""; + const inlineFile = fileInput?.files?.[0]; + if (inlineFile) { + if (inlineFile.size > 1024 * 1024) { + throw new Error("Inline composer file exceeds the 1MB send limit"); + } + attachedFileText = `\n\n[Attached file: ${inlineFile.name}]\n${await inlineFile.text()}`; + } + const displayedQuery = window.DavePrompt.buildDisplayedPrompt( + promptInput.value.trim(), + attachedFileText, + Boolean(supportFlag?.checked), + ); + const query = displayedQuery || (state.pendingImages.length ? "[image]" : ""); + const conversation = currentConversation(); + const conversationId = conversation?.project_id === projectHomeProjectId + ? state.sessionId + : null; + const response = await fetch( + routerEndpoint(`/projects/${encodeURIComponent(projectHomeProjectId)}/context-preview`), + { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + query, + conversation_id: conversationId, + model: modelSelect.value || null, + max_tokens: 2048, + }), + }, + ); + if (!response.ok) throw new Error(await apiError(response)); + const data = await response.json(); + const budget = data.budget || {}; + const limits = budget.available_limits || {}; + const usage = budget.usage || {}; + const budgetLines = [ + `Model context window: ${(budget.model_context_window || 0).toLocaleString()} tokens`, + `Project request budget: ${(budget.request_total || 0).toLocaleString()} tokens`, + `Sequential caps after prior unused capacity: instructions ${Number(limits.project_instructions || 0).toLocaleString()}, BRAIN ${Number(limits.brain || 0).toLocaleString()}, files ${Number(limits.file_context || 0).toLocaleString()}, artifacts ${Number(limits.artifact_history || 0).toLocaleString()}`, + `Included usage: instructions ${Number(usage.project_instructions || 0).toLocaleString()}, BRAIN ${Number(usage.brain || 0).toLocaleString()}, files ${Number(usage.file_context || 0).toLocaleString()}, artifacts ${Number(usage.artifact_history || 0).toLocaleString()}`, + ]; + const messageLines = data.messages.map((message, index) => { + const content = typeof message.content === "string" + ? message.content + : JSON.stringify(message.content, null, 2); + return `[${index + 1}] ${String(message.role || "unknown").toUpperCase()}\n${content}`; + }); + projectContextPreviewOutput.textContent = `${budgetLines.join("\n")}\n\n${messageLines.join("\n\n")}`; + projectContextPreviewOutput.hidden = false; + animateProjectContextPreview(); + projectContextPreviewStatus.textContent = `Preview assembled ${data.messages.length} messages. No model request was sent.`; + } catch (error) { + projectContextPreviewStatus.textContent = `Preview failed: ${error.message}`; + } finally { + previewProjectContextBtn.disabled = false; + } +} + +async function toggleActiveChatProject() { + const conversation = currentConversation(); + if (!conversation || !state.sessionId) return; + const nextProjectId = conversation.project_id === projectHomeProjectId + ? null + : projectHomeProjectId; + projectAttachmentState.textContent = nextProjectId ? "Attaching active chat..." : "Detaching active chat..."; + try { + const response = await fetch( + routerEndpoint(`/conversations/${encodeURIComponent(state.sessionId)}/project`), + { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ project_id: nextProjectId }) + } + ); + if (!response.ok) throw new Error(await apiError(response)); + const data = await response.json(); + conversation.project_id = data.project_id; + conversation.system_prompt = data.system_prompt; + renderProjectAttachmentState(); + renderConversationList(); + updateContextStrip(); + } catch (error) { + projectAttachmentState.textContent = `Attachment change failed: ${error.message}`; + } +} + +async function startProjectChat() { + selectedProjectId = projectHomeProjectId; + projectSelect.value = selectedProjectId; + localStorage.setItem("dave_project_id", selectedProjectId); + const conversationId = await createConversationFromTemplate(templateSelect?.value || "general"); + if (conversationId) { + projectHomeDialog.close(); + updateContextStrip(); } } @@ -2430,7 +3161,7 @@ async function sendMessage() { temperature: 0.7, model: modelSelect.value || undefined, node_id: state.selectedNode || undefined, - project_id: selectedProjectId || undefined, + project_id: convo.project_id ?? undefined, images: state.pendingImages.map(i => i.image_url) }) }); @@ -2903,7 +3634,70 @@ if (resyncBtn) { } if (editProjectBtn) { - editProjectBtn.addEventListener("click", editProjectInstructions); + editProjectBtn.addEventListener("click", openProjectHomepage); +} + +if (projectHomeClose) { + projectHomeClose.addEventListener("click", () => { + resetProjectHomeMotion(); + projectHomeDialog.close(); + }); +} + +if (projectHomeDialog) { + projectHomeDialog.addEventListener("close", resetProjectHomeMotion); +} + +if (projectHomeInstructions) { + projectHomeInstructions.addEventListener("input", renderProjectBudget); +} + +if (projectContextBudget) { + projectContextBudget.addEventListener("input", renderProjectBudget); +} + +if (saveProjectInstructionsBtn) { + saveProjectInstructionsBtn.addEventListener("click", saveProjectInstructions); +} + +if (projectFileForm) { + projectFileForm.addEventListener("submit", uploadProjectReference); +} + +if (projectFileInput) { + projectFileInput.addEventListener("change", () => { + const name = projectFileInput.files?.[0]?.name; + const label = projectFileForm.querySelector(".project-file-drop span"); + if (label) label.textContent = name || "Choose a local reference"; + }); +} + +if (saveProjectBrainBtn) { + saveProjectBrainBtn.addEventListener("click", saveProjectBrain); +} + +if (compactProjectBrainBtn) { + compactProjectBrainBtn.addEventListener("click", compactProjectBrain); +} + +if (restoreProjectBrainBtn) { + restoreProjectBrainBtn.addEventListener("click", restoreProjectBrain); +} + +if (deleteProjectBrainBtn) { + deleteProjectBrainBtn.addEventListener("click", deleteProjectBrain); +} + +if (previewProjectContextBtn) { + previewProjectContextBtn.addEventListener("click", previewProjectRequestContext); +} + +if (attachProjectChatBtn) { + attachProjectChatBtn.addEventListener("click", toggleActiveChatProject); +} + +if (startProjectChatBtn) { + startProjectChatBtn.addEventListener("click", startProjectChat); } if (instructionsBtn) { diff --git a/static/index.html b/static/index.html index 255816b..27e0a52 100644 --- a/static/index.html +++ b/static/index.html @@ -53,7 +53,7 @@

Conversations

- +
- + - + - - - + + +
+ + +
+
+ Project Homepage +

Project context

+

One source of truth for the context added to every attached chat.

+
+ +
+
+
No active chat
+
+ + +
+
+
+
+ Request allocation +

Context budget

+
+ +
+
+
+
+
+ 01 +

Project Instructions

Highest-level project direction, applied after the global default.

+
+ + + +
+ +
+
+ 02 +

File Context Uploads

Project references selected into requests with source labels.

+
+
+ + + +
+
+
+
+ +
+
+ 03 +

Artifact History

Prior assistant outputs, retained, searchable, and available for context.

+
+
+
+ +
+ +
+
+ 04 +

BRAIN

Durable tiers with threshold compaction and recoverable revisions.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Revision 1 +
+ +
+ +
+ Inspect next request context +
+

Assemble the current composer, attached chat history, instructions, BRAIN, ranked files, and artifacts without calling a model.

+ +
+
+ +
+
+
+
+
@@ -232,6 +347,7 @@

Session instructions

+ diff --git a/static/style.css b/static/style.css index eca6371..f3ea98c 100644 --- a/static/style.css +++ b/static/style.css @@ -656,6 +656,503 @@ textarea { flex-wrap: wrap; } +.project-home-dialog { + position: relative; + display: flex; + flex-direction: column; + width: min(1180px, calc(100vw - 32px)); + height: min(900px, calc(100dvh - 32px)); + max-width: none; + max-height: none; + padding: 0; + overflow: hidden; + color: var(--text); + background: linear-gradient(155deg, rgba(12, 40, 64, 0.98), var(--panel)); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58), var(--panel-glow); + isolation: isolate; +} + +.project-home-ambient { + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + +.project-home-ambient span { + position: absolute; + width: 380px; + height: 380px; + border-radius: 50%; + opacity: 0.28; + filter: blur(64px); +} + +.project-home-ambient span:first-child { + top: -210px; + left: -80px; + background: rgba(82, 243, 225, 0.72); +} + +.project-home-ambient span:last-child { + right: -100px; + bottom: -250px; + background: rgba(113, 150, 255, 0.58); +} + +.project-home-dialog:not([open]) { + display: none; +} + +.project-home-dialog::backdrop { + background: rgba(2, 10, 18, 0.82); + backdrop-filter: blur(6px); +} + +.project-home-header, +.project-home-toolbar, +.context-budget-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.project-home-header { + padding: 20px 22px 16px; + border-bottom: 1px solid var(--border); + background: + linear-gradient(90deg, rgba(105, 238, 225, 0.09), transparent 38%), + rgba(5, 25, 42, 0.52); +} + +.project-home-header h2, +.project-home-header p, +.context-budget-panel h3, +.project-component-heading h3, +.project-component-heading p { + margin: 0; +} + +.project-home-header h2 { + margin-top: 3px; + font-size: clamp(21px, 2.6vw, 31px); + letter-spacing: -0.025em; +} + +.project-home-header p, +.project-component-heading p { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.project-home-kicker { + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.project-home-toolbar { + padding: 10px 22px; + border-bottom: 1px solid var(--border); + background: rgba(16, 49, 75, 0.76); +} + +.project-attachment-state { + color: var(--muted); + font-size: 12px; +} + +.project-home-toolbar-actions, +.project-component-footer, +.brain-actions, +.project-record-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.context-budget-panel { + margin: 14px 22px 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(27, 67, 94, 0.62); +} + +.context-budget-panel label, +.brain-controls label { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; +} + +.context-budget-panel input, +.brain-controls input { + width: 110px; + min-height: 36px; +} + +.context-budget-meter { + display: flex; + flex: 1 1 440px; + min-width: 260px; + height: 42px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--input-bg); +} + +.budget-segment { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 4px 6px; + border-right: 1px solid rgba(4, 20, 33, 0.58); + color: #061923; + font-size: 10px; + font-weight: 800; + line-height: 1.15; + text-align: center; +} + +.budget-segment:last-child { + border-right: 0; +} + +.budget-segment:nth-child(1) { background: #8ff7ec; } +.budget-segment:nth-child(2) { background: #78b9ff; } +.budget-segment:nth-child(3) { background: #71d8b2; } +.budget-segment:nth-child(4) { background: #c7b7ff; } + +.project-component-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: max-content; + align-content: start; + gap: 12px; + flex: 1; + min-height: 0; + padding: 14px 22px 22px; + overflow-y: auto; +} + +.project-component { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 300px; + padding: 15px; + overflow: visible; + border: 1px solid var(--border); + border-radius: 12px; + background: + linear-gradient(150deg, rgba(52, 98, 127, 0.52), rgba(13, 43, 67, 0.8)); +} + +.project-component::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: linear-gradient(var(--accent), transparent 72%); +} + +.project-component-heading { + display: flex; + align-items: flex-start; + gap: 11px; +} + +.project-component-heading h3 { + font-size: 15px; +} + +.component-number { + display: grid; + place-items: center; + flex: 0 0 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 800; +} + +.project-component textarea { + width: 100%; + min-height: 118px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + line-height: 1.45; +} + +.project-component-artifacts, +.project-component-brain { + grid-column: 1 / -1; +} + +.project-context-preview { + grid-column: 1 / -1; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(4, 20, 33, 0.72); +} + +.project-context-preview > summary { + color: var(--accent); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.project-context-preview-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; +} + +.project-context-preview-toolbar p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} + +.project-context-preview-output { + max-height: 360px; + margin: 10px 0 0; + padding: 12px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: rgba(2, 13, 23, 0.92); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; +} + +.project-component-artifacts { + min-height: 220px; +} + +.project-component-brain { + min-height: 330px; +} + +.brain-tier-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.brain-tier { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; +} + +.brain-tier > label { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 3px; + color: var(--text); + font-size: 12px; +} + +.brain-tier > label span, +.component-status, +.project-component-footer, +.brain-controls { + color: var(--muted); + font-size: 11px; +} + +.project-component-footer { + justify-content: space-between; + margin-top: auto; +} + +.project-upload-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.project-file-drop { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 11px; + overflow: hidden; + border: 1px dashed var(--border); + border-radius: 8px; + color: var(--muted); + cursor: pointer; +} + +.project-file-drop:hover { + color: var(--accent); + border-color: var(--accent); +} + +.project-file-drop span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-record-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 7px; + min-height: 110px; + max-height: 240px; + overflow-y: auto; +} + +.project-record, +.project-empty-state { + padding: 9px 10px; + border: 1px solid rgba(101, 226, 224, 0.24); + border-radius: 8px; + background: var(--input-bg); +} + +.project-record { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.project-record strong, +.project-record small { + display: block; +} + +.project-record strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-record small, +.project-empty-state { + color: var(--muted); + font-size: 11px; + line-height: 1.4; +} + +.project-record-actions button { + padding: 5px 7px; + font-size: 11px; +} + +.project-artifact-preview { + max-height: 180px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: rgba(4, 20, 33, 0.82); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; +} + +.brain-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.brain-actions { + justify-content: flex-end; +} + +.brain-actions > [role="status"] { + flex: 1 1 140px; + margin-right: auto; +} + +.brain-actions > select { + width: min(300px, 100%); +} + +.project-home-dialog > .dialog-status { + flex: 0 0 auto; + padding: 5px 22px 9px; + border-top: 1px solid var(--border); +} + +.danger-text { + color: var(--danger); +} + +.over-budget { + color: var(--danger); + font-weight: 800; +} + +.inline-icon, +.composer-tool-btn .inline-icon, +.image-upload-btn .inline-icon, +.project-file-drop .inline-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.image-upload-btn, +.composer-tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.composer-tool-btn { + height: 32px; + padding: 6px 10px; + font-size: 13px; +} + .template-row { display: flex; gap: 6px; @@ -1720,6 +2217,106 @@ textarea { padding: 14px; } + .project-home-dialog { + width: calc(100vw - 12px); + height: calc(100dvh - 12px); + max-width: calc(100vw - 12px); + max-height: calc(100dvh - 12px); + min-width: 0; + min-height: 0; + border-radius: 12px; + } + + .project-home-header, + .project-home-toolbar, + .context-budget-panel { + align-items: stretch; + flex-direction: column; + gap: 10px; + } + + .project-home-header { + position: sticky; + top: 0; + z-index: 3; + padding: 14px; + } + + .project-home-header .text-btn { + position: absolute; + top: 9px; + right: 9px; + } + + .project-home-toolbar { + padding: 10px 14px; + } + + .project-home-toolbar-actions > button { + flex: 1; + min-height: 44px; + } + + .context-budget-panel { + margin: 10px 14px 0; + } + + .context-budget-meter { + flex: 0 0 48px; + width: 100%; + min-width: 0; + } + + .budget-segment { + font-size: 9px; + } + + .project-component-grid { + grid-template-columns: 1fr; + max-height: none; + padding: 10px 14px 18px; + } + + .project-component { + height: max-content; + min-height: 240px; + } + + .project-component-brain { + min-height: 760px; + } + + .project-component-artifacts, + .project-component-brain { + grid-column: auto; + } + + .project-context-preview { + grid-column: auto; + } + + .project-context-preview-toolbar { + align-items: stretch; + flex-direction: column; + } + + .project-context-preview-toolbar > button { + min-height: 44px; + } + + .brain-tier-grid { + grid-template-columns: 1fr; + } + + .project-upload-form { + grid-template-columns: 1fr; + } + + .brain-actions > button, + .brain-actions > select { + min-height: 44px; + } + .instructions-dialog-actions > button { min-height: 44px; } @@ -2054,10 +2651,21 @@ textarea { } :root[data-theme="light"] .notepad-panel, -:root[data-theme="light"] .instructions-dialog { +:root[data-theme="light"] .instructions-dialog, +:root[data-theme="light"] .project-home-dialog { background: #f7fbff; } +:root[data-theme="light"] .project-component { + background: linear-gradient(150deg, #ffffff, #eef6fb); +} + +:root[data-theme="light"] .project-home-header, +:root[data-theme="light"] .project-home-toolbar, +:root[data-theme="light"] .context-budget-panel { + background: #edf6fc; +} + :root[data-theme="light"] .message-header, :root[data-theme="light"] .message-content { color: var(--text); @@ -2085,6 +2693,14 @@ textarea { } :root[data-theme="forest"] .notepad-panel, -:root[data-theme="forest"] .instructions-dialog { +:root[data-theme="forest"] .instructions-dialog, +:root[data-theme="forest"] .project-home-dialog { background: #153d2c; } + +:root[data-theme="forest"] .project-component, +:root[data-theme="forest"] .project-home-header, +:root[data-theme="forest"] .project-home-toolbar, +:root[data-theme="forest"] .context-budget-panel { + background: linear-gradient(150deg, rgba(33, 83, 59, 0.96), rgba(15, 45, 31, 0.96)); +} diff --git a/static/vendor/gsap/NOTICE.md b/static/vendor/gsap/NOTICE.md new file mode 100644 index 0000000..869ed7b --- /dev/null +++ b/static/vendor/gsap/NOTICE.md @@ -0,0 +1,9 @@ +# GSAP Core 3.15.0 + +Vendored from the official `gsap@3.15.0` npm package on 2026-08-26. + +Copyright 2026 GreenSock. All rights reserved. Use is subject to the [GSAP Standard "No Charge" License](https://gsap.com/standard-license/). + +Source package integrity: `sha512-dMW4CWBTUK1AE[...]jGtOmPnfjZB+A==` (abbreviated npm registry output). The full integrity record is available from `npm view gsap@3.15.0 dist.integrity`. + +The distribution file differs only by repository-required trailing-whitespace normalization. Local SHA-256: `c3a03a345e45bc954cd48c43f11572891f5dca7a5d99348d5bc14753a728618c`. diff --git a/static/vendor/gsap/gsap.min.js b/static/vendor/gsap/gsap.min.js new file mode 100644 index 0000000..72c17a8 --- /dev/null +++ b/static/vendor/gsap/gsap.min.js @@ -0,0 +1,10 @@ +/*! + * GSAP 3.15.0 + * https://gsap.com + * + * @license Copyright 2026, GreenSock. All rights reserved. + * Subject to the terms at https://gsap.com/standard-license. + * @author: Jack Doyle, jack@greensock.com + */ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function R(t){return(i=bt(t,ht))&&Fe}function S(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function T(t,e){return!e&&console.warn(t)}function U(t,e){return t&&(ht[t]=e)&&i&&(i[t]=e)||ht}function V(){return 0}function ga(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=yt.length;r--&&!yt[r].targetTest(i););e=yt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Xt(t[r],e)))||t.splice(r,1);return t}function ha(t){return t._gsap||ga(Pt(t))[0]._gsap}function ia(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ja(t,e){return(t=t.split(",")).forEach(e)||t}function ka(t){return Math.round(1e5*t)/1e5||0}function la(t){return Math.round(1e7*t)/1e7||0}function ma(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function na(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++ia;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function Ba(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function Ca(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Da(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Fa(t,e,r,i){return t._startAt&&(I?t._startAt.revert(ft):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ha(t){return t._repeat?wt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ja(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ka(t){return t._end=la(t._start+(t._tDur/Math.abs(t._ts||t._rts||q)||0))}function La(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=la(r._time-(0q)&&e.render(r,!0)),Da(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur(n=Math.abs(n))&&(a=i,o=n);return a}function wb(t){return Ca(t),t.scrollTrigger&&t.scrollTrigger.kill(!!I),t.progress()<1&&At(t,"onInterrupt"),t}function zb(t){if(t)if(t=!t.name&&t.default||t,x()||t.headless){var e=t.name,r=s(t),i=e&&!r&&t.init?function(){this._props=[]}:t,n={init:V,render:_e,add:$t,kill:Te,modifier:ve,rawVars:0},a={targetTest:0,get:0,getSetter:ue,aliases:{},register:0};if(Lt(),t!==i){if(mt[e])return;ta(i,ta(xa(t,n),a)),bt(i.prototype,bt(n,xa(t,a))),mt[i.prop=e]=i,t.targetTest&&(yt.push(i),dt[e]=1),e=("css"===e?"CSS":e.charAt(0).toUpperCase()+e.substr(1))+"Plugin"}U(e,i),t.register&&t.register(Fe,i,we)}else Dt.push(t)}function Cb(t,e,r){return(6*(t+=t<0?1:1>16,e>>8&zt,e&zt]:0:Rt.black;if(!p){if(","===e.substr(-1)&&(e=e.substr(0,e.length-1)),Rt[e])p=Rt[e];else if("#"===e.charAt(0)){if(e.length<6&&(e="#"+(n=e.charAt(1))+n+(a=e.charAt(2))+a+(s=e.charAt(3))+s+(5===e.length?e.charAt(4)+e.charAt(4):"")),9===e.length)return[(p=parseInt(e.substr(1,6),16))>>16,p>>8&zt,p&zt,parseInt(e.substr(7),16)/255];p=[(e=parseInt(e.substr(1),16))>>16,e>>8&zt,e&zt]}else if("hsl"===e.substr(0,3))if(p=d=e.match(rt),r){if(~e.indexOf("="))return p=e.match(it),i&&p.length<4&&(p[3]=1),p}else o=+p[0]%360/360,u=p[1]/100,n=2*(h=p[2]/100)-(a=h<=.5?h*(u+1):h+u-h*u),3=X?u.endTime(!1):t._dur;return r(e)&&(isNaN(e)||e in o)?(a=e.charAt(0),s="%"===e.substr(-1),n=e.indexOf("="),"<"===a||">"===a?(0<=n&&(e=e.replace(/=/,"")),("<"===a?u._start:u.endTime(0<=u._repeat))+(parseFloat(e.substr(1))||0)*(s?(n<0?u:i).totalDuration()/100:1)):n<0?(e in o||(o[e]=h),o[e]):(a=parseFloat(e.charAt(n-1)+e.substr(n+1)),s&&i&&(a=a/100*(K(i)?i[0]:i).totalDuration()),1=r&&te)return i;i=i._next}else for(i=t._last;i&&i._start>=r;){if("isPause"===i.data&&i._start=n._start)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0=this.totalDuration()||!v&&_)&&(f!==this._start&&Math.abs(l)===Math.abs(this._ts)||this._lock||(!t&&g||!(v===m&&0=i&&(a instanceof te?e&&n.push(a):(r&&n.push(a),t&&n.push.apply(n,a.getChildren(!0,e,r)))),a=a._next;return n},e.getById=function getById(t){for(var e=this.getChildren(1,1,1),r=e.length;r--;)if(e[r].vars.id===t)return e[r]},e.remove=function remove(t){return r(t)?this.removeLabel(t):s(t)?this.killTweensOf(t):(t.parent===this&&Ba(this,t),t===this._recent&&(this._recent=this._last),Da(this))},e.totalTime=function totalTime(t,e){return arguments.length?(this._forcing=1,!this._dp&&this._ts&&(this._start=la(It.time-(0r:!r||s.isActive())&&n.push(s):(i=s.getTweensOf(a,r)).length&&n.push.apply(n,i),s=s._next;return n},e.tweenTo=function tweenTo(t,e){e=e||{};var r,i=this,n=Ot(i,t),a=e.startAt,s=e.onStart,o=e.onStartParams,u=e.immediateRender,h=te.to(i,ta({ease:e.ease||"none",lazy:!1,immediateRender:!1,time:n,overwrite:"auto",duration:e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale())||q,onStart:function onStart(){if(i.pause(),!r){var t=e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale());h._dur!==t&&Ua(h,t,0,1).render(h._time,!0,!0),r=1}s&&s.apply(h,o||[])}},e));return u?h.render(0):h},e.tweenFromTo=function tweenFromTo(t,e,r){return this.tweenTo(e,ta({startAt:{time:Ot(this,t)}},r))},e.recent=function recent(){return this._recent},e.nextLabel=function nextLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t))},e.previousLabel=function previousLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t),1)},e.currentLabel=function currentLabel(t){return arguments.length?this.seek(t,!0):this.previousLabel(this._time+q)},e.shiftChildren=function shiftChildren(t,e,r){void 0===r&&(r=0);var i,n=this._first,a=this.labels;for(t=la(t);n;)n._start>=r&&(n._start+=t,n._end+=t),n=n._next;if(e)for(i in a)a[i]>=r&&(a[i]+=t);return Da(this)},e.invalidate=function invalidate(t){var e=this._first;for(this._lock=0;e;)e.invalidate(t),e=e._next;return i.prototype.invalidate.call(this,t)},e.clear=function clear(t){void 0===t&&(t=!0);for(var e,r=this._first;r;)e=r._next,this.remove(r),r=e;return this._dp&&(this._time=this._tTime=this._pTime=0),t&&(this.labels={}),Da(this)},e.totalDuration=function totalDuration(t){var e,r,i,n=0,a=this,s=a._last,o=X;if(arguments.length)return a.timeScale((a._repeat<0?a.duration():a.totalDuration())/(a.reversed()?-t:t));if(a._dirty){for(i=a.parent;s;)e=s._prev,s._dirty&&s.totalDuration(),o<(r=s._start)&&a._sort&&s._ts&&!a._lock?(a._lock=1,Na(a,s,r-s._delay,1)._lock=0):o=r,r<0&&s._ts&&(n-=r,(!i&&!a._dp||i&&i.smoothChildTiming)&&(a._start+=la(r/a._ts),a._time-=r,a._tTime-=r),a.shiftChildren(-r,!1,-Infinity),o=0),s._end>n&&s._ts&&(n=s._end),s=e;Ua(a,a===L&&a._time>n?a._time:n,1,1),a._dirty=0}return a._tDur},Timeline.updateRoot=function updateRoot(t){if(L._ts&&(qa(L,Ja(t,L)),f=It.frame),It.frame>=vt){vt+=Y.autoSleep||120;var e=L._first;if((!e||!e._ts)&&Y.autoSleep&&It._listeners.length<2){for(;e&&!e._ts;)e=e._next;e||It.sleep()}}},Timeline}(qt);ta(Gt.prototype,{_lock:0,_hasPause:0,_forcing:0});function cc(t,e,i,n,a,o){var u,h,l,f;if(mt[t]&&!1!==(u=new mt[t]).init(a,u.rawVars?e[t]:function _processVars(t,e,i,n,a){if(s(t)&&(t=Qt(t,a,e,i,n)),!v(t)||t.style&&t.nodeType||K(t)||J(t))return r(t)?Qt(t,a,e,i,n):t;var o,u={};for(o in t)u[o]=Qt(t[o],a,e,i,n);return u}(e[t],n,a,o,i),i,n,o)&&(i._pt=h=new we(i._pt,a,t,0,1,u.render,u,0,u.priority),i!==c))for(l=i._ptLookup[i._targets.indexOf(a)],f=u._props.length;f--;)l[u._props[f]]=h;return u}function ic(t,r,e,i){var n,a,s=r.ease||i||"power1.inOut";if(K(r))a=e[t]||(e[t]=[]),r.forEach(function(t,e){return a.push({t:e/(r.length-1)*100,v:t,e:s})});else for(n in r)a=e[n]||(e[n]=[]),"ease"===n||a.push({t:parseFloat(t),v:r[n],e:s})}var Zt,Wt,$t=function _addPropTween(t,e,i,n,a,o,u,h,l,f){s(n)&&(n=n(a||0,t,o));var c,d=t[e],p="get"!==i?i:s(d)?l?t[e.indexOf("set")||!s(t["get"+e.substr(3)])?e:"get"+e.substr(3)](l):t[e]():d,_=s(d)?l?se:ae:ie;if(r(n)&&(~n.indexOf("random(")&&(n=rb(n)),"="===n.charAt(1)&&(!(c=ma(p,n)+(_a(p)||0))&&0!==c||(n=c))),!f||p!==n||Wt)return isNaN(p*n)||""===n?(d||e in t||S(e,n),function _addComplexStringPropTween(t,e,r,i,n,a,s){var o,u,h,l,f,c,d,p,_=new we(this._pt,t,e,0,1,pe,null,n),m=0,g=0;for(_.b=r,_.e=i,r+="",(d=~(i+="").indexOf("random("))&&(i=rb(i)),a&&(a(p=[r,i],t,e),r=p[0],i=p[1]),u=r.match(at)||[];o=at.exec(i);)l=o[0],f=i.substring(m,o.index),h?h=(h+1)%5:"rgba("===f.substr(-5)&&(h=1),l!==u[g++]&&(c=parseFloat(u[g-1])||0,_._pt={_next:_._pt,p:f||1===g?f:",",s:c,c:"="===l.charAt(1)?ma(c,l)-c:parseFloat(l)-c,m:h&&h<4?Math.round:0},m=at.lastIndex);return _.c=m")}),s.duration();else{for(l in u={},k)"ease"===l||"easeEach"===l||ic(l,k[l],u,k.easeEach);for(l in u)for(A=u[l].sort(function(t,e){return t.t-e.t}),o=R=0;o=t._tDur||e<0)&&t.ratio===u&&(u&&Ca(t,1),r||I||(At(t,u?"onComplete":"onReverseComplete",!0),t._prom&&t._prom()))}else t._zTime||(t._zTime=e)}(this,t,e,r);return this},e.targets=function targets(){return this._targets},e.invalidate=function invalidate(t){return t&&this.vars.runBackwards||(this._startAt=0),this._pt=this._op=this._onUpdate=this._lazy=this.ratio=0,this._ptLookup=[],this.timeline&&this.timeline.invalidate(t),E.prototype.invalidate.call(this,t)},e.resetTo=function resetTo(t,e,r,i,n){d||It.wake(),this._ts||this.play();var a,s=Math.min(this._dur,(this._dp._time-this._start)*this._ts);return this._initted||Ht(this,s),a=this._ease(s/this._dur),function _updatePropTweens(t,e,r,i,n,a,s,o){var u,h,l,f,c=(t._pt&&t._ptCache||(t._ptCache={}))[e];if(!c)for(c=t._ptCache[e]=[],l=t._ptLookup,f=t._targets.length;f--;){if((u=l[f][e])&&u.d&&u.d._pt)for(u=u.d._pt;u&&u.p!==e&&u.fp!==e;)u=u._next;if(!u)return Wt=1,t.vars[e]="+=0",Ht(t,s),Wt=0,o?T(e+" not eligible for reset. Try splitting into individual properties"):1;c.push(u)}for(f=c.length;f--;)(u=(h=c[f])._pt||h).s=!i&&0!==i||n?u.s+(i||0)+a*u.c:i,u.c=r-u.s,h.e&&(h.e=ka(r)+_a(h.e)),h.b&&(h.b=u.s+_a(h.b))}(this,t,e,r,i,a,s,n)?this.resetTo(t,e,r,i,1):(La(this,0),this.parent||Aa(this._dp,this,"_first","_last",this._dp._sort?"_start":0),this.render(0))},e.kill=function kill(t,e){if(void 0===e&&(e="all"),!(t||e&&"all"!==e))return this._lazy=this._pt=0,this.parent?wb(this):this.scrollTrigger&&this.scrollTrigger.kill(!!I),this;if(this.timeline){var i=this.timeline.totalDuration();return this.timeline.killTweensOf(t,e,Zt&&!0!==Zt.vars.overwrite)._first||wb(this),this.parent&&i!==this.timeline.totalDuration()&&Ua(this,this._dur*this.timeline._tDur/i,0,1),this}var n,a,s,o,u,h,l,f=this._targets,c=t?Pt(t):f,d=this._ptLookup,p=this._pt;if((!e||"all"===e)&&function _arraysMatch(t,e){for(var r=t.length,i=r===e.length;i&&r--&&t[r]===e[r];);return r<0}(f,c))return"all"===e&&(this._pt=0),wb(this);for(n=this._op=this._op||[],"all"!==e&&(r(e)&&(u={},ja(e,function(t){return u[t]=1}),e=u),e=function _addAliasesToVars(t,e){var r,i,n,a,s=t[0]?ha(t[0]).harness:0,o=s&&s.aliases;if(!o)return e;for(i in r=bt({},e),o)if(i in r)for(n=(a=o[i].split(",")).length;n--;)r[a[n]]=r[i];return r}(f,e)),l=f.length;l--;)if(~c.indexOf(f[l]))for(u in a=d[l],"all"===e?(n[l]=e,o=a,s={}):(s=n[l]=n[l]||{},o=e),o)(h=a&&a[u])&&("kill"in h.d&&!0!==h.d.kill(u)||Ba(this,h,"_pt"),delete a[u]),"all"!==s&&(s[u]=1);return this._initted&&!this._pt&&p&&wb(this),this},Tween.to=function to(t,e,r){return new Tween(t,e,r)},Tween.from=function from(t,e){return Ya(1,arguments)},Tween.delayedCall=function delayedCall(t,e,r,i){return new Tween(e,0,{immediateRender:!1,lazy:!1,overwrite:!1,delay:t,onComplete:e,onReverseComplete:e,onCompleteParams:r,onReverseCompleteParams:r,callbackScope:i})},Tween.fromTo=function fromTo(t,e,r){return Ya(2,arguments)},Tween.set=function set(t,e){return e.duration=0,e.repeatDelay||(e.repeat=0),new Tween(t,e)},Tween.killTweensOf=function killTweensOf(t,e,r){return L.killTweensOf(t,e,r)},Tween}(qt);ta(te.prototype,{_targets:[],_lazy:0,_startAt:0,_op:0,_onInit:0}),ja("staggerTo,staggerFrom,staggerFromTo",function(r){te[r]=function(){var t=new Gt,e=Ct.call(arguments,0);return e.splice("staggerFromTo"===r?5:4,0,0),t[r].apply(t,e)}});function qc(t,e,r){return t.setAttribute(e,r)}function yc(t,e,r,i){i.mSet(t,e,i.m.call(i.tween,r,i.mt),i)}var ie=function _setterPlain(t,e,r){return t[e]=r},ae=function _setterFunc(t,e,r){return t[e](r)},se=function _setterFuncWithParam(t,e,r,i){return t[e](i.fp,r)},ue=function _getSetter(t,e){return s(t[e])?ae:u(t[e])&&t.setAttribute?qc:ie},fe=function _renderPlain(t,e){return e.set(e.t,e.p,Math.round(1e6*(e.s+e.c*t))/1e6,e)},de=function _renderBoolean(t,e){return e.set(e.t,e.p,!!(e.s+e.c*t),e)},pe=function _renderComplexString(t,e){var r=e._pt,i="";if(!t&&e.b)i=e.b;else if(1===t&&e.e)i=e.e;else{for(;r;)i=r.p+(r.m?r.m(r.s+r.c*t):Math.round(1e4*(r.s+r.c*t))/1e4)+i,r=r._next;i+=e.c}e.set(e.t,e.p,i,e)},_e=function _renderPropTweens(t,e){for(var r=e._pt;r;)r.r(t,r.d),r=r._next},ve=function _addPluginModifier(t,e,r,i){for(var n,a=this._pt;a;)n=a._next,a.p===i&&a.modifier(t,e,r),a=n},Te=function _killPropTweensOf(t){for(var e,r,i=this._pt;i;)r=i._next,i.p===t&&!i.op||i.op===t?Ba(this,i,"_pt"):i.dep||(e=1),i=r;return!e},be=function _sortPropTweensByPriority(t){for(var e,r,i,n,a=t._pt;a;){for(e=a._next,r=i;r&&r.pr>a.pr;)r=r._next;(a._prev=r?r._prev:n)?a._prev._next=a:i=a,(a._next=r)?r._prev=a:n=a,a=e}t._pt=i},we=(PropTween.prototype.modifier=function modifier(t,e,r){this.mSet=this.mSet||this.set,this.set=yc,this.m=t,this.mt=r,this.tween=e},PropTween);function PropTween(t,e,r,i,n,a,s,o,u){this.t=e,this.s=i,this.c=n,this.p=r,this.r=a||fe,this.d=s||this,this.set=o||ie,this.pr=u||0,(this._next=t)&&(t._prev=this)}ja(Tt+"parent,duration,ease,delay,overwrite,runBackwards,startAt,yoyo,immediateRender,repeat,repeatDelay,data,paused,reversed,lazy,callbackScope,stringFilter,id,yoyoEase,stagger,inherit,repeatRefresh,keyframes,autoRevert,scrollTrigger,easeReverse",function(t){return dt[t]=1}),ht.TweenMax=ht.TweenLite=te,ht.TimelineLite=ht.TimelineMax=Gt,L=new Gt({sortChildren:!1,defaults:j,autoRemoveChildren:!0,id:"root",smoothChildTiming:!0}),Y.stringFilter=Ib;function Gc(t){return(Oe[t]||Me).map(function(t){return t()})}function Hc(){var t=Date.now(),o=[];2 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 2389f87..29cf092 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -48,6 +48,8 @@ def test_static_root_is_isolated(router_factory): "/feedback.db", "/performance.db", "/dave_vectors.db", + "/dave_project_context.db", + "/project_uploads/example.txt", "/cost_log.jsonl", ): assert client.get(path).status_code == 404, path diff --git a/tests/test_project_context.py b/tests/test_project_context.py new file mode 100644 index 0000000..8bbb43f --- /dev/null +++ b/tests/test_project_context.py @@ -0,0 +1,367 @@ +import json +import sqlite3 +from datetime import datetime, timedelta + +import httpx +import respx + +from conftest import TEST_API_KEY, TEST_NODE_URL + + +AUTH = {"X-API-Key": TEST_API_KEY} +MODEL_ID = "inventory-model:latest" + + +def load_inventory(client, mock): + mock.get(f"{TEST_NODE_URL}/api/tags").mock( + return_value=httpx.Response( + 200, + json={"models": [{"name": MODEL_ID, "model": MODEL_ID}]}, + ) + ) + assert client.get("/nodes/node-test/models", headers=AUTH).status_code == 200 + + +def create_project(client, **overrides): + payload = {"name": "Context Project", "system_prompt": "Stay inside the project."} + payload.update(overrides) + response = client.post("/projects", headers=AUTH, json=payload) + assert response.status_code == 200, response.text + return response.json() + + +def test_brain_compaction_preserves_protected_tiers_and_restores_revisions(router_factory): + router, client, _ = router_factory() + project = create_project(client) + project_id = project["project_id"] + pinned = "Decision: keep this exact byte sequence." + active = "Goal: ship BRAIN.\nRisk: context overflow." + recent = "\n".join( + ( + "Investigate the homepage", + "Investigate the homepage", + "[resolved] Old transient failure", + "[tool] raw call output", + "Keep the useful recent finding", + ) + ) + + saved = client.put( + f"/projects/{project_id}/brain", + headers=AUTH, + json={ + "pinned_text": pinned, + "active_text": active, + "recent_text": recent, + "compact_threshold": 3072, + "expected_revision": 1, + }, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["revision"] == 2 + assert saved.json()["compaction_queued"] is False + + compacted = client.post( + f"/projects/{project_id}/brain/compact", + headers=AUTH, + ) + assert compacted.status_code == 200, compacted.text + body = compacted.json() + assert body["revision"] == 3 + assert body["pinned_text"] == pinned + assert body["active_text"] == active + assert body["recent_text"].count("Investigate the homepage") == 1 + assert "Old transient failure" not in body["recent_text"] + assert "raw call output" not in body["recent_text"] + + revisions = client.get( + f"/projects/{project_id}/brain/revisions", + headers=AUTH, + ).json()["revisions"] + assert [item["revision"] for item in revisions] == [3, 2, 1] + + deleted = client.delete(f"/projects/{project_id}/brain", headers=AUTH) + assert deleted.status_code == 200 + assert deleted.json()["deleted_at"] + restored = client.post( + f"/projects/{project_id}/brain/revisions/2/restore", + headers=AUTH, + ) + assert restored.status_code == 200, restored.text + assert restored.json()["deleted_at"] is None + assert restored.json()["recent_text"] == recent + assert restored.json()["pinned_text"] == pinned + + assert client.delete(f"/projects/{project_id}/brain", headers=AUTH).status_code == 200 + expired_at = (datetime.now() - timedelta(days=31)).isoformat() + with sqlite3.connect(router.PROJECT_CONTEXT_DB) as connection: + connection.execute( + "UPDATE brain_states SET deleted_at = ? WHERE project_id = ?", + (expired_at, project_id), + ) + assert router.PROJECT_CONTEXT.purge_expired_brains(30) == [project_id] + expired = client.get(f"/projects/{project_id}/brain", headers=AUTH).json() + assert expired["revision"] == 1 + assert expired["pinned_text"] == "" + assert expired["deleted_at"] is None + + +def test_project_homepage_files_artifacts_and_context_order(router_factory): + _, client, data_dir = router_factory() + project = create_project(client) + project_id = project["project_id"] + + brain = client.put( + f"/projects/{project_id}/brain", + headers=AUTH, + json={ + "pinned_text": "Use the launch checklist.", + "active_text": "Goal: verify the context order.", + "recent_text": "The release candidate is ready.", + "expected_revision": 1, + }, + ) + assert brain.status_code == 200, brain.text + + uploaded = client.post( + f"/projects/{project_id}/files", + headers=AUTH, + files={"file": ("launch.md", b"Launch checklist: test, review, deploy.", "text/markdown")}, + ) + assert uploaded.status_code == 200, uploaded.text + assert uploaded.json()["status"] == "indexed" + assert (data_dir / "project_uploads" / project_id).is_dir() + file_id = uploaded.json()["file_id"] + + detached = client.put( + f"/projects/{project_id}/files/{file_id}", + headers=AUTH, + json={"attached": False}, + ) + assert detached.status_code == 200 + assert detached.json()["attached"] is False + attached = client.put( + f"/projects/{project_id}/files/{file_id}", + headers=AUTH, + json={"attached": True}, + ) + assert attached.status_code == 200 + reindexed = client.post( + f"/projects/{project_id}/files/{file_id}/reindex", + headers=AUTH, + ) + assert reindexed.status_code == 200 + assert reindexed.json()["status"] == "indexed" + + conversation_id = client.post( + "/conversations/from_template", + headers=AUTH, + json={"template_name": "general", "project_id": project_id}, + ).json()["conversation_id"] + + with respx.mock(assert_all_called=True) as mock: + load_inventory(client, mock) + first_chat = mock.post(f"{TEST_NODE_URL}/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={"choices": [{"message": {"content": "Verified launch artifact"}}]}, + ) + ) + response = client.post( + "/chat", + headers=AUTH, + json={ + "conversation_id": conversation_id, + "prompt": "Use the launch checklist", + "node_id": "node-test", + "model": MODEL_ID, + "project_id": project_id, + }, + ) + assert response.status_code == 200, response.text + messages = json.loads(first_chat.calls.last.request.content)["messages"] + assert messages[0]["content"].endswith( + "PROJECT INSTRUCTIONS\nStay inside the project." + ) + assert messages[1]["content"].startswith("BRAIN PROJECT CONTEXT") + assert "Use the launch checklist." in messages[1]["content"] + assert messages[2]["content"].startswith("PROJECT FILE CONTEXT") + assert "[File: launch.md#1]" in messages[2]["content"] + assert messages[-1] == {"role": "user", "content": "Use the launch checklist"} + + homepage = client.get( + f"/projects/{project_id}/homepage", + headers=AUTH, + ) + assert homepage.status_code == 200, homepage.text + body = homepage.json() + assert body["context_budget"]["quotas"] == { + "project_instructions": 4096, + "brain": 4096, + "file_context": 4915, + "artifact_history": 3277, + } + artifacts = body["components"]["artifact_history"] + assert len(artifacts) == 1 + assert artifacts[0]["preview"] == "Verified launch artifact" + artifact_id = artifacts[0]["artifact_id"] + + preview = client.post( + f"/projects/{project_id}/context-preview", + headers=AUTH, + json={ + "conversation_id": conversation_id, + "query": "Recall the verified artifact", + "model": MODEL_ID, + "max_tokens": 2048, + }, + ) + assert preview.status_code == 200, preview.text + preview_body = preview.json() + preview_messages = preview_body["messages"] + assert preview_messages[0]["content"].endswith( + "PROJECT INSTRUCTIONS\nStay inside the project." + ) + assert preview_messages[1]["content"].startswith("BRAIN PROJECT CONTEXT") + assert preview_messages[2]["content"].startswith("PROJECT FILE CONTEXT") + assert preview_messages[3]["content"].startswith("PROJECT ARTIFACT HISTORY") + assert preview_messages[-1] == { + "role": "user", + "content": "Recall the verified artifact", + } + limits = preview_body["budget"]["available_limits"] + quotas = preview_body["budget"]["quotas"] + assert limits["brain"] > quotas["brain"] + assert limits["file_context"] > quotas["file_context"] + assert limits["artifact_history"] > quotas["artifact_history"] + assert len(client.get(f"/conversations/{conversation_id}", headers=AUTH).json()["messages"]) == 2 + + with respx.mock(assert_all_called=True) as mock: + load_inventory(client, mock) + second_chat = mock.post(f"{TEST_NODE_URL}/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={"choices": [{"message": {"content": "Second answer"}}]}, + ) + ) + response = client.post( + "/chat", + headers=AUTH, + json={ + "conversation_id": conversation_id, + "prompt": "Recall the verified artifact", + "node_id": "node-test", + "model": MODEL_ID, + "project_id": project_id, + }, + ) + assert response.status_code == 200, response.text + second_messages = json.loads(second_chat.calls.last.request.content)["messages"] + assert second_messages[1]["content"].startswith("BRAIN PROJECT CONTEXT") + assert second_messages[2]["content"].startswith("PROJECT FILE CONTEXT") + assert second_messages[3]["content"].startswith("PROJECT ARTIFACT HISTORY") + assert "Verified launch artifact" in second_messages[3]["content"] + + opened = client.get( + f"/projects/{project_id}/artifacts/{artifact_id}", + headers=AUTH, + ) + assert opened.status_code == 200 + assert opened.json()["body"] == "Verified launch artifact" + pinned_artifact = client.put( + f"/projects/{project_id}/artifacts/{artifact_id}", + headers=AUTH, + json={"pinned": True}, + ) + assert pinned_artifact.status_code == 200 + assert pinned_artifact.json()["pinned"] is True + archived = client.put( + f"/projects/{project_id}/artifacts/{artifact_id}", + headers=AUTH, + json={"archived": True}, + ) + assert archived.status_code == 200 + visible = client.get(f"/projects/{project_id}/artifacts", headers=AUTH).json()["artifacts"] + assert all(item["artifact_id"] != artifact_id for item in visible) + all_artifacts = client.get( + f"/projects/{project_id}/artifacts?include_archived=true", + headers=AUTH, + ).json()["artifacts"] + assert any(item["artifact_id"] == artifact_id for item in all_artifacts) + assert client.delete( + f"/projects/{project_id}/artifacts/{artifact_id}", + headers=AUTH, + ).status_code == 200 + assert client.delete( + f"/projects/{project_id}/files/{file_id}", + headers=AUTH, + ).status_code == 200 + assert client.get( + f"/projects/{project_id}/files", + headers=AUTH, + ).json()["files"] == [] + assert not any((data_dir / "project_uploads" / project_id).iterdir()) + + +def test_project_attachment_is_explicit_and_records_future_only_event(router_factory): + _, client, _ = router_factory() + project = create_project(client) + conversation_id = client.post( + "/conversations/from_template", + headers=AUTH, + json={"template_name": "general", "project_id": None}, + ).json()["conversation_id"] + + rejected = client.post( + "/chat", + headers=AUTH, + json={ + "conversation_id": conversation_id, + "prompt": "Do not attach implicitly", + "node_id": "node-test", + "model": MODEL_ID, + "project_id": project["project_id"], + }, + ) + assert rejected.status_code == 409 + assert "explicit conversation project endpoint" in rejected.json()["detail"] + + attached = client.put( + f"/conversations/{conversation_id}/project", + headers=AUTH, + json={"project_id": project["project_id"]}, + ) + assert attached.status_code == 200, attached.text + event = attached.json()["context_events"][-1] + assert event["project_id"] == project["project_id"] + assert event["applies_to"] == "future_messages_only" + + detached = client.put( + f"/conversations/{conversation_id}/project", + headers=AUTH, + json={"project_id": None}, + ) + assert detached.status_code == 200 + assert detached.json()["project_id"] is None + + +def test_instruction_and_brain_protected_content_cannot_overflow_allocations(router_factory): + _, client, _ = router_factory() + project = create_project(client, context_budget_tokens=1024) + project_id = project["project_id"] + + instructions = client.put( + f"/projects/{project_id}", + headers=AUTH, + json={"system_prompt": "x" * 1100}, + ) + assert instructions.status_code == 422 + assert "25 percent" in instructions.json()["detail"] + + brain = client.put( + f"/projects/{project_id}/brain", + headers=AUTH, + json={"pinned_text": "p" * 1100, "active_text": "a" * 1100}, + ) + assert brain.status_code == 422 + assert "protected allocation" in brain.json()["detail"] diff --git a/tests/test_project_context_cli.py b/tests/test_project_context_cli.py new file mode 100644 index 0000000..5128dba --- /dev/null +++ b/tests/test_project_context_cli.py @@ -0,0 +1,56 @@ +from scripts import project_context_cli + + +def test_pin_command_reads_current_revision_and_appends_pinned_text(monkeypatch): + calls = [] + + def fake_request(api_base, api_key, method, path, payload=None): + calls.append((api_base, api_key, method, path, payload)) + if method == "GET": + return {"revision": 7, "pinned_text": "Existing decision"} + return {"revision": 8, **(payload or {})} + + monkeypatch.setattr(project_context_cli, "request_json", fake_request) + args = project_context_cli.build_parser().parse_args( + ["--api-base", "http://router.test", "pin", "proj one", "--text", "New fact"] + ) + result = project_context_cli.run(args, "secret") + + assert calls[0][2:] == ("GET", "/projects/proj%20one/brain", None) + assert calls[1][2] == "PUT" + assert calls[1][4] == { + "pinned_text": "Existing decision\nNew fact", + "expected_revision": 7, + } + assert result["revision"] == 8 + + +def test_edit_command_accepts_utf8_tier_files(tmp_path, monkeypatch): + active_file = tmp_path / "active.md" + active_file.write_text("Goal: finish verification.", encoding="utf-8") + captured = {} + + def fake_request(api_base, api_key, method, path, payload=None): + captured.update(payload or {}) + return {"revision": 3} + + monkeypatch.setattr(project_context_cli, "request_json", fake_request) + args = project_context_cli.build_parser().parse_args( + [ + "edit", + "proj_1", + "--active-file", + str(active_file), + "--compact-threshold", + "2048", + "--expected-revision", + "2", + ] + ) + project_context_cli.run(args, "secret") + + assert captured == { + "active_text": "Goal: finish verification.", + "compact_threshold": 2048, + "expected_revision": 2, + } diff --git a/tests/test_security_and_frontend.py b/tests/test_security_and_frontend.py index d2c4c81..3fac10c 100644 --- a/tests/test_security_and_frontend.py +++ b/tests/test_security_and_frontend.py @@ -121,6 +121,7 @@ def test_data_dir_contains_every_persistence_artifact(router_factory): router.VECTOR_DB, router.FEEDBACK_DB, router.PERFORMANCE_DB, + router.PROJECT_CONTEXT_DB, router.COST_LOG, } assert {path.name for path in paths} == { @@ -130,6 +131,7 @@ def test_data_dir_contains_every_persistence_artifact(router_factory): "dave_vectors.db", "feedback.db", "performance.db", + "dave_project_context.db", "cost_log.jsonl", } assert all(path.parent == data_dir.resolve() for path in paths) @@ -174,6 +176,7 @@ def test_displayed_prompt_contract_and_renderer_security(): app_source = (repo / "static" / "app.js").read_text() index_source = (repo / "static" / "index.html").read_text() style_source = (repo / "static" / "style.css").read_text() + gsap_source = (repo / "static" / "vendor" / "gsap" / "gsap.min.js").read_text() monitoring_source = (repo / "static" / "monitoring.html").read_text() preload_source = (repo / "desktop" / "preload.js").read_text() main_source = (repo / "desktop" / "main.js").read_text() @@ -212,6 +215,34 @@ def test_displayed_prompt_contract_and_renderer_security(): assert 'id="effectiveInstructions"' in index_source assert 'id="notepadPanel"' in index_source assert 'id="notepadInput"' in index_source + assert 'id="projectHomeDialog"' in index_source + assert 'id="projectHomeInstructions"' in index_source + assert 'id="projectFilesList"' in index_source + assert 'id="projectArtifactsList"' in index_source + assert 'id="brainPinned"' in index_source + assert 'id="brainActive"' in index_source + assert 'id="brainRecent"' in index_source + assert 'id="previewProjectContext"' in index_source + assert 'id="projectContextPreviewOutput"' in index_source + assert "async function openProjectHomepage()" in app_source + assert "/homepage`)" in app_source + assert "/context-preview`)" in app_source + assert "/brain/compact`)" in app_source + assert "/project`)" in app_source + assert "vendor/lucide/lucide.svg#image" in index_source + assert "vendor/lucide/lucide.svg#file-audio" in index_source + assert "vendor/lucide/lucide.svg#captions" in index_source + assert "vendor/lucide/lucide.svg#mic" in index_source + assert "vendor/lucide/lucide.svg#paperclip" in index_source + assert '' in index_source + assert "GSAP 3.15.0" in gsap_source[:200] + assert "window.gsap.matchMedia()" in app_source + assert '"(prefers-reduced-motion: no-preference)"' in app_source + assert '"(prefers-reduced-motion: reduce)"' in app_source + assert all( + glyph not in index_source + app_source + for glyph in ("📷", "🎤", "🗣️", "✍️", "🎙️", "⏹️", "📎") + ) assert 'aria-controls="notepadPanel"' in index_source assert "navigator.clipboard?.writeText" in app_source assert "rawMessageText(message)" in app_source