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

96 KiB

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

"""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
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):

    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):

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
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:

    "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):

    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
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

# 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
"""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
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:

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:

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
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

# 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)
"""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
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

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:

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
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

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:

"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:

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
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
# 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:

# 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:

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
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

# 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
"""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
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

# 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):

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
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:

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:

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:

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
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
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:

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:

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:

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
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:

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:

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

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

/** 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
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

/** 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
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:

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.

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
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

/**
 * 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
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

// 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:

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
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:

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:

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
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

// 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:

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
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:

"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
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

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
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

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
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

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
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

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
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

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
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:

export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;

to:

export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
  • Step 2: Add label

Inside TAB_LABELS (around line 81):

files: t('projects.folder.title'),
  • Step 3: Commit
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:

import { FolderChip } from './folder/FolderChip';
import { FilesSection } from './folder/FilesSection';
  • Step 2: Add filesRef and register it in sectionRefs

Inside ProjectDetail:

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:

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>:

<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:

<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

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:

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:

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
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.