Files
workspace/docs/superpowers/plans/2026-05-11-project-folder-integration.md
Roberto 0abed9563b docs: add project-folder integration implementation plan
32 bite-sized tasks across 6 phases: API foundation, indexer (text +
vision + PDF/DOCX), agent wiring, Electron schema + scanner, renderer
UI, end-to-end smoke verification. Linked spec:
docs/superpowers/specs/2026-05-11-project-folder-integration-design.md
2026-05-11 22:24:34 +02:00

3036 lines
96 KiB
Markdown

# Project Folder Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users link a local folder to a project; backend agents (Home / Brief / Task-Brief) receive a pre-injected manifest of per-file summaries and can read files on demand via a scoped tool.
**Architecture:** Lightweight manifest (no embeddings). Electron scans + streams files over the existing `/api/v1/device` WS. Backend generates per-file summaries via gpt-4o-mini (text + vision), records token usage in Postgres, returns summaries to Electron which stores them locally. Agents pre-inject the manifest and lazy-read file content through a new scoped tool.
**Tech Stack:**
- API: FastAPI, SQLAlchemy 2.0 async, Alembic, LiteLLM, Langfuse, pytest + pytest-asyncio.
- adiuvAI: Electron, TypeScript, Drizzle ORM + better-sqlite3, React 19, TanStack Router, tRPC v11, shadcn/ui new-york.
**Reference spec:** `docs/superpowers/specs/2026-05-11-project-folder-integration-design.md`
---
## File Inventory
### API (Python)
| File | Action | Responsibility |
|---|---|---|
| `api/alembic/versions/d6e3f4a5b6c7_folder_index_tables.py` | Create | Migration: `agent_run_logs.tokens_used`, `monthly_token_usage` |
| `api/app/models.py` | Modify | Add `MonthlyTokenUsage` model, `AgentRunLog.tokens_used` |
| `api/app/billing/tier_manager.py` | Modify | Add `folder_max_files`, `folder_monthly_tokens` features |
| `api/app/billing/quota.py` | Create | `check_folder_quota`, `add_token_usage` helpers |
| `api/app/api/routes/billing.py` | Modify | Add `POST /quota/check` endpoint |
| `api/app/core/folder_indexer.py` | Create | `summarize_text`, `summarize_image` LLM calls |
| `api/app/agents/folder_agent.py` | Create | Scoped `read_project_folder_file` tool |
| `api/app/api/routes/device_ws.py` | Modify | New frame handlers (`index_*`) |
| `api/app/core/deep_agent.py` | Modify | Manifest pre-injection in `run_home`, `run_brief`, `run_task_brief_research_stream` |
| `api/tests/test_folder_indexer.py` | Create | summarize_text/image happy path + token recording |
| `api/tests/test_folder_quota.py` | Create | tier rejection + atomic increment + monthly rollover |
| `api/tests/test_ws_index_session.py` | Create | session lifecycle + cancel + bad payloads |
| `api/tests/test_folder_agent_tool.py` | Create | scoped tool + traversal guard |
| `api/tests/test_manifest_injection.py` | Create | manifest formatting + truncation |
### adiuvAI (TypeScript)
| File | Action | Responsibility |
|---|---|---|
| `adiuvAI/src/main/db/schema.ts` | Modify | Add `projects.folder*` columns + `projectFolderFiles` table |
| `adiuvAI/src/main/db/migrations/0008_project_folders.sql` | Create | Drizzle SQL migration |
| `adiuvAI/src/main/db/migrations/meta/_journal.json` | Modify | Register new migration |
| `adiuvAI/src/main/files/constants.ts` | Create | Extension whitelist, size caps |
| `adiuvAI/src/main/files/scanner.ts` | Create | Walk dir + filter + mtime delta |
| `adiuvAI/src/main/files/indexer.ts` | Create | WS index session orchestration |
| `adiuvAI/src/main/files/daily-rescan.ts` | Create | On-ready 24h staleness check |
| `adiuvAI/src/main/router/projectFolders.ts` | Create | tRPC procedures |
| `adiuvAI/src/main/router/index.ts` | Modify | Register `projectFolders` sub-router |
| `adiuvAI/src/main/api/backend-client.ts` | Modify | Add `sendIndexBatch`, `cancelIndexSession` |
| `adiuvAI/src/main/api/drizzle-executor.ts` | Modify | Add `read_project_folder_manifest`, `read_project_folder_file`, `list_projects_with_folder_manifests` |
| `adiuvAI/src/main/index.ts` | Modify | Wire `runDailyRescan()` on `ready` |
| `adiuvAI/src/renderer/components/projects/folder/FolderChip.tsx` | Create | Hero chip |
| `adiuvAI/src/renderer/components/projects/folder/FilesSection.tsx` | Create | Files tab body |
| `adiuvAI/src/renderer/components/projects/folder/FolderLinkCard.tsx` | Create | Path + Rescan + Unlink |
| `adiuvAI/src/renderer/components/projects/folder/FolderFileList.tsx` | Create | Manifest list |
| `adiuvAI/src/renderer/components/projects/folder/FolderUnlinkDialog.tsx` | Create | Confirm unlink |
| `adiuvAI/src/renderer/components/projects/ProjectTabBar.tsx` | Modify | Add `'files'` section |
| `adiuvAI/src/renderer/components/projects/ProjectDetail.tsx` | Modify | Wire chip + Files section |
| `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` | Modify | Add `projects.folder.*` keys |
---
## Phase A — Backend Foundation (Quota + Migrations)
### Task 1: Alembic migration — folder index tables
**Files:**
- Create: `api/alembic/versions/d6e3f4a5b6c7_folder_index_tables.py`
- [ ] **Step 1: Write the migration file**
```python
"""Add token tracking columns for folder integration.
Revision ID: d6e3f4a5b6c7
Revises: e04100e88ace
Create Date: 2026-05-11 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "d6e3f4a5b6c7"
down_revision: Union[str, None] = "e04100e88ace"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"agent_run_logs",
sa.Column("tokens_used", sa.Integer(), nullable=False, server_default="0"),
)
op.create_table(
"monthly_token_usage",
sa.Column("user_id", UUID(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("year_month", sa.String(7), nullable=False),
sa.Column("feature", sa.String(64), nullable=False),
sa.Column("tokens_used", sa.Integer(), nullable=False, server_default="0"),
sa.PrimaryKeyConstraint("user_id", "year_month", "feature"),
)
op.create_index(
"ix_monthly_token_usage_user_month",
"monthly_token_usage",
["user_id", "year_month"],
)
def downgrade() -> None:
op.drop_index("ix_monthly_token_usage_user_month", table_name="monthly_token_usage")
op.drop_table("monthly_token_usage")
op.drop_column("agent_run_logs", "tokens_used")
```
- [ ] **Step 2: Run migration locally**
Run: `cd api && alembic upgrade head`
Expected: `INFO [alembic.runtime.migration] Running upgrade e04100e88ace -> d6e3f4a5b6c7`
- [ ] **Step 3: Commit**
```bash
git add api/alembic/versions/d6e3f4a5b6c7_folder_index_tables.py
git commit -m "feat(api): add migration for folder token tracking"
```
---
### Task 2: ORM model — MonthlyTokenUsage + AgentRunLog.tokens_used
**Files:**
- Modify: `api/app/models.py` (add column to `AgentRunLog`, new `MonthlyTokenUsage` class)
- [ ] **Step 1: Add `tokens_used` column to `AgentRunLog`**
Insert after `items_created` (around line 245):
```python
tokens_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
```
- [ ] **Step 2: Add `MonthlyTokenUsage` model**
Append below `AgentRunLog` (after line 263):
```python
class MonthlyTokenUsage(Base):
__tablename__ = "monthly_token_usage"
user_id: Mapped[str] = mapped_column(
Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
year_month: Mapped[str] = mapped_column(String(7), primary_key=True) # 'YYYY-MM'
feature: Mapped[str] = mapped_column(String(64), primary_key=True)
tokens_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
```
- [ ] **Step 3: Run existing test suite to verify no regression**
Run: `cd api && pytest tests/ -x --tb=short`
Expected: PASS (existing tests unaffected by added column)
- [ ] **Step 4: Commit**
```bash
git add api/app/models.py
git commit -m "feat(api): MonthlyTokenUsage model + AgentRunLog.tokens_used"
```
---
### Task 3: Tier matrix — folder features
**Files:**
- Modify: `api/app/billing/tier_manager.py:20-69`
- [ ] **Step 1: Add features to each tier**
Add inside each tier dict in `FEATURES`:
```python
"free": {
# existing keys...
"folder_max_files": 200,
"folder_monthly_tokens": 100_000,
},
"pro": {
# existing keys...
"folder_max_files": 5000,
"folder_monthly_tokens": 2_000_000,
},
"power": {
# existing keys...
"folder_max_files": -1,
"folder_monthly_tokens": -1,
},
"team": {
# existing keys...
"folder_max_files": -1,
"folder_monthly_tokens": -1,
},
```
- [ ] **Step 2: Add `get_feature_value` helper**
Append a method to `TierManager` class (after `require_feature`):
```python
def get_feature_value(self, tier: BillingTier, feature: str) -> int:
"""Return integer feature value for tier. -1 means unlimited."""
value = FEATURES.get(tier, FEATURES["free"]).get(feature)
if not isinstance(value, int):
return 0
return value
```
- [ ] **Step 3: Commit**
```bash
git add api/app/billing/tier_manager.py
git commit -m "feat(api): tier features for folder integration"
```
---
### Task 4: Quota helpers (TDD)
**Files:**
- Create: `api/app/billing/quota.py`
- Test: `api/tests/test_folder_quota.py`
- [ ] **Step 1: Write the failing test**
```python
# api/tests/test_folder_quota.py
"""Folder quota helpers."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app.billing.quota import (
check_folder_quota,
add_token_usage,
QuotaExceeded,
)
from app.models import MonthlyTokenUsage
pytestmark = pytest.mark.asyncio
async def test_check_folder_quota_free_rejects_above_file_cap(db, test_user_free):
with pytest.raises(QuotaExceeded) as exc:
await check_folder_quota(
user_id=test_user_free.id, tier="free", estimated_files=500, db=db
)
assert exc.value.reason == "max_files"
async def test_check_folder_quota_free_passes_under_cap(db, test_user_free):
# No raise
await check_folder_quota(
user_id=test_user_free.id, tier="free", estimated_files=50, db=db
)
async def test_check_folder_quota_rejects_when_monthly_exhausted(db, test_user_free):
ym = datetime.now(timezone.utc).strftime("%Y-%m")
db.add(MonthlyTokenUsage(
user_id=test_user_free.id, year_month=ym, feature="folder_index", tokens_used=100_000
))
await db.commit()
with pytest.raises(QuotaExceeded) as exc:
await check_folder_quota(
user_id=test_user_free.id, tier="free", estimated_files=10, db=db
)
assert exc.value.reason == "monthly_tokens"
async def test_check_folder_quota_power_unlimited(db, test_user_power):
await check_folder_quota(
user_id=test_user_power.id, tier="power", estimated_files=999_999, db=db
)
async def test_add_token_usage_atomic_increment(db, test_user_free):
await add_token_usage(user_id=test_user_free.id, feature="folder_index", tokens=1500, db=db)
await add_token_usage(user_id=test_user_free.id, feature="folder_index", tokens=2500, db=db)
ym = datetime.now(timezone.utc).strftime("%Y-%m")
row = (await db.execute(
select(MonthlyTokenUsage).where(
MonthlyTokenUsage.user_id == test_user_free.id,
MonthlyTokenUsage.year_month == ym,
MonthlyTokenUsage.feature == "folder_index",
)
)).scalar_one()
assert row.tokens_used == 4000
async def test_add_token_usage_returns_exhausted_when_over_cap(db, test_user_free):
result = await add_token_usage(
user_id=test_user_free.id, feature="folder_index", tokens=150_000, db=db, cap=100_000
)
assert result.exhausted is True
assert result.tokens_used == 150_000
```
You will also need to add `test_user_free` and `test_user_power` fixtures to `api/tests/conftest.py` (one user with `Subscription.tier='free'`, one with `'power'`) — follow the existing user-creation pattern in that file.
- [ ] **Step 2: Run test — expect import errors**
Run: `cd api && pytest tests/test_folder_quota.py -v`
Expected: ImportError (`app.billing.quota` does not exist).
- [ ] **Step 3: Implement `app/billing/quota.py`**
```python
"""Quota checks and atomic token-usage accounting for folder integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.billing.tier_manager import TierManager
from app.models import MonthlyTokenUsage
from app.schemas import BillingTier
class QuotaExceeded(Exception):
"""Raised when a folder operation cannot proceed under the user's tier."""
def __init__(self, reason: str, message: str) -> None:
super().__init__(message)
self.reason = reason # "max_files" | "monthly_tokens"
@dataclass
class TokenUsageResult:
tokens_used: int
exhausted: bool
def _current_year_month() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m")
_tier_manager = TierManager()
async def check_folder_quota(
*,
user_id: str,
tier: BillingTier,
estimated_files: int,
db: AsyncSession,
) -> None:
"""Raise QuotaExceeded if folder_max_files or folder_monthly_tokens
would be violated. -1 in either feature means unlimited."""
max_files = _tier_manager.get_feature_value(tier, "folder_max_files")
if max_files != -1 and estimated_files > max_files:
raise QuotaExceeded(
"max_files",
f"Folder has {estimated_files} files; tier '{tier}' allows max {max_files}.",
)
cap = _tier_manager.get_feature_value(tier, "folder_monthly_tokens")
if cap == -1:
return
ym = _current_year_month()
row = (
await db.execute(
select(MonthlyTokenUsage).where(
MonthlyTokenUsage.user_id == user_id,
MonthlyTokenUsage.year_month == ym,
MonthlyTokenUsage.feature == "folder_index",
)
)
).scalar_one_or_none()
used = row.tokens_used if row else 0
if used >= cap:
raise QuotaExceeded(
"monthly_tokens",
f"Monthly token budget exhausted ({used}/{cap}); resets next month.",
)
async def add_token_usage(
*,
user_id: str,
feature: str,
tokens: int,
db: AsyncSession,
cap: int | None = None,
) -> TokenUsageResult:
"""Atomically add `tokens` to MonthlyTokenUsage row for (user, current month, feature).
Returns post-update total + whether cap is exhausted."""
ym = _current_year_month()
stmt = pg_insert(MonthlyTokenUsage).values(
user_id=user_id, year_month=ym, feature=feature, tokens_used=tokens,
).on_conflict_do_update(
index_elements=["user_id", "year_month", "feature"],
set_={"tokens_used": MonthlyTokenUsage.tokens_used + tokens},
).returning(MonthlyTokenUsage.tokens_used)
used = (await db.execute(stmt)).scalar_one()
await db.commit()
exhausted = cap is not None and cap != -1 and used >= cap
return TokenUsageResult(tokens_used=used, exhausted=exhausted)
```
- [ ] **Step 4: Run tests, verify PASS**
Run: `cd api && pytest tests/test_folder_quota.py -v`
Expected: 6 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/billing/quota.py api/tests/test_folder_quota.py api/tests/conftest.py
git commit -m "feat(api): folder quota helpers with atomic token usage"
```
---
### Task 5: `POST /api/v1/billing/quota/check` endpoint
**Files:**
- Modify: `api/app/api/routes/billing.py`
- Test: extend `api/tests/test_folder_quota.py`
- [ ] **Step 1: Write failing endpoint test**
Append to `api/tests/test_folder_quota.py`:
```python
async def test_quota_check_endpoint_rejects(client, auth_headers_free):
res = await client.post(
"/api/v1/billing/quota/check",
json={"feature": "folder_index", "estimated_files": 500},
headers=auth_headers_free,
)
assert res.status_code == 402
body = res.json()
assert body["detail"]["reason"] == "max_files"
async def test_quota_check_endpoint_passes(client, auth_headers_free):
res = await client.post(
"/api/v1/billing/quota/check",
json={"feature": "folder_index", "estimated_files": 50},
headers=auth_headers_free,
)
assert res.status_code == 200
assert res.json() == {"ok": True}
```
(Reuse the `client` and `auth_headers_*` fixtures from `conftest.py`.)
- [ ] **Step 2: Run — expect 404 (route missing)**
Run: `cd api && pytest tests/test_folder_quota.py::test_quota_check_endpoint_rejects -v`
Expected: assertion 404 != 402
- [ ] **Step 3: Add endpoint to `billing.py`**
Add at the bottom of `api/app/api/routes/billing.py`:
```python
from pydantic import BaseModel
from app.billing.quota import check_folder_quota, QuotaExceeded
class QuotaCheckRequest(BaseModel):
feature: str
estimated_files: int
@router.post("/quota/check")
async def quota_check(
payload: QuotaCheckRequest,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Pre-flight folder quota check. 402 if tier limits would be exceeded."""
if payload.feature != "folder_index":
raise HTTPException(status_code=400, detail="Unknown feature")
try:
await check_folder_quota(
user_id=current_user.id,
tier=current_user.tier,
estimated_files=payload.estimated_files,
db=db,
)
except QuotaExceeded as exc:
raise HTTPException(
status_code=402,
detail={"reason": exc.reason, "message": str(exc)},
)
return {"ok": True}
```
If `HTTPException`, `Depends`, `get_current_user`, `get_db`, `AsyncSession`, or `router` are not yet imported in `billing.py`, add them at the top (match existing imports in that file).
- [ ] **Step 4: Run tests, verify PASS**
Run: `cd api && pytest tests/test_folder_quota.py -v`
Expected: 8 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/api/routes/billing.py api/tests/test_folder_quota.py
git commit -m "feat(api): POST /billing/quota/check endpoint"
```
---
## Phase B — Backend Indexer
### Task 6: Langfuse prompts (manual)
**Files:** (none — Langfuse UI)
- [ ] **Step 1: In the Langfuse UI, create two text prompts**
Prompt 1: `folder_file_summary_text`
```
You are summarising a file for an AI assistant that helps the user manage a project.
Produce a single sentence (≤30 words, ≤200 chars) that captures the file's purpose and most important detail.
File extension: {{ext}}
File name: {{name}}
Content (truncated if long):
{{content}}
```
Prompt 2: `folder_file_summary_image`
```
You are summarising an image attached to a project folder.
Produce a single sentence (≤30 words, ≤200 chars) describing what the image shows and any obvious purpose (logo, screenshot, diagram, photo of a whiteboard, etc.).
```
Tag both as `production`. Note: backend will use these via `get_prompt_or_fallback` so hardcoded fallbacks are mandatory in the next task.
- [ ] **Step 2: No commit (Langfuse-only).** Proceed to Task 7.
---
### Task 7: `core/folder_indexer.py` — summarize_text (TDD)
**Files:**
- Create: `api/app/core/folder_indexer.py`
- Test: `api/tests/test_folder_indexer.py`
- [ ] **Step 1: Write failing test**
```python
# api/tests/test_folder_indexer.py
"""Folder indexer LLM helpers."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from app.core.folder_indexer import summarize_text, summarize_image, IndexResult
pytestmark = pytest.mark.asyncio
async def test_summarize_text_returns_summary_and_tokens():
mock_resp = AsyncMock()
mock_resp.content = "Kickoff notes covering scope and deadlines."
mock_resp.usage_metadata = {"input_tokens": 320, "output_tokens": 18, "total_tokens": 338}
with patch("app.core.folder_indexer._llm_text", new=AsyncMock(return_value=mock_resp)):
result = await summarize_text(content="hello world", ext=".md", name="kickoff.md")
assert isinstance(result, IndexResult)
assert result.summary == "Kickoff notes covering scope and deadlines."
assert result.tokens_used == 338
async def test_summarize_text_truncates_summary_at_500_chars():
mock_resp = AsyncMock()
mock_resp.content = "x" * 1000
mock_resp.usage_metadata = {"total_tokens": 100}
with patch("app.core.folder_indexer._llm_text", new=AsyncMock(return_value=mock_resp)):
result = await summarize_text(content="x", ext=".md", name="x.md")
assert len(result.summary) <= 500
```
- [ ] **Step 2: Run — expect ImportError**
Run: `cd api && pytest tests/test_folder_indexer.py -v`
Expected: ImportError
- [ ] **Step 3: Implement `app/core/folder_indexer.py` (text only)**
```python
"""Per-file summarisation for project folder integration."""
from __future__ import annotations
from dataclasses import dataclass
from langchain_core.messages import HumanMessage, SystemMessage
from app.core.langfuse_client import (
compile_prompt,
extract_usage,
get_prompt_or_fallback,
)
from app.core.llm import get_llm
_TEXT_FALLBACK = (
"You are summarising a file for an AI assistant that helps the user manage a project.\n"
"Produce a single sentence (≤30 words, ≤200 chars) that captures the file's purpose "
"and most important detail.\nFile extension: {{ext}}\nFile name: {{name}}\nContent (truncated if long):\n{{content}}"
)
_IMAGE_FALLBACK = (
"You are summarising an image attached to a project folder.\n"
"Produce a single sentence (≤30 words, ≤200 chars) describing what the image shows "
"and any obvious purpose (logo, screenshot, diagram, photo of a whiteboard, etc.)."
)
_MAX_INPUT_CHARS = 6000
@dataclass
class IndexResult:
summary: str
tokens_used: int
async def _llm_text():
"""Indirection so tests can patch the LLM call."""
return get_llm(model="gpt-4o-mini", temperature=0.2)
async def summarize_text(*, content: str, ext: str, name: str) -> IndexResult:
template, prompt_obj = get_prompt_or_fallback("folder_file_summary_text", _TEXT_FALLBACK)
truncated = content[:_MAX_INPUT_CHARS]
compiled = compile_prompt(template, ext=ext, name=name, content=truncated)
llm = await _llm_text()
response = await llm.ainvoke([
SystemMessage(content=compiled),
HumanMessage(content="Summarise this file."),
])
usage = extract_usage(response)
summary = (response.content or "").strip()[:500]
return IndexResult(summary=summary, tokens_used=usage.get("total_tokens", 0))
```
Note: leave `summarize_image` undefined for now — the test for it will fail in Task 8.
- [ ] **Step 4: Run text tests, verify PASS**
Run: `cd api && pytest tests/test_folder_indexer.py::test_summarize_text_returns_summary_and_tokens tests/test_folder_indexer.py::test_summarize_text_truncates_summary_at_500_chars -v`
Expected: 2 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/core/folder_indexer.py api/tests/test_folder_indexer.py
git commit -m "feat(api): folder_indexer.summarize_text via gpt-4o-mini"
```
---
### Task 8: `summarize_image` (vision)
**Files:**
- Modify: `api/app/core/folder_indexer.py`
- Modify: `api/tests/test_folder_indexer.py`
- [ ] **Step 1: Append failing test**
```python
async def test_summarize_image_uses_vision_content_blocks():
mock_resp = AsyncMock()
mock_resp.content = "Final logo on white background."
mock_resp.usage_metadata = {"total_tokens": 500}
captured = {}
async def fake_invoke(messages):
captured["messages"] = messages
return mock_resp
fake_llm = AsyncMock()
fake_llm.ainvoke = fake_invoke
with patch("app.core.folder_indexer._llm_vision", new=AsyncMock(return_value=fake_llm)):
result = await summarize_image(image_b64="iVBORw0KG", mime="image/png")
assert "Final logo" in result.summary
assert result.tokens_used == 500
# last message contains an image content block
last = captured["messages"][-1]
assert any(
isinstance(p, dict) and p.get("type") == "image_url"
for p in (last.content if isinstance(last.content, list) else [])
)
```
- [ ] **Step 2: Run — expect failure**
Run: `cd api && pytest tests/test_folder_indexer.py::test_summarize_image_uses_vision_content_blocks -v`
Expected: FAIL (`summarize_image` not defined)
- [ ] **Step 3: Implement `summarize_image`**
Add at the bottom of `app/core/folder_indexer.py`:
```python
async def _llm_vision():
return get_llm(model="gpt-4o-mini", temperature=0.2)
async def summarize_image(*, image_b64: str, mime: str) -> IndexResult:
template, prompt_obj = get_prompt_or_fallback("folder_file_summary_image", _IMAGE_FALLBACK)
llm = await _llm_vision()
response = await llm.ainvoke([
SystemMessage(content=template),
HumanMessage(content=[
{"type": "text", "text": "Summarise this image."},
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image_b64}"}},
]),
])
usage = extract_usage(response)
summary = (response.content or "").strip()[:500]
return IndexResult(summary=summary, tokens_used=usage.get("total_tokens", 0))
```
- [ ] **Step 4: Run test, verify PASS**
Run: `cd api && pytest tests/test_folder_indexer.py -v`
Expected: 3 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/core/folder_indexer.py api/tests/test_folder_indexer.py
git commit -m "feat(api): folder_indexer.summarize_image via gpt-4o-mini vision"
```
---
### Task 8b: PDF & DOCX text extraction in indexer
**Files:**
- Modify: `api/app/core/folder_indexer.py`
- Modify: `api/tests/test_folder_indexer.py`
- Modify: `api/pyproject.toml` (add `pypdf`, `python-docx` deps)
- [ ] **Step 1: Append failing tests**
```python
async def test_summarize_pdf_extracts_then_summarizes(monkeypatch):
# pypdf.PdfReader returns text from pages
from app.core import folder_indexer
class FakePage:
def extract_text(self): return "PDF page content with project info."
class FakeReader:
pages = [FakePage(), FakePage()]
monkeypatch.setattr(folder_indexer, "PdfReader", lambda buf: FakeReader())
mock_resp = AsyncMock(); mock_resp.content = "Project info doc."; mock_resp.usage_metadata = {"total_tokens": 50}
with patch("app.core.folder_indexer._llm_text", new=AsyncMock(return_value=mock_resp)):
result = await folder_indexer.summarize_pdf(pdf_b64="SGVsbG8=", name="doc.pdf")
assert "Project info" in result.summary
assert result.tokens_used == 50
async def test_summarize_docx_extracts_then_summarizes(monkeypatch):
from app.core import folder_indexer
class FakePara:
def __init__(self, t): self.text = t
class FakeDoc:
paragraphs = [FakePara("Heading"), FakePara("Body paragraph one.")]
monkeypatch.setattr(folder_indexer, "DocxDocument", lambda buf: FakeDoc())
mock_resp = AsyncMock(); mock_resp.content = "Heading and body."; mock_resp.usage_metadata = {"total_tokens": 30}
with patch("app.core.folder_indexer._llm_text", new=AsyncMock(return_value=mock_resp)):
result = await folder_indexer.summarize_docx(docx_b64="UEsDBBQ=", name="doc.docx")
assert result.summary == "Heading and body."
```
- [ ] **Step 2: Add dependencies**
Edit `api/pyproject.toml` `[project.dependencies]` block — append:
```toml
"pypdf>=4.0",
"python-docx>=1.1",
```
Run: `cd api && pip install -e .`
Expected: dependencies installed.
- [ ] **Step 3: Add extractor helpers + summarize wrappers**
Append to `app/core/folder_indexer.py`:
```python
import base64
import io
from pypdf import PdfReader
from docx import Document as DocxDocument
def _extract_pdf_text(pdf_b64: str) -> str:
buf = io.BytesIO(base64.b64decode(pdf_b64))
reader = PdfReader(buf)
parts: list[str] = []
for page in reader.pages:
try:
parts.append(page.extract_text() or "")
except Exception:
continue
return "\n".join(parts).strip()
def _extract_docx_text(docx_b64: str) -> str:
buf = io.BytesIO(base64.b64decode(docx_b64))
doc = DocxDocument(buf)
return "\n".join(p.text for p in doc.paragraphs if p.text).strip()
async def summarize_pdf(*, pdf_b64: str, name: str) -> IndexResult:
text = _extract_pdf_text(pdf_b64)
if not text:
return IndexResult(summary="Could not extract text", tokens_used=0)
return await summarize_text(content=text, ext=".pdf", name=name)
async def summarize_docx(*, docx_b64: str, name: str) -> IndexResult:
text = _extract_docx_text(docx_b64)
if not text:
return IndexResult(summary="Could not extract text", tokens_used=0)
return await summarize_text(content=text, ext=".docx", name=name)
```
- [ ] **Step 4: Run tests, verify PASS**
Run: `cd api && pytest tests/test_folder_indexer.py -v`
Expected: 5 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/core/folder_indexer.py api/tests/test_folder_indexer.py api/pyproject.toml
git commit -m "feat(api): PDF + DOCX extraction in folder indexer"
```
---
### Task 9: WS index session — frames & state machine
**Files:**
- Modify: `api/app/api/routes/device_ws.py`
- Test: `api/tests/test_ws_index_session.py`
- [ ] **Step 1: Read existing `device_ws.py` to find the frame-dispatch switch**
Run: `grep -n "frame.*type\|type ==" api/app/api/routes/device_ws.py | head -20`
Note the function that dispatches on `frame["type"]`. New cases go there.
- [ ] **Step 2: Write failing tests**
```python
# api/tests/test_ws_index_session.py
"""WS index session lifecycle."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
pytestmark = pytest.mark.asyncio
async def test_index_session_happy_path(ws_client, test_user_power):
# Open WS via fixture
await ws_client.send_json({
"type": "index_session_start",
"sessionId": "s1",
"projectId": "p1",
"totalFiles": 2,
})
# Patch indexer so we don't actually call OpenAI
with patch("app.api.routes.device_ws.summarize_text", new=AsyncMock(
return_value=type("R", (), {"summary": "ok", "tokens_used": 10})()
)):
await ws_client.send_json({
"type": "index_file_batch",
"sessionId": "s1",
"files": [
{"relPath": "a.md", "kind": "text", "content": "hello", "sizeBytes": 5, "mtimeMs": 1},
{"relPath": "b.md", "kind": "text", "content": "world", "sizeBytes": 5, "mtimeMs": 2},
],
})
results = []
for _ in range(3): # 2 file results + 1 progress
msg = await ws_client.receive_json()
results.append(msg)
types = [m["type"] for m in results]
assert types.count("index_file_result") == 2
assert "index_session_progress" in types
async def test_index_session_cancel(ws_client):
await ws_client.send_json({
"type": "index_session_start", "sessionId": "s2", "projectId": "p1", "totalFiles": 100,
})
await ws_client.send_json({"type": "index_session_cancel", "sessionId": "s2"})
msg = await ws_client.receive_json()
assert msg["type"] == "index_session_done"
assert msg["status"] == "cancelled"
async def test_index_session_quota_exceeded(ws_client, test_user_free):
# Pre-fill monthly_token_usage to exhaust the cap (see conftest helper)
await exhaust_folder_tokens(test_user_free, ws_client) # helper in conftest
await ws_client.send_json({
"type": "index_session_start", "sessionId": "s3", "projectId": "p1", "totalFiles": 1,
})
await ws_client.send_json({
"type": "index_file_batch", "sessionId": "s3",
"files": [{"relPath": "x.md", "kind": "text", "content": "x", "sizeBytes": 1, "mtimeMs": 1}],
})
msgs = [await ws_client.receive_json() for _ in range(2)]
assert any(m.get("type") == "index_session_done" and m.get("status") == "quota_exceeded" for m in msgs)
```
The `ws_client` fixture and `exhaust_folder_tokens` helper need to be added to `tests/conftest.py` if absent; pattern in existing `tests/test_ws_unified.py`.
- [ ] **Step 3: Run — expect failure**
Run: `cd api && pytest tests/test_ws_index_session.py -v`
Expected: assertion failures (frame types unknown — handler doesn't exist).
- [ ] **Step 4: Implement frame handlers in `device_ws.py`**
Add a new module-level dict to hold per-session state:
```python
# Top of device_ws.py
_index_sessions: dict[str, dict] = {} # sessionId -> { user_id, project_id, processed, total, cancelled }
```
Add three new cases in the dispatch:
```python
elif frame_type == "index_session_start":
_index_sessions[frame["sessionId"]] = {
"user_id": ws_ctx.user_id,
"project_id": frame["projectId"],
"processed": 0,
"total": int(frame["totalFiles"]),
"cancelled": False,
}
elif frame_type == "index_session_cancel":
sess = _index_sessions.get(frame["sessionId"])
if sess:
sess["cancelled"] = True
await ws.send_json({
"type": "index_session_done",
"sessionId": frame["sessionId"],
"status": "cancelled",
})
_index_sessions.pop(frame["sessionId"], None)
elif frame_type == "index_file_batch":
from app.core.folder_indexer import summarize_text, summarize_image, summarize_pdf, summarize_docx
from app.billing.quota import add_token_usage
from app.billing.tier_manager import TierManager
sess = _index_sessions.get(frame["sessionId"])
if not sess or sess["cancelled"]:
return
cap = TierManager().get_feature_value(ws_ctx.user.tier, "folder_monthly_tokens")
for f in frame["files"]:
try:
if f["kind"] == "image":
res = await summarize_image(image_b64=f["content"], mime=f.get("mime", "image/png"))
elif f["kind"] == "pdf":
res = await summarize_pdf(pdf_b64=f["content"], name=f["relPath"])
elif f["kind"] == "docx":
res = await summarize_docx(docx_b64=f["content"], name=f["relPath"])
else:
res = await summarize_text(content=f["content"], ext=f.get("ext", ""), name=f["relPath"])
usage = await add_token_usage(
user_id=sess["user_id"],
feature="folder_index",
tokens=res.tokens_used,
db=db_session,
cap=cap,
)
except Exception as exc:
await ws.send_json({
"type": "index_file_result",
"sessionId": frame["sessionId"],
"relPath": f["relPath"],
"summary": None,
"tokensUsed": 0,
"error": str(exc),
})
continue
await ws.send_json({
"type": "index_file_result",
"sessionId": frame["sessionId"],
"relPath": f["relPath"],
"summary": res.summary,
"tokensUsed": res.tokens_used,
})
sess["processed"] += 1
if usage.exhausted:
await ws.send_json({
"type": "index_session_done",
"sessionId": frame["sessionId"],
"status": "quota_exceeded",
})
_index_sessions.pop(frame["sessionId"], None)
return
await ws.send_json({
"type": "index_session_progress",
"sessionId": frame["sessionId"],
"processed": sess["processed"],
"total": sess["total"],
})
if sess["processed"] >= sess["total"]:
await ws.send_json({
"type": "index_session_done",
"sessionId": frame["sessionId"],
"status": "completed",
})
_index_sessions.pop(frame["sessionId"], None)
```
If `ws_ctx`, `db_session`, or the dispatch structure use different identifiers in the existing file, mirror those — read 30 lines around the dispatch site before pasting.
- [ ] **Step 5: Run tests, verify PASS**
Run: `cd api && pytest tests/test_ws_index_session.py -v`
Expected: 3 passed
- [ ] **Step 6: Commit**
```bash
git add api/app/api/routes/device_ws.py api/tests/test_ws_index_session.py api/tests/conftest.py
git commit -m "feat(api): WS index_session frames + handlers"
```
---
## Phase C — Backend Agent Wiring
### Task 10: `folder_agent.py` — scoped read tool (TDD)
**Files:**
- Create: `api/app/agents/folder_agent.py`
- Test: `api/tests/test_folder_agent_tool.py`
- [ ] **Step 1: Write failing test**
```python
# api/tests/test_folder_agent_tool.py
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from app.agents.folder_agent import read_project_folder_file
pytestmark = pytest.mark.asyncio
async def test_happy_path():
with patch(
"app.agents.folder_agent.execute_on_client",
new=AsyncMock(return_value={"content": "file body"}),
):
out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "docs/x.md"})
assert out == "file body"
async def test_traversal_rejected():
out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "../../etc/passwd"})
assert out == "Access denied"
async def test_absolute_path_rejected():
out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "C:\\Windows\\foo"})
assert out == "Access denied"
async def test_missing_file():
with patch(
"app.agents.folder_agent.execute_on_client",
new=AsyncMock(return_value={"content": ""}),
):
out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "ghost.md"})
assert "not found" in out.lower()
```
- [ ] **Step 2: Run — ImportError**
Run: `cd api && pytest tests/test_folder_agent_tool.py -v`
Expected: ImportError
- [ ] **Step 3: Implement `app/agents/folder_agent.py`**
```python
"""Scoped file-read tool for the project folder feature."""
from __future__ import annotations
from langchain_core.tools import tool
from app.core.ws_context import execute_on_client
def _is_unsafe_path(rel: str) -> bool:
if not rel:
return True
norm = rel.replace("\\", "/")
if norm.startswith("/"):
return True
# Windows drive letter
if len(rel) >= 2 and rel[1] == ":":
return True
parts = norm.split("/")
return ".." in parts
@tool
async def read_project_folder_file(project_id: str, relative_path: str) -> str:
"""Read full content of a file inside the project's linked folder."""
if _is_unsafe_path(relative_path):
return "Access denied"
result = await execute_on_client(
action="read_project_folder_file",
data={"projectId": project_id, "relativePath": relative_path},
)
content = result.get("content", "")
if not content:
return f"File not found: {relative_path}"
return content
FOLDER_TOOLS = [read_project_folder_file]
```
- [ ] **Step 4: Run tests, verify PASS**
Run: `cd api && pytest tests/test_folder_agent_tool.py -v`
Expected: 4 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/agents/folder_agent.py api/tests/test_folder_agent_tool.py
git commit -m "feat(api): scoped read_project_folder_file tool with traversal guard"
```
---
### Task 11: Manifest injection — formatter + truncation (TDD)
**Files:**
- Modify: `api/app/core/deep_agent.py`
- Test: `api/tests/test_manifest_injection.py`
- [ ] **Step 1: Write failing test**
```python
# api/tests/test_manifest_injection.py
from __future__ import annotations
from app.core.deep_agent import format_folder_manifest, MANIFEST_TOKEN_BUDGET
def test_format_folder_manifest_basic():
manifest = {
"folderPath": "D:\\Acme",
"lastScannedAt": "2h ago",
"files": [
{"relPath": "briefs/kickoff.md", "kind": "text", "summary": "Kickoff notes; scope and deadlines."},
{"relPath": "logos/logo-v3.png", "kind": "image", "summary": "Final logo on white."},
],
}
out = format_folder_manifest(manifest)
assert "<linked_folder>" in out
assert "/briefs/kickoff.md" in out or "briefs/kickoff.md" in out
assert "[text]" in out
assert "[image]" in out
def test_format_folder_manifest_truncates_past_budget():
files = [
{"relPath": f"f{i}.md", "kind": "text", "summary": "x" * 100, "mtimeMs": i}
for i in range(2000)
]
out = format_folder_manifest({"folderPath": "p", "lastScannedAt": "now", "files": files})
assert "more files omitted" in out
# Rough token check
assert len(out) // 4 < MANIFEST_TOKEN_BUDGET + 200
def test_format_folder_manifest_null_returns_empty():
assert format_folder_manifest(None) == ""
assert format_folder_manifest({"files": []}) == ""
```
- [ ] **Step 2: Run — expect failure**
Run: `cd api && pytest tests/test_manifest_injection.py -v`
Expected: ImportError
- [ ] **Step 3: Add formatter to `deep_agent.py`**
Add near the top-level helpers (e.g. after the existing language-instruction helper):
```python
MANIFEST_TOKEN_BUDGET = 3000 # rough budget for <linked_folder> block
def format_folder_manifest(manifest: dict | None) -> str:
"""Format a folder manifest into the <linked_folder> block.
Truncates by mtime DESC if estimated tokens exceed MANIFEST_TOKEN_BUDGET.
Returns empty string if manifest is None or has no files.
"""
if not manifest or not manifest.get("files"):
return ""
files = list(manifest["files"])
files.sort(key=lambda f: f.get("mtimeMs", 0), reverse=True)
header = (
f"<linked_folder>\npath: {manifest.get('folderPath', '?')} "
f"({len(files)} files, scanned {manifest.get('lastScannedAt', '?')})\nfiles:\n"
)
footer_template = "… {} more files omitted, use read_project_folder_file to access by path\n</linked_folder>"
char_budget = MANIFEST_TOKEN_BUDGET * 4 # ~4 chars/token
body = ""
included = 0
for f in files:
line = f"- /{f['relPath']} [{f.get('kind','text')}] {f.get('summary','')}\n"
if len(header) + len(body) + len(line) + len(footer_template.format(0)) > char_budget:
break
body += line
included += 1
omitted = len(files) - included
if omitted > 0:
return header + body + footer_template.format(omitted)
return header + body + "</linked_folder>"
```
- [ ] **Step 4: Run tests, verify PASS**
Run: `cd api && pytest tests/test_manifest_injection.py -v`
Expected: 3 passed
- [ ] **Step 5: Commit**
```bash
git add api/app/core/deep_agent.py api/tests/test_manifest_injection.py
git commit -m "feat(api): manifest formatter with token-budget truncation"
```
---
### Task 12: Wire manifest into `run_task_brief_research_stream`
**Files:**
- Modify: `api/app/core/deep_agent.py`
- [ ] **Step 1: Read existing function signature**
Run: `grep -n "async def run_task_brief_research_stream" api/app/core/deep_agent.py`
Read 60 lines starting at that line. Identify (a) where the system prompt is assembled, (b) how `task` / `projectId` are available, (c) where tools are bound.
- [ ] **Step 2: Add helper that fetches manifest**
Add to `deep_agent.py`:
```python
async def _fetch_project_manifest(project_id: str) -> dict | None:
"""Fetch manifest from Electron via execute_on_client. Returns None if unlinked or error."""
from app.core.ws_context import execute_on_client
try:
result = await execute_on_client(
action="read_project_folder_manifest",
data={"projectId": project_id},
)
if not result or not result.get("folderPath"):
return None
return result
except Exception:
return None
```
- [ ] **Step 3: Inject manifest in task-brief**
Inside `run_task_brief_research_stream`, after `projectId` is known and before the system prompt is finalised, add:
```python
manifest_block = ""
if project_id:
manifest = await _fetch_project_manifest(project_id)
manifest_block = format_folder_manifest(manifest)
system_prompt = system_prompt + ("\n\n" + manifest_block if manifest_block else "")
```
Also append the new tool to the agent's tool list:
```python
from app.agents.folder_agent import FOLDER_TOOLS
tools = [...existing..., *FOLDER_TOOLS]
```
(Use exact variable names found in the function — do not guess.)
- [ ] **Step 4: Smoke-test by running existing brief tests**
Run: `cd api && pytest tests/test_brief_agent.py tests/test_deep_agent.py -v --tb=short`
Expected: PASS (manifest is `None` in tests since no WS, no behaviour change for existing flows).
- [ ] **Step 5: Commit**
```bash
git add api/app/core/deep_agent.py
git commit -m "feat(api): inject folder manifest into task brief agent"
```
---
### Task 13: Wire manifest into `run_home`
**Files:**
- Modify: `api/app/core/deep_agent.py`
- [ ] **Step 1: Locate `run_home`**
Run: `grep -n "async def run_home" api/app/core/deep_agent.py`
Identify how the caller can pass an `active_project_id` (search for existing `@project` mention parsing or `projectId` in the request payload — look at `device_ws.py` to see what fields the home frame contains).
- [ ] **Step 2: Inject manifest when project context is set**
Same pattern as Task 12 — inside `run_home`, if `project_id` (or `active_project_id`) is set, fetch + inject the manifest. Bind `FOLDER_TOOLS`.
- [ ] **Step 3: Run home-agent tests**
Run: `cd api && pytest tests/test_deep_agent.py -v --tb=short`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add api/app/core/deep_agent.py
git commit -m "feat(api): inject folder manifest into home agent when project context active"
```
---
### Task 14: Brief agent — multi-project manifest (TDD)
**Files:**
- Modify: `api/app/core/deep_agent.py`
- Modify: `api/tests/test_manifest_injection.py`
- [ ] **Step 1: Write failing test**
Append to `test_manifest_injection.py`:
```python
from unittest.mock import AsyncMock, patch
import pytest
from app.core.deep_agent import build_brief_multi_project_manifest
pytestmark = pytest.mark.asyncio
async def test_brief_multi_project_manifest_top_5_per_project():
fake_response = [
{
"projectId": "p1", "projectName": "Acme", "folderPath": "/a",
"lastScannedAt": "now",
"files": [
{"relPath": f"f{i}.md", "kind": "text", "summary": "s", "mtimeMs": i}
for i in range(10)
],
},
{
"projectId": "p2", "projectName": "Beta", "folderPath": "/b",
"lastScannedAt": "now",
"files": [{"relPath": "x.md", "kind": "text", "summary": "s", "mtimeMs": 1}],
},
]
with patch(
"app.core.deep_agent.execute_on_client",
new=AsyncMock(return_value={"projects": fake_response}),
):
out = await build_brief_multi_project_manifest()
# Project 1 has 10 files, only top 5 by mtimeMs should appear
assert out.count("[p1]") <= 5
# Project 2 has 1 file, must appear
assert "[p2]" in out or "Beta" in out
```
- [ ] **Step 2: Run — ImportError**
Run: `cd api && pytest tests/test_manifest_injection.py::test_brief_multi_project_manifest_top_5_per_project -v`
Expected: ImportError
- [ ] **Step 3: Implement builder**
Add to `deep_agent.py`:
```python
async def build_brief_multi_project_manifest() -> str:
"""Build a compact multi-project manifest for the daily brief agent.
Calls execute_on_client('list_projects_with_folder_manifests') and keeps
the top 5 most-recently-modified files per project.
"""
from app.core.ws_context import execute_on_client
try:
result = await execute_on_client(
action="list_projects_with_folder_manifests",
data={},
)
except Exception:
return ""
projects = (result or {}).get("projects") or []
if not projects:
return ""
blocks: list[str] = ["<linked_folders>"]
for p in projects:
files = sorted(p.get("files", []), key=lambda f: f.get("mtimeMs", 0), reverse=True)[:5]
if not files:
continue
blocks.append(f"project: {p.get('projectName','?')} [{p.get('projectId','?')}]")
blocks.append(f" path: {p.get('folderPath','?')} (scanned {p.get('lastScannedAt','?')})")
for f in files:
blocks.append(f" - /{f['relPath']} [{f.get('kind','text')}] {f.get('summary','')}")
blocks.append("</linked_folders>")
return "\n".join(blocks)
```
- [ ] **Step 4: Wire into `run_brief`**
Find `run_brief` in `deep_agent.py`. Before the system prompt is finalised:
```python
brief_manifest = await build_brief_multi_project_manifest()
system_prompt = system_prompt + ("\n\n" + brief_manifest if brief_manifest else "")
from app.agents.folder_agent import FOLDER_TOOLS
tools = [...existing..., *FOLDER_TOOLS]
```
- [ ] **Step 5: Run tests, verify PASS**
Run: `cd api && pytest tests/test_manifest_injection.py tests/test_brief_agent.py -v --tb=short`
Expected: all pass
- [ ] **Step 6: Commit**
```bash
git add api/app/core/deep_agent.py api/tests/test_manifest_injection.py
git commit -m "feat(api): multi-project folder manifest for daily brief"
```
---
## Phase D — Electron Schema, Scanner & tRPC
### Task 15: Drizzle schema additions
**Files:**
- Modify: `adiuvAI/src/main/db/schema.ts`
- [ ] **Step 1: Extend `projects`**
In `adiuvAI/src/main/db/schema.ts:12-19`, add columns:
```typescript
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
clientId: text('client_id'),
name: text('name').notNull(),
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
aiSummary: text('ai_summary'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
folderPath: text('folder_path'),
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
folderLastScanStatus: text('folder_last_scan_status', {
enum: ['idle', 'scanning', 'error'],
}).default('idle'),
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
});
```
- [ ] **Step 2: Add `projectFolderFiles` table**
After the `notes` table definition:
```typescript
export const projectFolderFiles = sqliteTable('project_folder_files', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
relativePath: text('relative_path').notNull(),
ext: text('ext').notNull(),
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
summary: text('summary'),
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
});
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
```
- [ ] **Step 3: Generate migration**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npx drizzle-kit generate`
Expected: new file under `src/main/db/migrations/` named `0008_*.sql`.
- [ ] **Step 4: Inspect generated migration**
Open the generated `0008_*.sql` and verify it contains:
- `ALTER TABLE projects ADD COLUMN folder_path TEXT;`
- `ALTER TABLE projects ADD COLUMN folder_last_scanned_at INTEGER;`
- `ALTER TABLE projects ADD COLUMN folder_last_scan_status TEXT DEFAULT 'idle';`
- `ALTER TABLE projects ADD COLUMN folder_total_files INTEGER DEFAULT 0 NOT NULL;`
- `CREATE TABLE project_folder_files (...);`
If anything is wrong, edit the SQL by hand and re-save. Also verify the journal in `migrations/meta/_journal.json` includes the new entry.
- [ ] **Step 5: Apply migration**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npx drizzle-kit push`
Expected: `Changes applied`.
- [ ] **Step 6: Smoke check**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm start`
- App boots.
- Open DevTools → run `await window.electronTRPC.sendMessage(...)` or just verify no migration error in the terminal.
- Quit.
- [ ] **Step 7: Commit**
```bash
git add adiuvAI/src/main/db/schema.ts adiuvAI/src/main/db/migrations/
git commit -m "feat(adiuvAI): schema for project folder integration"
```
---
### Task 16: `files/constants.ts`
**Files:**
- Create: `adiuvAI/src/main/files/constants.ts`
- [ ] **Step 1: Write the file**
```typescript
/** File-type whitelists & size caps for project folder indexing. */
export const TEXT_EXTS = new Set([
'.md', '.txt', '.rst', '.adoc',
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
'.html', '.htm', '.xml',
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
'.css', '.scss', '.sass',
'.sql',
]);
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
export const PDF_EXTS = new Set(['.pdf']);
export const DOCX_EXTS = new Set(['.docx']);
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
export const INDEX_BATCH_SIZE = 5;
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/main/files/constants.ts
git commit -m "feat(adiuvAI): folder index constants"
```
---
### Task 17: `files/scanner.ts` — walk + filter + delta
**Files:**
- Create: `adiuvAI/src/main/files/scanner.ts`
- [ ] **Step 1: Write the file**
```typescript
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../db';
import { projectFolderFiles } from '../db/schema';
import { eq } from 'drizzle-orm';
import {
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
} from './constants';
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
export interface ScannedFile {
relativePath: string;
ext: string;
kind: FileKind;
sizeBytes: number;
mtimeMs: number;
}
export interface ScanDelta {
newFiles: ScannedFile[];
changedFiles: ScannedFile[];
unchangedCount: number;
deletedRelPaths: string[];
}
function classify(ext: string, sizeBytes: number): FileKind | null {
const e = ext.toLowerCase();
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
return null; // not indexable
}
async function walk(root: string): Promise<ScannedFile[]> {
const out: ScannedFile[] = [];
async function recurse(dir: string) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // permission denied — skip silently
}
for (const e of entries) {
if (e.name.startsWith('.')) continue; // skip dot dirs / files
if (e.name === 'node_modules') continue; // common noise
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await recurse(full);
} else if (e.isFile()) {
let s;
try { s = await stat(full); } catch { continue; }
const ext = path.extname(e.name);
const kind = classify(ext, s.size);
if (kind === null) continue;
out.push({
relativePath: path.relative(root, full),
ext,
kind,
sizeBytes: s.size,
mtimeMs: Math.floor(s.mtimeMs),
});
}
}
}
await recurse(root);
return out;
}
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
const scanned = await walk(folderPath);
const existing = getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
const newFiles: ScannedFile[] = [];
const changedFiles: ScannedFile[] = [];
let unchanged = 0;
for (const f of scanned) {
const prev = existingMap.get(f.relativePath);
if (!prev) newFiles.push(f);
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
else unchanged++;
existingMap.delete(f.relativePath);
}
const deletedRelPaths = Array.from(existingMap.keys());
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS (no lint errors in `scanner.ts`).
- [ ] **Step 3: Commit**
```bash
git add adiuvAI/src/main/files/scanner.ts
git commit -m "feat(adiuvAI): folder scanner with mtime delta"
```
---
### Task 18: `backend-client.ts` — index frame senders
**Files:**
- Modify: `adiuvAI/src/main/api/backend-client.ts`
- [ ] **Step 1: Read existing client**
Run: `grep -n "sendHomeRequest\|sendFloatingRequest\|send\|onFrame\|class.*Client" adiuvAI/src/main/api/backend-client.ts | head -20`
Note the WS send/receive pattern (likely a method `sendFrame(payload)` and an event-listener dispatch keyed off `frame.type`).
- [ ] **Step 2: Add senders & receivers**
In the `BackendClient` class, add public methods:
```typescript
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
this.sendFrame({ type: 'index_session_start', sessionId, projectId, totalFiles });
}
sendIndexFileBatch(sessionId: string, files: Array<{
relPath: string; kind: string; content: string; ext?: string; mime?: string;
sizeBytes: number; mtimeMs: number;
}>): void {
this.sendFrame({ type: 'index_file_batch', sessionId, files });
}
sendIndexSessionCancel(sessionId: string): void {
this.sendFrame({ type: 'index_session_cancel', sessionId });
}
```
Then extend the inbound dispatch (the place where it switches on `frame.type`) to forward `index_file_result`, `index_session_progress`, and `index_session_done` to listeners.
```typescript
case 'index_file_result':
case 'index_session_progress':
case 'index_session_done':
this.emit('index', frame);
break;
```
(Use the existing event-emitter idiom — if `this.emit` doesn't exist, follow the existing pattern for dispatching v3 stream frames.)
- [ ] **Step 3: Lint**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add adiuvAI/src/main/api/backend-client.ts
git commit -m "feat(adiuvAI): WS index session frame senders + dispatcher"
```
---
### Task 19: `files/indexer.ts` — session orchestration
**Files:**
- Create: `adiuvAI/src/main/files/indexer.ts`
- [ ] **Step 1: Write the file**
```typescript
/**
* Folder index session orchestrator.
*
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
* returned summaries to projectFolderFiles, drives progress callbacks.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { eq, and } from 'drizzle-orm';
import { scanFolder, type ScannedFile } from './scanner';
import { INDEX_BATCH_SIZE } from './constants';
import { getBackendClient } from '../api/backend-client';
export interface IndexProgress {
sessionId: string;
processed: number;
total: number;
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
error?: string;
}
export type ProgressListener = (p: IndexProgress) => void;
async function readForIndex(folderPath: string, f: ScannedFile): Promise<{ content: string; mime?: string }> {
const abs = path.join(folderPath, f.relativePath);
if (f.kind === 'image') {
const buf = await readFile(abs);
const ext = f.ext.toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
return { content: buf.toString('base64'), mime };
}
if (f.kind === 'text') {
return { content: await readFile(abs, 'utf-8') };
}
// pdf / docx: read as binary, base64. Server is responsible for extraction.
const buf = await readFile(abs);
return { content: buf.toString('base64') };
}
export async function startIndexSession(
projectId: string,
onProgress: ProgressListener,
): Promise<{ sessionId: string; cancel: () => void }> {
const sessionId = randomUUID();
const db = getDb();
// Read folderPath from DB
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj || !proj.folderPath) {
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
return { sessionId, cancel: () => {} };
}
db.update(projects).set({ folderLastScanStatus: 'scanning' }).where(eq(projects.id, projectId)).run();
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
const delta = await scanFolder(projectId, proj.folderPath);
const toIndex = [...delta.newFiles, ...delta.changedFiles];
const total = toIndex.length;
// Delete rows for deleted files
for (const rel of delta.deletedRelPaths) {
db.delete(projectFolderFiles).where(
and(eq(projectFolderFiles.projectId, projectId), eq(projectFolderFiles.relativePath, rel))
).run();
}
if (total === 0) {
db.update(projects).set({
folderLastScanStatus: 'idle',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount,
}).where(eq(projects.id, projectId)).run();
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
return { sessionId, cancel: () => {} };
}
const backend = getBackendClient();
// Listener
let processed = 0;
let cancelled = false;
const finalize = (status: IndexProgress['status'], error?: string) => {
db.update(projects).set({
folderLastScanStatus: status === 'completed' ? 'idle' : status === 'cancelled' ? 'idle' : 'error',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount + processed,
}).where(eq(projects.id, projectId)).run();
onProgress({ sessionId, processed, total, status, error });
};
const handler = (frame: any) => {
if (frame.sessionId !== sessionId) return;
if (frame.type === 'index_file_result') {
const f = toIndex.find(x => x.relativePath === frame.relPath);
if (f && !frame.error) {
const now = Date.now();
db.insert(projectFolderFiles).values({
id: randomUUID(),
projectId,
relativePath: f.relativePath,
ext: f.ext,
kind: f.kind,
sizeBytes: f.sizeBytes,
mtimeMs: f.mtimeMs,
summary: frame.summary ?? null,
summaryUpdatedAt: now,
}).onConflictDoUpdate({
target: [projectFolderFiles.projectId, projectFolderFiles.relativePath],
set: {
mtimeMs: f.mtimeMs,
sizeBytes: f.sizeBytes,
kind: f.kind,
summary: frame.summary ?? null,
summaryUpdatedAt: now,
},
}).run();
}
} else if (frame.type === 'index_session_progress') {
processed = frame.processed;
onProgress({ sessionId, processed, total, status: 'scanning' });
} else if (frame.type === 'index_session_done') {
backend.off('index', handler);
finalize(frame.status === 'completed' ? 'completed'
: frame.status === 'cancelled' ? 'cancelled'
: frame.status === 'quota_exceeded' ? 'quota_exceeded'
: 'error');
}
};
backend.on('index', handler);
backend.sendIndexSessionStart(sessionId, projectId, total);
// Send batches
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
if (cancelled) break;
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
const payload = await Promise.all(batch.map(async f => {
const { content, mime } = await readForIndex(proj.folderPath!, f);
return {
relPath: f.relativePath, kind: f.kind, content,
ext: f.ext, mime,
sizeBytes: f.sizeBytes, mtimeMs: f.mtimeMs,
};
}));
backend.sendIndexFileBatch(sessionId, payload);
}
const cancel = () => {
cancelled = true;
backend.sendIndexSessionCancel(sessionId);
};
return { sessionId, cancel };
}
```
- [ ] **Step 2: Lint**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add adiuvAI/src/main/files/indexer.ts
git commit -m "feat(adiuvAI): WS index session orchestrator"
```
---
### Task 20: `router/projectFolders.ts` + register
**Files:**
- Create: `adiuvAI/src/main/router/projectFolders.ts`
- Modify: `adiuvAI/src/main/router/index.ts`
- [ ] **Step 1: Create router**
```typescript
// adiuvAI/src/main/router/projectFolders.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { dialog } from 'electron';
import { eq } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { startIndexSession, type IndexProgress } from '../files/indexer';
import { getBackendClient } from '../api/backend-client';
import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
// In-memory map of active sessions per projectId so we can cancel
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
export const projectFoldersRouter = router({
chooseFolder: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
}),
link: publicProcedure
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
db.update(projects)
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
.where(eq(projects.id, input.projectId)).run();
return { ok: true };
}),
unlink: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
db.update(projects).set({
folderPath: null, folderLastScannedAt: null,
folderLastScanStatus: 'idle', folderTotalFiles: 0,
}).where(eq(projects.id, input.projectId)).run();
return { ok: true };
}),
startScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
if (!proj?.folderPath) throw new Error('No folder linked');
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
// Pre-flight: count indexable files (cheap walk) then call quota/check
// For simplicity here, send 0 (backend ignores when -1) and rely on
// mid-stream quota_exceeded. (Full pre-flight added in Task 24.)
const session = await startIndexSession(input.projectId, (p) => {
const entry = _active.get(input.projectId);
if (entry) entry.lastProgress = p;
if (p.status === 'completed' || p.status === 'cancelled' ||
p.status === 'quota_exceeded' || p.status === 'error') {
_active.delete(input.projectId);
}
});
_active.set(input.projectId, {
cancel: session.cancel,
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
});
return { sessionId: session.sessionId };
}),
cancelScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(({ input }) => {
const entry = _active.get(input.projectId);
if (entry) entry.cancel();
return { ok: true };
}),
getStatus: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
const entry = _active.get(input.projectId);
return entry?.lastProgress ?? null;
}),
listFiles: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
return getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, input.projectId))
.orderBy(projectFolderFiles.relativePath)
.all();
}),
});
```
- [ ] **Step 2: Register in `router/index.ts`**
Edit `adiuvAI/src/main/router/index.ts` — add import and merge into `appRouter`:
```typescript
import { projectFoldersRouter } from './projectFolders';
export const appRouter = router({
// existing routers...
projectFolders: projectFoldersRouter,
});
```
- [ ] **Step 3: Lint + type check by starting dev**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add adiuvAI/src/main/router/projectFolders.ts adiuvAI/src/main/router/index.ts
git commit -m "feat(adiuvAI): projectFolders tRPC router (link, unlink, scan, list)"
```
---
### Task 21: drizzle-executor actions
**Files:**
- Modify: `adiuvAI/src/main/api/drizzle-executor.ts`
- [ ] **Step 1: Add three new action handlers**
Locate the `switch (call.action)` (or equivalent dispatch) in `drizzle-executor.ts`. Add three new cases:
```typescript
case 'read_project_folder_manifest': {
const { projectId } = call.data as { projectId: string };
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
// On-demand mtime check: if any tracked file's mtime is stale, fire-and-forget rescan.
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
// for the next call.
if (proj.folderLastScanStatus !== 'scanning') {
void import('../files/scanner').then(async ({ scanFolder }) => {
const delta = await scanFolder(projectId, proj.folderPath!);
if (delta.newFiles.length > 0 || delta.changedFiles.length > 0 || delta.deletedRelPaths.length > 0) {
const { startIndexSession } = await import('../files/indexer');
void startIndexSession(projectId, () => {});
}
}).catch(() => {});
}
return {
folderPath: proj.folderPath,
lastScannedAt: proj.folderLastScannedAt,
files,
};
}
case 'read_project_folder_file': {
const { projectId, relativePath } = call.data as { projectId: string; relativePath: string };
// Re-check guards even though backend tool also guards
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
throw new ExecutorError('Access denied');
}
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { content: '' };
const abs = path.join(proj.folderPath, relativePath);
// Confine to folderPath
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
throw new ExecutorError('Access denied');
}
try {
const stat = await import('node:fs/promises').then(m => m.stat(abs));
if (stat.size > MAX_READ_SIZE_BYTES) {
return { content: (await fs.promises.readFile(abs, 'utf-8')).slice(0, MAX_READ_SIZE_BYTES) };
}
// Image → base64; else utf-8
const ext = path.extname(relativePath).toLowerCase();
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
return { content: (await fs.promises.readFile(abs)).toString('base64') };
}
return { content: await fs.promises.readFile(abs, 'utf-8') };
} catch {
return { content: '' };
}
}
case 'list_projects_with_folder_manifests': {
const projs = getDb()
.select()
.from(projects)
.where(/* folderPath not null — drizzle: */ sql`${projects.folderPath} IS NOT NULL`)
.all();
const out = [] as Array<unknown>;
for (const p of projs) {
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, p.id))
.all();
out.push({
projectId: p.id,
projectName: p.name,
folderPath: p.folderPath,
lastScannedAt: p.folderLastScannedAt,
files,
});
}
return { projects: out };
}
```
Also add to `TABLE_REGISTRY` if the existing dispatch routes table-by-name operations:
```typescript
const TABLE_REGISTRY = {
// existing...
projectFolderFiles,
};
```
And import `projectFolderFiles` and `sql` at top of file.
- [ ] **Step 2: Lint**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add adiuvAI/src/main/api/drizzle-executor.ts
git commit -m "feat(adiuvAI): drizzle-executor folder manifest + scoped read actions"
```
---
### Task 22: `files/daily-rescan.ts` + wire on app `ready`
**Files:**
- Create: `adiuvAI/src/main/files/daily-rescan.ts`
- Modify: `adiuvAI/src/main/index.ts`
- [ ] **Step 1: Create the rescan module**
```typescript
// adiuvAI/src/main/files/daily-rescan.ts
import { getDb } from '../db';
import { projects } from '../db/schema';
import { sql, and, isNotNull, lt } from 'drizzle-orm';
import { startIndexSession } from './indexer';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export async function runDailyRescan(): Promise<void> {
const cutoff = Date.now() - ONE_DAY_MS;
const stale = getDb()
.select()
.from(projects)
.where(and(
isNotNull(projects.folderPath),
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
))
.all();
for (const p of stale) {
if (p.folderLastScanStatus === 'scanning') continue;
// Fire-and-forget; no UI listener.
void startIndexSession(p.id, () => {});
}
}
```
- [ ] **Step 2: Wire in `index.ts`**
In `adiuvAI/src/main/index.ts`, find the `app.whenReady().then(...)` block. Append:
```typescript
import { runDailyRescan } from './files/daily-rescan';
// ...inside whenReady, after window creation and auth init:
setTimeout(() => { void runDailyRescan(); }, 10_000); // delay so WS is up
```
- [ ] **Step 3: Lint**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add adiuvAI/src/main/files/daily-rescan.ts adiuvAI/src/main/index.ts
git commit -m "feat(adiuvAI): daily auto-rescan of stale folder links"
```
---
## Phase E — Renderer UI
### Task 23: i18n keys
**Files:**
- Modify: `adiuvAI/src/renderer/locales/en/translation.json`
- Modify: `adiuvAI/src/renderer/locales/it/translation.json`
- Modify: `adiuvAI/src/renderer/locales/es/translation.json`
- Modify: `adiuvAI/src/renderer/locales/fr/translation.json`
- Modify: `adiuvAI/src/renderer/locales/de/translation.json`
- [ ] **Step 1: Add the same keyset to all 5 locales**
For each `translation.json`, add under `projects`:
```json
"folder": {
"title": "Files",
"linkCta": "Link folder",
"browse": "Browse",
"rescan": "Rescan",
"unlink": "Unlink",
"filesCount_one": "{{count}} file",
"filesCount_other": "{{count}} files",
"lastScanned": "last scanned {{relative}}",
"scanning": "indexing {{processed}}/{{total}}",
"scanFailed": "Scan failed",
"empty": {
"title": "Link a project folder",
"description": "Connect a local folder so AI agents can read its files when answering questions about this project.",
"cta": "Choose folder…"
},
"errors": {
"tooBig": "Folder too big for {{tier}} plan — max {{count}} files",
"monthlyExhausted": "Monthly token budget exhausted (resets {{date}})",
"notFound": "Folder not found: {{path}}"
},
"webOnlyTooltip": "Folder linking available in desktop app"
},
```
Translate the values into each locale's language. Keys stay identical across files.
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/locales/
git commit -m "i18n: projects.folder keys in all 5 locales"
```
---
### Task 24: `FolderChip.tsx`
**Files:**
- Create: `adiuvAI/src/renderer/components/projects/folder/FolderChip.tsx`
- [ ] **Step 1: Write component**
```tsx
import { useTranslation } from 'react-i18next';
import { Folder, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
interface FolderChipProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
scanProgress?: { processed: number; total: number } | null;
onClick: () => void;
}
export function FolderChip({
folderPath, totalFiles, lastScannedAt, scanStatus, scanProgress, onClick,
}: FolderChipProps) {
const { t } = useTranslation();
if (!folderPath) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-3 w-3" />
{t('projects.folder.linkCta')}
</button>
);
}
if (scanStatus === 'scanning' && scanProgress) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
>
<Folder className="h-3 w-3 animate-pulse" />
{t('projects.folder.scanning', { processed: scanProgress.processed, total: scanProgress.total })}
</button>
);
}
if (scanStatus === 'error') {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
>
<Folder className="h-3 w-3" />
{t('projects.folder.scanFailed')}
</button>
);
}
const relative = lastScannedAt
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
: '—';
return (
<button
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
)}
>
<Folder className="h-3 w-3" />
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
<span className="opacity-60">·</span>
<span className="opacity-70">{relative}</span>
</button>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/folder/FolderChip.tsx
git commit -m "feat(adiuvAI): FolderChip component"
```
---
### Task 25: `FolderLinkCard.tsx`
**Files:**
- Create: `adiuvAI/src/renderer/components/projects/folder/FolderLinkCard.tsx`
- [ ] **Step 1: Write component**
```tsx
import { useTranslation } from 'react-i18next';
import { Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { formatDistanceToNow } from 'date-fns';
interface FolderLinkCardProps {
projectId: string;
folderPath: string;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
onUnlinkRequested: () => void;
}
export function FolderLinkCard({
projectId, folderPath, totalFiles, lastScannedAt, scanStatus, onUnlinkRequested,
}: FolderLinkCardProps) {
const { t } = useTranslation();
const notify = useNotify();
const utils = trpc.useUtils();
const startScan = trpc.projectFolders.startScan.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notify.error(err.message),
});
return (
<div className="border border-border rounded-xl p-4 flex items-start gap-3 bg-background">
<div className="h-10 w-10 rounded-lg bg-[#fbc881]/30 grid place-items-center shrink-0">
<Folder className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{t('projects.folder.title')}</div>
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('projects.folder.filesCount', { count: totalFiles })}
{lastScannedAt && (
<> · {t('projects.folder.lastScanned', { relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }) })}</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="outline" size="sm"
disabled={scanStatus === 'scanning' || startScan.isPending}
onClick={() => startScan.mutate({ projectId })}
>
{t('projects.folder.rescan')}
</Button>
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
{t('projects.folder.unlink')}
</Button>
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/folder/FolderLinkCard.tsx
git commit -m "feat(adiuvAI): FolderLinkCard component"
```
---
### Task 26: `FolderFileList.tsx`
**Files:**
- Create: `adiuvAI/src/renderer/components/projects/folder/FolderFileList.tsx`
- [ ] **Step 1: Write component**
```tsx
import { useState, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface FolderFileListProps {
projectId: string;
}
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
export function FolderFileList({ projectId }: FolderFileListProps) {
const [filter, setFilter] = useState<Filter>('all');
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
const items = useMemo(() => {
if (!data) return [];
if (filter === 'all') return data;
return data.filter(f => f.kind === filter);
}, [data, filter]);
if (isLoading) {
return <div className="space-y-2">{[0,1,2].map(i => <Skeleton key={i} className="h-12" />)}</div>;
}
return (
<div>
<div className="flex gap-2 mb-3 text-xs">
{FILTERS.map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-2.5 py-1 rounded-full border border-border',
filter === f ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'
)}
>
{f}
</button>
))}
</div>
<ul className="space-y-1.5">
{items.map(f => (
<li
key={f.id}
className={cn(
'rounded-md px-3 py-2 border border-border bg-background/50',
f.kind === 'skipped' && 'opacity-50',
)}
>
<div className="font-mono text-xs">{f.relativePath}</div>
{f.summary && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
)}
</li>
))}
</ul>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/folder/FolderFileList.tsx
git commit -m "feat(adiuvAI): FolderFileList with kind filter"
```
---
### Task 27: `FolderUnlinkDialog.tsx`
**Files:**
- Create: `adiuvAI/src/renderer/components/projects/folder/FolderUnlinkDialog.tsx`
- [ ] **Step 1: Write component**
```tsx
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
interface FolderUnlinkDialogProps {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FolderUnlinkDialog({ projectId, open, onOpenChange }: FolderUnlinkDialogProps) {
const { t } = useTranslation();
const notify = useNotify();
const utils = trpc.useUtils();
const unlink = trpc.projectFolders.unlink.useMutation({
onSuccess: () => {
utils.projects.get.invalidate({ id: projectId });
utils.projectFolders.listFiles.invalidate({ projectId });
onOpenChange(false);
},
onError: (err) => notify.error(err.message),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
<DialogDescription>
{t('common.deleteTitle')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button variant="destructive" onClick={() => unlink.mutate({ projectId })} disabled={unlink.isPending}>
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/folder/FolderUnlinkDialog.tsx
git commit -m "feat(adiuvAI): FolderUnlinkDialog"
```
---
### Task 28: `FilesSection.tsx`
**Files:**
- Create: `adiuvAI/src/renderer/components/projects/folder/FilesSection.tsx`
- [ ] **Step 1: Write component**
```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription,
} from '@/components/ui/empty';
import { useNotify } from '@/hooks/useNotify';
import { usePlatform } from '@/lib/platform';
import { FolderLinkCard } from './FolderLinkCard';
import { FolderFileList } from './FolderFileList';
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
interface FilesSectionProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
}
export function FilesSection({
projectId, folderPath, totalFiles, lastScannedAt, scanStatus,
}: FilesSectionProps) {
const { t } = useTranslation();
const notify = useNotify();
const platform = usePlatform();
const [unlinkOpen, setUnlinkOpen] = useState(false);
const utils = trpc.useUtils();
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
const link = trpc.projectFolders.link.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notify.error(err.message),
});
const handleChoose = async () => {
const folderPath = await chooseFolder.mutateAsync();
if (folderPath) {
await link.mutateAsync({ projectId, folderPath });
// Kick first scan
await utils.client.projectFolders.startScan.mutate({ projectId });
}
};
if (!platform.isElectron) {
return (
<div className="text-sm text-muted-foreground p-6 text-center">
{t('projects.folder.webOnlyTooltip')}
</div>
);
}
if (!folderPath) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia><Sparkles /></EmptyMedia>
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
</EmptyHeader>
<Button onClick={handleChoose} disabled={chooseFolder.isPending || link.isPending}>
{t('projects.folder.empty.cta')}
</Button>
</Empty>
);
}
return (
<div className="space-y-4">
<FolderLinkCard
projectId={projectId}
folderPath={folderPath}
totalFiles={totalFiles}
lastScannedAt={lastScannedAt}
scanStatus={scanStatus}
onUnlinkRequested={() => setUnlinkOpen(true)}
/>
<FolderFileList projectId={projectId} />
<FolderUnlinkDialog projectId={projectId} open={unlinkOpen} onOpenChange={setUnlinkOpen} />
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/folder/FilesSection.tsx
git commit -m "feat(adiuvAI): FilesSection orchestrator"
```
---
### Task 29: Wire `'files'` section into ProjectTabBar
**Files:**
- Modify: `adiuvAI/src/renderer/components/projects/ProjectTabBar.tsx:6-7`
- [ ] **Step 1: Extend SECTIONS**
Change:
```typescript
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
```
to:
```typescript
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
```
- [ ] **Step 2: Add label**
Inside `TAB_LABELS` (around line 81):
```typescript
files: t('projects.folder.title'),
```
- [ ] **Step 3: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/ProjectTabBar.tsx
git commit -m "feat(adiuvAI): add 'files' tab to ProjectTabBar"
```
---
### Task 30: Wire chip + section into `ProjectDetail.tsx`
**Files:**
- Modify: `adiuvAI/src/renderer/components/projects/ProjectDetail.tsx`
- [ ] **Step 1: Import additions**
At the top of the file:
```typescript
import { FolderChip } from './folder/FolderChip';
import { FilesSection } from './folder/FilesSection';
```
- [ ] **Step 2: Add `filesRef` and register it in `sectionRefs`**
Inside `ProjectDetail`:
```typescript
const filesRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
overview: summaryRef,
timeline: timelineRef,
tasks: tasksRef,
notes: notesRef,
files: filesRef,
}), []);
```
- [ ] **Step 3: Subscribe to live scan status**
Inside `ProjectDetail`, add:
```typescript
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
{ projectId },
{ refetchInterval: (data) => data?.status === 'scanning' ? 1000 : false },
);
```
- [ ] **Step 4: Render the FolderChip in the hero region**
Inside the hero JSX (`<div ref={heroRef}>...`), near the title block, add a row that includes `<FolderChip>`:
```tsx
<FolderChip
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
scanProgress={scanStatus && scanStatus.status === 'scanning'
? { processed: scanStatus.processed, total: scanStatus.total }
: null}
onClick={() => {/* scroll to files; reuse pattern from other tabs */}}
/>
```
For the click handler, copy the pattern from existing `<ProjectTabBar>` `scrollToSection` call sites — typically by setting a query param or scrolling to `filesRef.current`.
- [ ] **Step 5: Render the section body**
Below the existing `<div ref={notesRef} data-section="notes">…</div>` block, add:
```tsx
<div ref={filesRef} data-section="files" className="mx-auto max-w-6xl px-8 py-10 border-t border-border/40">
<FilesSection
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
/>
</div>
```
- [ ] **Step 6: Manual smoke test**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm start`
- App boots.
- Open any project. Verify "Files" tab appears in tab bar; chip appears in hero (dashed CTA when unlinked).
- Click "Files" tab → empty state renders.
- Click "Choose folder…" → OS dialog opens. Pick a small folder with mixed file types.
- Folder is linked; chip switches to "indexing N/M"; progress polls every second; finishes; chip shows "N files · just now".
- Tab body shows FolderLinkCard + filterable file list.
- Click Rescan; status flips back and resolves.
- Click Unlink → dialog confirms; chip returns to "Link folder".
- [ ] **Step 7: Commit**
```bash
git add adiuvAI/src/renderer/components/projects/ProjectDetail.tsx
git commit -m "feat(adiuvAI): wire FolderChip + FilesSection into ProjectDetail"
```
---
### Task 31: Pre-flight quota check + error toasts
**Files:**
- Modify: `adiuvAI/src/main/router/projectFolders.ts`
- Modify: `adiuvAI/src/renderer/components/projects/folder/FilesSection.tsx`
- [ ] **Step 1: Add pre-flight in router**
In `startScan` mutation, before calling `startIndexSession`, count files via `scanFolder` and call backend `/api/v1/billing/quota/check`:
```typescript
import { scanFolder } from '../files/scanner';
import { getAuthManager } from '../auth/auth-manager';
// inside startScan:
const delta = await scanFolder(input.projectId, proj.folderPath);
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
const auth = getAuthManager();
const token = await auth.getAccessToken();
const apiBase = process.env.API_BASE_URL ?? 'http://localhost:8000'; // pull from existing config
const res = await fetch(`${apiBase}/api/v1/billing/quota/check`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ feature: 'folder_index', estimated_files: estimated }),
});
if (res.status === 402) {
const body = await res.json();
throw new Error(`QUOTA:${body.detail.reason}:${body.detail.message}`);
}
```
(Reuse whatever existing pattern the codebase already has for authenticated fetches from the main process — `BackendClient` may expose an HTTP helper; if so, use that instead of raw fetch.)
- [ ] **Step 2: Surface quota errors in `FilesSection.tsx`**
Wrap the `handleChoose` call:
```typescript
try {
await utils.client.projectFolders.startScan.mutate({ projectId });
} catch (err: any) {
const msg = err?.message ?? '';
if (msg.startsWith('QUOTA:max_files')) {
notify.error(t('projects.folder.errors.tooBig', { tier: 'free', count: 200 }));
} else if (msg.startsWith('QUOTA:monthly_tokens')) {
notify.error(t('projects.folder.errors.monthlyExhausted', { date: 'next month' }));
} else {
notify.error(msg);
}
}
```
- [ ] **Step 3: Lint + manual smoke**
Run: `cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint && npm start`
Smoke-test with a Free-tier account: link a folder with >200 files → toast "Folder too big for free plan — max 200 files".
- [ ] **Step 4: Commit**
```bash
git add adiuvAI/src/main/router/projectFolders.ts adiuvAI/src/renderer/components/projects/folder/FilesSection.tsx
git commit -m "feat(adiuvAI): pre-flight quota check + error toasts for folder integration"
```
---
## Phase F — End-to-End Verification
### Task 32: Manual smoke checklist
**Files:** none
- [ ] **Step 1: Walk the full happy path on a real folder**
With dev server running:
1. Create a new project. Open it.
2. Hero shows "📁 Link folder" chip. Click → folder dialog. Choose a folder with ~20 mixed files (some `.md`, `.png`, `.pdf`, one `node_modules` dir).
3. Verify dotfiles + `node_modules` are skipped (look at `projectFolderFiles` rows via DevTools tRPC query).
4. Chip shows "indexing N/M" with spinner. File list rows appear as summaries come in.
5. Final chip: "N files · just now".
6. Open the task brief for any task in this project — its system prompt now contains a `<linked_folder>` block with file summaries (verify in Langfuse trace).
7. Ask the agent "what is in the kickoff doc?" — agent should call `read_project_folder_file` and answer with content.
- [ ] **Step 2: Delta + rescan**
Modify one `.md` in the linked folder, add one `.png`, delete one file.
Click Rescan. Verify:
- Only the modified `.md` and the new `.png` show up in `index_file_result` frames.
- The deleted file's row is removed from `projectFolderFiles`.
- Chip ticks the new count.
- [ ] **Step 3: Cancel**
Link a folder with many files. Mid-scan, navigate away or click Unlink. Confirm:
- Scan stops.
- Status row in `projects` flips to `'idle'` (after cancel).
- No background errors logged.
- [ ] **Step 4: Quota**
Switch the test user to `free` tier in the DB. Try to link a folder with >200 files → toast appears, link rejected.
- [ ] **Step 5: Traversal guard**
In a manual agent run, prompt the agent to call `read_project_folder_file` with `relative_path="../../etc/passwd"`. Tool must return `"Access denied"`.
- [ ] **Step 6: Document any issues found**
If any step fails, file an issue with reproduction steps and add a follow-up task to the plan.
- [ ] **Step 7: Commit the verification log (optional)**
If you keep a verification log file, commit it now. Otherwise skip.
---
## Done Criteria
- All API tests pass (`pytest tests/test_folder_*.py tests/test_ws_index_session.py tests/test_manifest_injection.py`).
- Electron app boots without migration errors.
- Manual smoke checklist (Task 32) passes end to end.
- No lint regressions (`npm run lint`).
- No new console errors on app startup.
---
## Out of Scope
- Token-usage display UI (recorded backend-side; Settings page deferred).
- Multi-folder per project.
- Live file watcher (chokidar).
- Phase-2 wiki tier.
- Web SPA support (Files tab disabled message only).
- File editing from adiuvAI.