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