From 361f89a29dcd574c181eed4b8ed1971acda4cedc Mon Sep 17 00:00:00 2001 From: Roberto Date: Mon, 11 May 2026 22:08:44 +0200 Subject: [PATCH] docs: add project-folder integration design spec Approved design for linking a local folder to a project: lightweight manifest with per-file LLM summaries, WS-streamed indexing pipeline, pre-injected manifest for Home/Brief/Task-Brief agents, tier-gated token+file-count quota recorded backend-side. --- ...05-11-project-folder-integration-design.md | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-11-project-folder-integration-design.md diff --git a/docs/superpowers/specs/2026-05-11-project-folder-integration-design.md b/docs/superpowers/specs/2026-05-11-project-folder-integration-design.md new file mode 100644 index 0000000..449da16 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-project-folder-integration-design.md @@ -0,0 +1,386 @@ +# Project Folder Integration — Design + +**Date:** 2026-05-11 +**Status:** Approved (brainstorming complete) +**Author:** Roberto + Claude + +## Goal + +Let users link a local (or shared-PC) folder to an adiuvAI project. Adiuvai scans the folder, generates per-file summaries via LLM, and exposes the resulting manifest to the Home, Brief, and Task-Brief agents so they can answer project questions with awareness of the user's local files. + +## Non-goals + +- Multi-folder linking per project (deferred — 1 folder per project for now). +- Full-text RAG over file contents (we use lightweight per-file summaries instead). +- File editing from inside adiuvAI (read-only). +- Token-usage display in the project UI (recorded backend-side; dedicated Settings page comes later). +- Web SPA support (Electron-only — web SPA has no filesystem access). + +## Strategy + +**Hybrid AI File System (manifest first, optional wiki tier later).** + +Phase 1 (this spec): build a lightweight manifest — for each indexable file record `(relativePath, kind, size, mtime, 1-line LLM summary)`. The agent receives the manifest pre-injected into its system prompt and reads full file contents lazily via a scoped tool. + +Phase 2 (future, out of scope): for folders above N files or on user opt-in, generate per-folder + per-file wiki summaries written to a structured index. Not built now. + +## Architecture + +``` +┌─────────────────────── adiuvAI (Electron) ───────────────────────┐ +│ Renderer (React) │ +│ • Project hero: (status glance) │ +│ • : link/unlink, browse, rescan, progress, browser │ +│ • : tree view of manifest │ +│ │ +│ Main (Node) │ +│ • db/schema.ts: +projectFolderFiles, +projects.folderPath │ +│ • files/scanner.ts: walk + filter + mtime delta │ +│ • files/indexer.ts: orchestrates WS index session │ +│ • files/daily-rescan.ts: 24h-stale check on app start │ +│ • router/projectFolders.ts: tRPC procedures │ +│ • api/backend-client.ts: +sendIndexBatch frame │ +│ • api/drizzle-executor.ts: +read_project_folder_manifest, │ +│ +read_project_folder_file actions │ +└───────────────────────────────────────────────────────────────────┘ + │ /api/v1/device WS + ▼ +┌────────────────────────── api (FastAPI) ─────────────────────────┐ +│ device_ws.py: +index_file_batch / +index_file_result frames │ +│ core/folder_indexer.py: summarize text / vision per file │ +│ core/deep_agent.py: pre-inject manifest when project context set │ +│ agents/folder_agent.py: scoped read_project_folder_file tool │ +│ billing/tier_manager.py: +folder_max_files, +folder_monthly_tokens│ +│ models.py: +AgentRunLog.tokens_used, +MonthlyTokenUsage table │ +└───────────────────────────────────────────────────────────────────┘ +``` + +**Privacy invariant:** file content travels to the backend only transiently — for summarization — and is never persisted there. Summaries and manifest entries live in the local SQLite database. Token usage is recorded backend-side because it gates the user's tier quota. + +## Decisions + +| Topic | Decision | +|-------|----------| +| Retrieval strategy | Hybrid: manifest first, optional wiki tier later (phase 2, out of scope) | +| File scope | Text whitelist (.md, .txt, .pdf, .docx, .csv, code) + images (.png/.jpg) summarized via gpt-4o-mini vision | +| Cardinality | One folder per project | +| Rescan triggers | Manual button + daily auto (24h staleness check on app start) + on-demand mtime delta when manifest is read | +| Rate-limit metric | Tokens-per-month per user **and** total file-count cap per folder, both tier-gated | +| Indexing pipeline | WS streaming over existing `/api/v1/device` with new frame types | +| Agent access | Pre-inject manifest into system prompt; lazy reads via scoped `read_project_folder_file` tool | +| UI placement | Hero chip + dedicated "Files" tab in `ProjectTabBar` | +| Platform | Electron-only (web SPA: tab disabled) | +| Token-usage display | Out of scope (record backend-side, surface in Settings later) | + +## Schema Changes + +### adiuvAI local SQLite (`src/main/db/schema.ts`) + +Extend `projects`: + +```typescript +projects: { + // existing columns... + folderPath: text('folder_path'), // nullable absolute path or UNC + folderLastScannedAt: integer('folder_last_scanned_at'),// ms, nullable + folderLastScanStatus: text('folder_last_scan_status'), // 'idle' | 'scanning' | 'error' + folderTotalFiles: integer('folder_total_files').default(0), +} +``` + +New table `projectFolderFiles`: + +```typescript +projectFolderFiles: { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), // FK projects.id (no DB constraint per convention) + relativePath: text('relative_path').notNull(), // path relative to folderPath + ext: text('ext').notNull(), // '.md', '.png', ... + kind: text('kind').notNull(), // 'text' | 'image' | 'pdf' | 'docx' | 'skipped' | 'error' + sizeBytes: integer('size_bytes').notNull(), + mtimeMs: integer('mtime_ms').notNull(), + summary: text('summary'), // nullable, ≤500 chars + summaryUpdatedAt: integer('summary_updated_at'), + // Unique index: (projectId, relativePath) +} +``` + +### api Postgres (alembic migration) + +```python +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), ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('year_month', sa.String(7), nullable=False), # 'YYYY-MM' + sa.Column('feature', sa.String(64), nullable=False), # 'folder_index' + sa.Column('tokens_used', sa.Integer, nullable=False, server_default='0'), + sa.PrimaryKeyConstraint('user_id', 'year_month', 'feature'), +) +``` + +### Tier matrix (`app/billing/tier_manager.py`) + +| Feature | Free | Pro | Power | Team | +|-------------------------|------|-----|-------|------| +| `folder_max_files` | 200 | 5000| -1 | -1 | +| `folder_monthly_tokens` | 100k | 2M | -1 | -1 | + +## Indexing Pipeline + +### New WS frame types on `/api/v1/device` + +| Direction | Frame | Payload | +|-----------|---------------------------|---------| +| C → S | `index_session_start` | `{ sessionId, projectId, totalFiles }` | +| C → S | `index_file_batch` | `{ sessionId, files: [{relPath, kind, content/imageB64, sizeBytes, mtimeMs}] }` (batches of 5) | +| S → C | `index_file_result` | `{ sessionId, relPath, summary, tokensUsed, error? }` | +| S → C | `index_session_progress` | `{ sessionId, processed, total }` | +| C → S | `index_session_cancel` | `{ sessionId }` | +| S → C | `index_session_done` | `{ sessionId, status: 'completed' \| 'cancelled' \| 'quota_exceeded' \| 'error' }` | + +### Flow (Electron `files/indexer.ts`) + +1. tRPC `projectFolders.startScan({ projectId })`. +2. `scanner.ts` walks `folderPath`: + - Filter by whitelist (text exts + .png/.jpg). + - Apply size cap (1 MB / file). + - Compute mtime delta vs `projectFolderFiles`. + - Returns `{ newFiles[], changedFiles[], deletedFiles[] }`. +3. Backend pre-flight: `POST /api/v1/billing/quota/check { feature: 'folder_index', estimated_files: N }`: + - Rejects 402 if `folder_max_files` exceeded for the user's tier. + - Rejects 402 if `folder_monthly_tokens` already exhausted. +4. Open `index_session_start` over WS. +5. For each batch of 5 files: + - Read content (text) or base64-encode (image). + - Send `index_file_batch`. + - Await `index_file_result × 5`. + - Upsert `projectFolderFiles` row with the returned summary. + - Backend atomically increments `MonthlyTokenUsage` and writes a row in `AgentRunLog` with `tokens_used`. +6. Send `index_session_done`. Update `projects.folderLastScannedAt`, `.folderTotalFiles`, `.folderLastScanStatus = 'idle'`. +7. Delete `projectFolderFiles` rows for `deletedFiles`. + +### Backend (`core/folder_indexer.py`) + +- `summarize_text(content, ext) → (summary, tokens)` via `gpt-4o-mini`, Langfuse prompt `folder_file_summary_text`. +- `summarize_image(b64) → (summary, tokens)` via `gpt-4o-mini` vision, Langfuse prompt `folder_file_summary_image`. +- After each summarization, atomically increment `MonthlyTokenUsage(user_id, year_month, 'folder_index', +tokens)`. If the increment would exceed cap, the call returns a `quota_exceeded` error in `index_file_result`, and the session sends `index_session_done(status='quota_exceeded')`. + +### Rescan triggers + +- **Manual button** → tRPC `projectFolders.startScan` mutation. +- **On-demand mtime check** → inside `read_project_folder_manifest` drizzle-executor action: if any tracked mtime is stale, fire-and-forget `startScan` before returning the current manifest. +- **Daily auto** → `app.on('ready')` iterates user projects; if `folderLastScannedAt < now − 24h` and `folderPath != null`, queue `startScan`. + +`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers (manual button disabled, daily auto + mtime on-demand both skip). + +## Agent Integration + +### Manifest pre-injection + +In `core/deep_agent.py`, every agent run that has a resolved `projectId` builds a compact manifest block and prepends it to the system prompt: + +``` + +path: D:\Clients\Acme\Brand (214 files, scanned 2h ago) +files: +- /briefs/kickoff.md [text] Project kickoff notes; scope, stakeholders, deadlines +- /logos/logo-v3.png [image] Final logo, golden-yellow palette on white +- /research/competitor.pdf [pdf] Competitor brand audit, 12 entries +... + +``` + +Format: `relativePath [kind] summary`. If the rendered block exceeds ~3000 tokens, truncate to the top N files by `mtimeMs DESC` and append: + +``` +… {M} more files omitted, use read_project_folder_file to access by path +``` + +The backend pulls the manifest via the new drizzle-executor action: + +``` +action: read_project_folder_manifest +data: { projectId } +returns: { folderPath, lastScannedAt, files: [{relPath, kind, summary}] } +``` + +### projectId resolution per agent + +- `run_task_brief_research_stream` — `task.projectId`. +- `run_home` — null unless the user message is project-scoped (via `@project` mention or active project context passed from renderer). +- `run_brief` — backend cannot enumerate projects directly because projects live in the local SQLite. It calls a new `execute_on_client` action `list_projects_with_folder_manifests` that returns `[{ projectId, projectName, folderPath, lastScannedAt, files: [{relPath, kind, summary}] }]` for every project that has a linked folder. The backend then builds a **multi-project compact manifest** (top 5 most-recently-modified files per project). + +### New scoped tool (`agents/folder_agent.py`) + +```python +@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.""" + result = await execute_on_client( + action="read_project_folder_file", + data={"projectId": project_id, "relativePath": relative_path}, + ) + return result.get("content", "") or f"File not found: {relative_path}" +``` + +Backed by a new `drizzle-executor` action that: +1. Looks up `projects.folderPath` for the projectId. +2. Resolves `path.join(folderPath, relativePath)` with traversal guard (`..` and absolute paths rejected). +3. Reads the file via the existing fs helpers. Image → returns base64. Text → returns content (size-capped). + +The existing journey-only `FILESYSTEM_TOOLS` are not added to home/brief/task-brief; only the new scoped tool is bound. + +## UI + +### Hero chip (`ProjectDetail.tsx`) + +```tsx + scrollToTab('files')} +/> +``` + +States: +- **Unlinked:** dashed pill "📁 Link folder" + Sparkles icon. +- **Linked idle:** "📁 214 files · 2h ago" with soft golden-yellow background. +- **Scanning:** "📁 indexing 47/214" + spinner. +- **Error:** "📁 Scan failed" red-tinted; click → Files tab. + +### Files tab + +Add `'files'` to `SECTIONS` in `ProjectTabBar.tsx`. The tab body: + +``` +┌──────────────────────────────────────────────────┐ +│ Linked folder │ +│ ┌────────────────────────────────────────────┐ │ +│ │ 📁 D:\Clients\Acme\Brand [⋯ menu] │ │ +│ │ 214 files · last scanned 2h ago │ │ +│ │ [Rescan] [Unlink] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ Files (filter: [All] [Text] [Images] [PDF]) │ +│ ┌────────────────────────────────────────────┐ │ +│ │ briefs/kickoff.md │ │ +│ │ Project kickoff notes; scope, deadlines │ │ +│ │ logos/logo-v3.png │ │ +│ │ Final logo, golden-yellow on white │ │ +│ │ ... │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +### Empty state (no folder linked) + +``` + + Sparkles + Link a project folder + Connect a local folder so AI agents can read its files + when answering questions about this project. + [Choose folder...] ← opens Electron dialog.showOpenDialog + +``` + +### New components (`src/renderer/components/projects/folder/`) + +- `FolderChip.tsx` +- `FilesSection.tsx` (mounts inside `ProjectDetail`) +- `FolderLinkCard.tsx` (path + actions) +- `FolderFileList.tsx` (virtualized list of manifest entries) +- `FolderUnlinkDialog.tsx` + +### Platform gating + +Feature is **Electron-only**. Wrap entry points in `platform.isElectron`. On the web SPA, the Files tab renders disabled with a tooltip "Folder linking available in desktop app". + +### Folder dialog + +New tRPC `projectFolders.chooseFolder` mutation invokes `dialog.showOpenDialog({ properties: ['openDirectory'] })` in the main process and returns the selected path. + +### i18n + +Add `projects.folder.*` keys (title, link CTA, browse, rescan, unlink, status strings, empty state copy, error toasts) to all 5 locale JSON files: en, it, es, fr, de. + +## Error Handling + +### Quota exhaustion + +- Pre-flight 402 → toast `"Folder too big for {tier} plan — max {N} files"` or `"Monthly token budget exhausted (resets {date})"`. Folder not linked. +- Mid-scan `quota_exceeded` frame → partial manifest kept, scan marked `error`, toast as above, banner in Files tab `"Indexing paused — quota exhausted"`. + +### Path errors + +- Folder no longer exists at scan start → tRPC throws → toast `"Folder not found: {path}"`. `folderLastScanStatus = 'error'`. User offered Unlink or Re-link. +- Permission denied on a file during scan → file skipped, logged in `projectFolderFiles` with `kind='skipped'`, no summary. Skipped files appear greyed in the Files tab. +- Path traversal attempt in `read_project_folder_file` (relativePath contains `..` or is absolute) → tool returns `"Access denied"`; backend logs a warning. Hard fail, no fallback. + +### Network / WS failures + +- WS drop mid-scan: the in-flight session is abandoned server-side and the local `folderLastScanStatus` is flipped from `'scanning'` to `'error'`. The next trigger (manual rescan, daily auto, or the next on-demand mtime check) starts a **new** session; because the scanner's mtime delta only re-summarizes files whose `mtimeMs` changed (or that have no row yet), already-indexed files are skipped naturally — there is no explicit session-resume protocol. +- Backend 5xx on summarize → file marked `kind='error'`, retried in the next rescan, not auto-retried inline. + +### File-type fallbacks + +- PDF parse fails (corrupt) → skipped, `kind='skipped'` with `summary='Could not extract text'`. +- Image too large (>5 MB) → skipped with reason. Cap is a constant in `files/scanner.ts`. +- DOCX or other unsupported types → skipped silently with extension noted. + +### Concurrent scan guard + +`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers. Manual button shows "Scanning..." disabled; daily auto + mtime on-demand both check the status flag first. + +### Manifest size overflow + +If the agent's pre-injected `` block would exceed ~3000 tokens, the backend truncates to the top N files by `mtimeMs DESC` and appends an "M more files omitted" hint. + +### Tool call on unlinked project + +`read_project_folder_file` when `folderPath === null` returns `"No folder linked to project {projectId}"`. The agent can recover and answer without folder context. + +## Testing + +### API (`api/tests/`) + +| File | Coverage | +|------|----------| +| `test_folder_indexer.py` | `summarize_text` / `summarize_image` happy path, token recording, Langfuse prompt linking | +| `test_folder_quota.py` | Pre-flight 402 rejects (max_files + monthly_tokens), atomic increment + `quota_exceeded` mid-stream, monthly reset at `year_month` rollover | +| `test_ws_index_session.py` | Session lifecycle, cancel mid-stream, abandoned-on-disconnect (next scan skips already-indexed files via mtime delta), bad batch payload validation | +| `test_folder_agent_tool.py` | `read_project_folder_file` happy path, unlinked project, traversal guard (`../`, absolute) | +| `test_manifest_injection.py` | `` block formatting, truncation past 3k tokens, multi-project brief manifest, null projectId skips injection | + +Reuse fixtures in `tests/conftest.py` and WS test helpers (`ws_unified` already covers session lifecycle). + +### Electron / adiuvAI + +No automated test suite currently. Manual smoke checks during development: + +- Link folder → manifest populated → unlink → manifest rows deleted. +- Scan a synthetic dir with mixed text/image/binary → only whitelisted indexed. +- mtime delta: change one file, rescan only re-indexes that file. +- Disconnect WS mid-scan → status flips to `'error'`; next manual rescan re-indexes only the remaining files (mtime delta). + +### Eval (Langfuse) + +Build a test set of 10 representative folders (mix of markdown, code, PDFs, images). Score summary quality (LLM-as-judge) and token efficiency. Link scores to the prompt version per the existing `LOCAL_AGENT_V2_PLAN.md` pattern. + +## Out of scope (this spec) + +- Phase-2 wiki tier (per-folder + per-file structured summaries). +- Multi-folder per project. +- Web SPA support. +- Token-usage display UI (Settings page comes later). +- File editing from inside adiuvAI. +- Live file watcher (chokidar). Daily + manual + on-demand mtime is enough for now. + +## Open questions (none) + +All resolved during brainstorming.