zimejin commited on
Commit
8eb816f
·
0 Parent(s):

Deploy: DeepTrust workspace (v2 clean history)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.cursor/plans/client-side_rag_knowledge_upload_c1d69919.plan.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Client-side RAG knowledge upload
3
+ overview: "Implement the Context panel as a real client-side RAG flow: persist PDFs, notes, and URLs in IndexedDB, index them with in-browser embeddings and a vector store, retrieve relevant chunks at query time, and pass that context to the research agent so the LLM uses it when planning and synthesizing."
4
+ todos: []
5
+ isProject: false
6
+ ---
7
+
8
+ # Client-Side RAG: Knowledge Upload and Retrieval
9
+
10
+ ## Current state
11
+
12
+ - **Frontend** ([app/page.tsx](app/page.tsx)): The Context panel stores only metadata in React state (`KnowledgeItem`: id, type, label, meta). File content is never read; notes are plain text; URLs are just strings. No persistence, no embeddings, no retrieval.
13
+ - **API** ([app/api/research/route.ts](app/api/research/route.ts)): Reads only `query` from the body; `knowledge` is ignored.
14
+ - **Agent** ([lib/agent/graph.ts](lib/agent/graph.ts), [lib/agent/state.ts](lib/agent/state.ts)): `runResearch(userQuery, sessionName)` and `createInitialState({ threadId, userQuery, sessionName })`; no field for retrieved context. The Thinker builds the prompt from `state.userQuery` only ([lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts) line 117).
15
+
16
+ ## Target flow
17
+
18
+ ```mermaid
19
+ sequenceDiagram
20
+ participant User
21
+ participant UI
22
+ participant IndexedDB
23
+ participant VectorStore
24
+ participant API
25
+ participant Agent
26
+
27
+ User->>UI: Add PDF / note / URL
28
+ UI->>UI: Extract text (PDF: pdf.js; note: as-is; URL: see below)
29
+ UI->>UI: Chunk and embed (browser)
30
+ UI->>VectorStore: Store chunks + vectors in IndexedDB
31
+ User->>UI: Run research
32
+ UI->>VectorStore: Embed query; top-k similarity search
33
+ UI->>API: POST { query, retrievedContext }
34
+ API->>Agent: runResearch(query, { knowledgeContext })
35
+ Agent->>Agent: Thinker / Synthesizer use knowledgeContext in prompts
36
+ ```
37
+
38
+
39
+
40
+ ## 1. Client: Storage, extraction, embedding, and retrieval
41
+
42
+ ### 1.1 Persistence and data model
43
+
44
+ - Introduce a **client-side knowledge store** backed by **IndexedDB** (separate from the existing React state so refreshes and future sessions retain data).
45
+ - **Schema** (conceptual):
46
+ - **Documents**: `id`, `type` (file | url | note), `label`, `createdAt`, optional `url` for type url.
47
+ - **Chunks**: `id`, `documentId`, `text`, `embedding` (array of numbers), `startIndex`, `endIndex`.
48
+ - Use a single IndexedDB database (e.g. `deeptrust-knowledge`) with object stores for documents and chunks (or a library that wraps this).
49
+
50
+ ### 1.2 Text extraction
51
+
52
+ - **PDFs**: Use **pdf.js** (Mozilla) in the browser to extract text from dropped/selected files. No server round-trip; keep PDFs client-side only.
53
+ - **Notes**: Use the note text as a single “document”; optional chunking by paragraph or fixed size.
54
+ - **URLs**: Two options (choose one for v1):
55
+ - **A (recommended for scope)**
56
+ Store URL as metadata only. Do **not** fetch or embed URL content in the browser (avoids CORS and complexity). When building the research payload, send the list of “context URLs” with the request; the backend can pass them into the Thinker so the plan may include `document_fetch` steps for those URLs, or the Thinker can be prompted to consider “the user provided these URLs as references” in the plan.
57
+ - **B (full client-side)**
58
+ Fetch URL in the browser (e.g. via a Next.js API route that proxies the fetch to avoid CORS), extract text (HTML → text), then chunk and embed like PDFs. Adds proxy and error handling.
59
+
60
+ Recommendation: **A** for the first iteration; document B as a follow-up.
61
+
62
+ ### 1.3 Chunking and embedding in the browser
63
+
64
+ - **Chunking**: Split document text into overlapping or fixed-size segments (e.g. 256–512 tokens or ~500 chars with 50–100 char overlap). No server call.
65
+ - **Embeddings**: Use a **browser-run embedding model** so everything stays client-side:
66
+ - **Option A**: **Transformers.js** (`@xenova/transformers`) with a small feature-extraction model (e.g. `Xenova/all-MiniLM-L6-v2` or similar). Runs in WebAssembly; single dependency consistent with “local-first”.
67
+ - **Option B**: A dedicated client-side vector DB that includes embeddings (e.g. **idbvec** with external embeddings, or **entity-db** which uses Transformers.js under the hood). Prefer one that stores vectors in IndexedDB and supports similarity search.
68
+ - Store each chunk’s embedding in IndexedDB with a reference to its document and text.
69
+
70
+ ### 1.4 Vector store and retrieval
71
+
72
+ - **Option A**: **idbvec** (`@brainwires/idbvec`): WASM + IndexedDB, HNSW index, configurable distance. You supply embeddings (e.g. from Transformers.js); idbvec handles storage and k-NN search.
73
+ - **Option B**: **Custom**: Store chunks (with embeddings) in IndexedDB; on query, load relevant chunks and run brute-force cosine similarity (or a tiny WASM k-NN) in a worker to avoid blocking the main thread. Good for small corpora (< ~10k chunks).
74
+ - At “Run research” time: embed the user query with the same model, run top-k similarity search (e.g. k = 5–10), build a single `retrievedContext` string (e.g. concatenate chunk texts with source labels) and send it with the request.
75
+
76
+ ### 1.5 UI and sync with existing Context panel
77
+
78
+ - Keep the existing **Context panel** UX: drag-and-drop, “Attach files”, URL input, “Short note”, and the list of items.
79
+ - **On add**:
80
+ - For **files** (PDF): read file, extract text with pdf.js, chunk, embed, and write documents + chunks (+ vectors) to IndexedDB. Add a corresponding entry to React state for the list (label, type, id) and optionally sync the list from IndexedDB on load.
81
+ - For **notes**: create one document, chunk if needed, embed, store; update UI list.
82
+ - For **URLs** (v1): store URL in IndexedDB as a document with no chunks (or a single placeholder chunk with URL as “text”); backend will receive “context URLs” and can use them in the prompt or in plan steps.
83
+ - **On remove**: Delete document (and its chunks) from IndexedDB and from React state. Add a remove control next to each item in the list (currently there is none).
84
+ - **On load**: Hydrate the Context list from IndexedDB so persisted knowledge survives refresh.
85
+
86
+ ## 2. Backend: Accept and use retrieved context
87
+
88
+ ### 2.1 Research API
89
+
90
+ - In [app/api/research/route.ts](app/api/research/route.ts), read from the request body: `query` and either `retrievedContext` (string) or `knowledge` (array or object that includes `retrievedContext` and optionally `contextUrls: string[]`).
91
+ - Call the agent with this context, e.g. `runResearch(query, sessionName, { retrievedContext, contextUrls })`.
92
+
93
+ ### 2.2 Agent state and graph
94
+
95
+ - In [lib/agent/state.ts](lib/agent/state.ts), add an optional field to `ResearchState`, e.g.
96
+ `knowledgeContext: z.string().optional()`
97
+ and optionally `contextUrls: z.array(z.string()).optional()`.
98
+ - In [lib/agent/graph.ts](lib/agent/graph.ts), add the new channel(s) to the state graph (e.g. `knowledgeContext: { value: (_, n) => n }`) and ensure they are part of the initial state when provided.
99
+ - Extend [lib/agent/state.ts](lib/agent/state.ts) `createInitialState` to accept optional `knowledgeContext` (and `contextUrls` if used), and pass them through in [lib/agent/graph.ts](lib/agent/graph.ts) when calling `createInitialState` and streaming.
100
+
101
+ ### 2.3 Thinker and Synthesizer
102
+
103
+ - In [lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts), when building the user message (around line 117), if `state.knowledgeContext` is present, append it to the prompt, e.g.
104
+ “The user provided the following retrieved context from their local knowledge base. Use it to inform the plan and prefer steps that leverage this context where relevant:\n\n” + state.knowledgeContext.
105
+ If `state.contextUrls` is used, add a line like “The user also referenced these URLs: …” so the Thinker can emit `document_fetch` steps for them.
106
+ - In [lib/agent/nodes/synthesizer.ts](lib/agent/nodes/synthesizer.ts), if `state.knowledgeContext` is present, include it in the context passed to the LLM when synthesizing the final report (so the report can cite or summarize the user’s local knowledge).
107
+
108
+ ## 3. Dependencies and build
109
+
110
+ - Add npm packages: e.g. **pdfjs-dist** (or **react-pdf** if you prefer a React wrapper) for PDF text extraction; **@xenova/transformers** (or the chosen embedding solution); and **idbvec** or a minimal IndexedDB + vector search helper.
111
+ - Ensure the embedding model is loaded only when the user first adds knowledge (or on first “Run research” with non-empty context) to avoid blocking initial page load; consider a small “Preparing knowledge…” state in the UI when the embedding pipeline is loading.
112
+
113
+ ## 4. Scope summary
114
+
115
+
116
+ | Area | In scope |
117
+ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118
+ | Client | IndexedDB persistence for documents/chunks; PDF text extraction (pdf.js); notes as documents; URLs stored as references (no client fetch in v1). |
119
+ | Client | Browser embeddings (Transformers.js or equivalent); vector store in IndexedDB (idbvec or custom); top-k retrieval at query time; send `retrievedContext` (+ optional `contextUrls`) with POST /api/research. |
120
+ | Client | Context panel: remove item; hydrate list from IndexedDB on load; show “indexed” or “ready” per item after processing. |
121
+ | Backend | Research route parses `retrievedContext` (and optional `contextUrls`); runResearch(..., { knowledgeContext, contextUrls }); state + createInitialState + graph channels; Thinker and Synthesizer include context in prompts. |
122
+
123
+
124
+ ## 5. Files to add or touch
125
+
126
+ - **New**: `lib/knowledge/` (or `app/lib/knowledge/`) — IndexedDB schema, chunking, embedding pipeline (or wrapper around Transformers.js + idbvec), and retrieval function. Keep it UI-agnostic so it can be called from the Context panel and before `fetch('/api/research')`.
127
+ - **New** (optional): `app/components/KnowledgePanel.tsx` — Extract the Context panel into a component that uses the knowledge store and exposes “items” + “retrievedContext” for the parent.
128
+ - **Edit**: [app/page.tsx](app/page.tsx) — Wire file drop/note/URL to the knowledge store; before runResearch, call retrieval and send `retrievedContext` (and `contextUrls`) in the request body; add remove button; hydrate from IndexedDB.
129
+ - **Edit**: [app/api/research/route.ts](app/api/research/route.ts) — Read `retrievedContext` / `contextUrls`; pass to runResearch.
130
+ - **Edit**: [lib/agent/state.ts](lib/agent/state.ts) — Add `knowledgeContext` (and optionally `contextUrls`); extend `createInitialState`.
131
+ - **Edit**: [lib/agent/graph.ts](lib/agent/graph.ts) — Add channel(s); pass context into `createInitialState` in runResearch.
132
+ - **Edit**: [lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts) — Include `state.knowledgeContext` (and URLs) in the Thinker prompt.
133
+ - **Edit**: [lib/agent/nodes/synthesizer.ts](lib/agent/nodes/synthesizer.ts) — Include `state.knowledgeContext` in the synthesis prompt.
134
+ - **Edit**: [lib/agent/index.ts](lib/agent/index.ts) — Export any new types if needed; ensure runResearch signature is updated and documented.
135
+
136
+ ## 6. Optional follow-ups
137
+
138
+ - **URL content in browser**: Proxy fetch in an API route; extract text; chunk and embed like PDFs so URL content is fully in the vector store.
139
+ - **Delete-all / export**: Clear all knowledge from IndexedDB; export documents/chunks as JSON.
140
+ - **Progress UX**: Show “Extracting…”, “Embedding…”, “Indexed” per file so large PDFs don’t look stuck.
141
+
.dockerignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies (reinstalled in image)
2
+ node_modules
3
+ npm-debug.log*
4
+ .pnpm-debug.log*
5
+ .yarn*
6
+
7
+ # Build outputs (rebuilt in image)
8
+ .next
9
+ dist
10
+ out
11
+
12
+ # Git and IDE
13
+ .git
14
+ .gitignore
15
+ .cursor
16
+ .vscode
17
+ *.md
18
+ !README.md
19
+
20
+ # Env and secrets
21
+ .env
22
+ .env.*
23
+ !.env.example
24
+
25
+ # Misc
26
+ .DS_Store
27
+ *.log
28
+ coverage
29
+ .nyc_output
30
+ .hf-cache
.gitignore ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
42
+
43
+ # huggingface model cache
44
+ .hf-cache/
45
+
46
+ # HF Spaces reject binary files in git; keep .pptx out of history
47
+ docs/*.pptx
AGENTS.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepTrust — Agent context
2
+
3
+ **What this is:** TypeScript research agent (LangGraph state graph + local LLM + Next.js). Plan → audit → tools → synthesize. Real-time workspace UI with SSE streaming and context/knowledge upload.
4
+
5
+ **Where the truth lives:** Structure and features change over time. Prefer these for current details:
6
+ - **README.md** — Overview, stack, project structure, API, frontend, run/config.
7
+ - **docs/ARCHITECTURE.md** — Low-level design: state machine, nodes, routing, LLM layer, SSE protocol, frontend architecture.
8
+
9
+ **Rough layout (may evolve):**
10
+ - `lib/agent/` — Graph, state, nodes, LLM client (worker thread), utils.
11
+ - `app/` — Next.js app; `page.tsx` = workspace UI; `app/api/` = research + model endpoints.
12
+ - `dist/llm/` — Built worker (from `npm run build:worker`).
13
+
14
+ **Commands:** `npm run build:worker` before first run; `npm run dev` to develop; `npm run build` then `npm run start` for production.
15
+
16
+ **When editing:** Follow existing patterns in the area you change. For agent graph or state, check `lib/agent/state.ts` and `lib/agent/graph.ts`; for API/streaming, see the route handlers and README/ARCHITECTURE.
DEPLOY.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying DeepTrust on Render
2
+
3
+ The app is containerized with **Docker**. You do **not** need Docker installed locally—Render builds the image in the cloud when you push.
4
+
5
+ ## Prerequisites
6
+
7
+ - Git repo pushed to GitHub (or GitLab)
8
+ - Account on [Render](https://render.com)
9
+
10
+ ## Deploy steps
11
+
12
+ 1. Go to [dashboard.render.com](https://dashboard.render.com), connect your GitHub repo.
13
+ 2. Add a **Web Service**.
14
+ 3. Set:
15
+ - **Build:** Docker (Render will use the repo `Dockerfile`).
16
+ - **Plan:** See [Memory and plan](#memory-and-plan) below.
17
+ 4. Create the service. Render builds and deploys on each push.
18
+
19
+ **Blueprint:** Alternatively, use the repo’s `render.yaml` (Blueprint deploy) so the service is defined in code.
20
+
21
+ ## Memory and plan
22
+
23
+ Render **Free** instances have **512 MB RAM**. Model loading often exceeds this:
24
+
25
+ - **SmolLM2-360M (q4)** ≈ 300–450 MB for weights alone, plus Node, Next.js, worker, and inference → typically **OOM on Free** (502 when loading).
26
+ - **SmolLM2-135M (q4)** ≈ 150–200 MB → may work on Free but is tight; 502 can still occur under load.
27
+
28
+ **Recommendations:**
29
+
30
+ - **Free tier (512 MB):** Set env `HF_MODEL=HuggingFaceTB/SmolLM2-135M-Instruct` and use the 135M model (q4) in the UI. If you still get 502 on “Load model”, the instance is likely OOM — upgrade to Starter.
31
+ - **Starter (2 GB) or higher:** Safe for SmolLM2-360M (q4) and 135M. Use Starter if you need 360M or reliable inference.
32
+
33
+ ## Environment variables (optional)
34
+
35
+ - `HF_MODEL` — Hugging Face model id (default in code). On Free tier, use `HuggingFaceTB/SmolLM2-135M-Instruct` to reduce OOM risk.
36
+ - `HF_CACHE_DIR` — Where to cache model files (default `./.hf-cache`). On ephemeral disks this is lost between restarts; models re-download on cold start.
37
+ - `DEEPTRUST_LOAD_MODEL_AT_STARTUP` — Set to `0` or `false` to disable loading the model when the server starts (default: load at startup). Use this only if you prefer to load on first user request.
38
+
39
+ ## Hugging Face Spaces (Docker)
40
+
41
+ When running as a **Docker Space**, the app **skips startup model preload** so the Space becomes healthy within the 30‑minute launch timeout. You’ll see in the logs: `[instrumentation] Hugging Face Space detected; skipping startup model preload (model will load on first use).` The model loads on first use (e.g. when you click “Load model” or run research). If your Space still times out, set the variable `DEEPTRUST_LOAD_MODEL_AT_STARTUP=0` in the Space’s **Settings → Variables**. In the Space README YAML you can set `app_port: 3000` to match the Dockerfile.
42
+
43
+ ## Notes
44
+
45
+ - **Model at startup:** The app loads the default model (from `HF_MODEL`, or 135M q4) when the Node server starts, so the first user request does not wait for download/load. (On Hugging Face Spaces this is skipped so the Space stays healthy.) If preload fails (e.g. OOM), the server still starts and the UI "Load model" button can be used to retry.
46
+ - **First deploy/cold start** can be slow while the model downloads and loads during server boot; subsequent requests are fast.
47
+ - **Standalone:** The image uses Next.js `output: "standalone"` for a smaller build and includes the compiled LLM worker under `dist/llm/`.
Dockerfile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepTrust — multi-stage build for Render
2
+ # Build runs in the cloud; no local Docker required.
3
+ # Uses node:20-slim (glibc) so onnxruntime-node and tokenizers native bindings work.
4
+ # Alpine (musl) causes 502 on model load due to incompatible native modules.
5
+
6
+ FROM node:20-slim AS deps
7
+ WORKDIR /app
8
+ COPY package.json package-lock.json* ./
9
+ RUN npm ci
10
+
11
+ FROM node:20-slim AS builder
12
+ WORKDIR /app
13
+ COPY --from=deps /app/node_modules ./node_modules
14
+ COPY . .
15
+
16
+ # Build LLM worker (dist/llm/) then Next.js (standalone)
17
+ ENV NEXT_TELEMETRY_DISABLED=1
18
+ RUN npm run build
19
+
20
+ # Pre-download the default model so the container doesn't fetch at runtime
21
+ ENV HF_CACHE_DIR=/app/.hf-cache-build
22
+ RUN mkdir -p /app/.hf-cache-build && node scripts/preload-hf-model.cjs
23
+
24
+ FROM node:20-slim AS runner
25
+ WORKDIR /app
26
+
27
+ ENV NODE_ENV=production
28
+ ENV NEXT_TELEMETRY_DISABLED=1
29
+
30
+ RUN groupadd --gid 1001 nodejs && useradd --uid 1001 --gid nodejs --shell /bin/false nextjs
31
+
32
+ # Standalone output: server + minimal node_modules
33
+ COPY --from=builder /app/.next/standalone ./
34
+ COPY --from=builder /app/.next/static ./.next/static
35
+ COPY --from=builder /app/public ./public
36
+ # LLM worker (required at runtime; path is process.cwd()/dist/llm)
37
+ COPY --from=builder /app/dist ./dist
38
+ # Worker runs in a separate thread and requires these at runtime; standalone does not trace them.
39
+ COPY --from=builder /app/package.json ./package.json
40
+ COPY --from=builder /app/package-lock.json ./package-lock.json
41
+ RUN npm install @huggingface/transformers onnxruntime-node --omit=dev --ignore-scripts --no-save
42
+
43
+ # Pre-downloaded model cache (avoids runtime download; worker reads from here)
44
+ COPY --from=builder --chown=nextjs:nodejs /app/.hf-cache-build ./.hf-cache
45
+ ENV HF_CACHE_DIR=/app/.hf-cache
46
+
47
+ USER nextjs
48
+
49
+ EXPOSE 3000
50
+ ENV PORT=3000
51
+ ENV HOSTNAME="0.0.0.0"
52
+
53
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DeepTrust Research Agent
3
+ emoji: 🔬
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 3000
8
+ pinned: false
9
+ short_description: LangGraph research agent with local LLM and Next.js workspace.
10
+ tags:
11
+ - langgraph
12
+ - nextjs
13
+ - transformers
14
+ - research-agent
15
+ - llm
16
+ - typescript
17
+ ---
18
+
19
+ # DeepTrust Research Agent
20
+
21
+ A TypeScript implementation of an autonomous research agent: LangGraph state machines, local LLM inference (Hugging Face Transformers in a worker thread), and a real-time, AI-centric Next.js workspace. The UI is designed for a Cursor/Gemini-like flow—immediate feedback, Server-Sent Events (SSE) streaming, optimistic updates, a knowledge/context drop zone, and quick-action chips—so the full application from graph nodes to the browser is understandable in one read.
22
+
23
+ ## Documentation
24
+
25
+ - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — Low-level design: state machine, channels, routing, LLM layer, API protocols, and frontend architecture (SSE, streaming UX, knowledge flow).
26
+
27
+ ## Table of Contents
28
+
29
+ 1. [Architecture Overview](#architecture-overview)
30
+ 2. [Technology Stack](#technology-stack)
31
+ 3. [Project Structure](#project-structure)
32
+ 4. [State Management](#state-management)
33
+ 5. [Graph Nodes](#graph-nodes)
34
+ 6. [Routing and Conditional Edges](#routing-and-conditional-edges)
35
+ 7. [LLM Integration](#llm-integration)
36
+ 8. [API Layer](#api-layer)
37
+ 9. [Frontend: Real-Time Workspace](#frontend-real-time-workspace)
38
+ 10. [Running the Project](#running-the-project)
39
+ 11. [Configuration](#configuration)
40
+
41
+ ---
42
+
43
+ ## Architecture Overview
44
+
45
+ DeepTrust implements a cyclic state graph where a research query flows through multiple specialized nodes:
46
+
47
+ ```
48
+ ┌──────────────────────────────────────────────────────────┐
49
+ │ │
50
+ │ [START] ──► thinker ──► auditor ──► tool_executor │
51
+ │ ▲ │ │ │
52
+ │ │ reject │ │ │
53
+ │ └───────────┘ ▼ │
54
+ │ synthesizer │
55
+ │ │ │
56
+ │ [END] │
57
+ └──────────────────────────────────────────────────────────┘
58
+ ```
59
+
60
+ ### Node Responsibilities
61
+
62
+ - **Thinker**: Decomposes a research question into a structured, multi-step plan
63
+ - **Auditor**: Validates the plan against organizational policy; rejects non-compliant plans
64
+ - **HITL Gate**: Pauses execution for human approval before tool execution
65
+ - **Tool Executor**: Executes each plan step sequentially (web search, document fetch, etc.)
66
+ - **Synthesizer**: Aggregates tool outputs into a final research report
67
+
68
+ The graph supports revision loops: if the Auditor rejects a plan, control returns to the Thinker with structured feedback. A configurable ceiling (`maxPlanRevisions`) prevents infinite loops.
69
+
70
+ ---
71
+
72
+ ## Technology Stack
73
+
74
+ | Layer | Technology | Purpose |
75
+ |-------|------------|---------|
76
+ | **Frontend** | Next.js 16, React 19, Tailwind CSS 4 | Server-side rendering, streaming UI updates |
77
+ | **State Machine** | LangGraph.js | Graph construction, checkpointing, conditional routing |
78
+ | **LLM Inference** | @huggingface/transformers | Local model loading and text generation |
79
+ | **Schema Validation** | Zod 4 | Runtime type validation for state and API contracts |
80
+ | **Type System** | TypeScript 5 | Static type safety across the codebase |
81
+
82
+ ### Key Dependencies
83
+
84
+ ```json
85
+ {
86
+ "@langchain/langgraph": "^1.1.5",
87
+ "@huggingface/transformers": "^3.8.1",
88
+ "zod": "^4.3.6",
89
+ "next": "16.1.6",
90
+ "react": "19.2.3"
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Project Structure
97
+
98
+ ```
99
+ lib/agent/
100
+ ├── index.ts # Public API exports
101
+ ├── graph.ts # StateGraph construction and compilation
102
+ ├── state.ts # Zod schemas and TypeScript types
103
+ ├── routing.ts # Conditional edge functions
104
+ ├── llm/
105
+ │ ├── index.ts # Worker proxy: loadModel, chatComplete, getModelStatus
106
+ │ ├── pipeline.ts # Pipeline config (used by worker)
107
+ │ └── worker-entry.ts # Worker entry: runs Transformers in a separate thread
108
+ ├── nodes/
109
+ │ ├── index.ts # Node exports
110
+ │ ├── thinker.ts # Plan generation node
111
+ │ ├── auditor.ts # Policy validation node
112
+ │ ├── hitl-gate.ts # Human approval checkpoint
113
+ │ ├── tool-executor.ts # Tool dispatch node
114
+ │ └── synthesizer.ts # Report synthesis node
115
+ └── utils/
116
+ ├── index.ts # Utility exports
117
+ ├── extract-json.ts # Robust JSON parsing
118
+ └── policy.ts # Policy file loader
119
+
120
+ dist/llm/ # Built by npm run build:worker
121
+ ├── worker-entry.js # Compiled worker
122
+ └── pipeline.js # Pipeline bundle
123
+
124
+ app/
125
+ ├── page.tsx # Workspace UI: chat, context panel, model card, SSE client
126
+ ├── layout.tsx
127
+ ├── globals.css
128
+ └── api/
129
+ ├── research/
130
+ │ └── route.ts # POST: SSE stream of research events
131
+ └── model/
132
+ └── load/
133
+ └── route.ts # GET/POST: model load + SSE progress
134
+ ```
135
+
136
+ ---
137
+
138
+ ## State Management
139
+
140
+ ### The ResearchState Schema
141
+
142
+ All state flows through a single Zod-validated schema. This ensures runtime type safety and enables serialization for checkpointing.
143
+
144
+ ```typescript
145
+ export const ResearchState = z.object({
146
+ // Identity
147
+ threadId: z.string().uuid(),
148
+ sessionName: z.string().default("Unnamed Session"),
149
+
150
+ // Input
151
+ userQuery: z.string().min(1),
152
+
153
+ // Planning
154
+ plan: ResearchPlan.nullable().default(null),
155
+ rejectionFeedback: z.string().nullable().default(null),
156
+ planRevisionCount: z.number().int().nonnegative().default(0),
157
+ maxPlanRevisions: z.number().int().positive().default(5),
158
+
159
+ // Auditing
160
+ auditResult: AuditResult.nullable().default(null),
161
+
162
+ // Execution
163
+ currentStepIndex: z.number().int().nonnegative().default(0),
164
+ humanApproved: z.boolean().default(false),
165
+
166
+ // Output
167
+ finalReport: z.string().nullable().default(null),
168
+
169
+ // Observability
170
+ reasoning: z.array(ReasoningEntry).default([]),
171
+ status: RunStatus.default("idle"),
172
+ updatedAt: z.string().datetime(),
173
+ errorMessage: z.string().nullable().default(null),
174
+ });
175
+ ```
176
+
177
+ ### Channel Configuration
178
+
179
+ LangGraph requires explicit channel definitions for state merging. Most fields use last-write-wins semantics, but the `reasoning` array uses append-only concatenation:
180
+
181
+ ```typescript
182
+ const graph = new StateGraph<ResearchState>({
183
+ channels: {
184
+ threadId: { value: (_, n) => n },
185
+ // ... other scalar fields use (_, n) => n
186
+
187
+ // Append-only reasoning log
188
+ reasoning: {
189
+ value: (existing: ReasoningEntry[], incoming: ReasoningEntry[]) =>
190
+ [...(existing ?? []), ...(incoming ?? [])],
191
+ default: () => [],
192
+ },
193
+ },
194
+ });
195
+ ```
196
+
197
+ ### Sub-Schemas
198
+
199
+ **ResearchStep**: A single action in the research plan.
200
+
201
+ ```typescript
202
+ export const ResearchStep = z.object({
203
+ id: z.string().uuid(),
204
+ tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
205
+ input: z.string().min(1),
206
+ rationale: z.string(),
207
+ output: z.string().optional(),
208
+ executedAt: z.string().datetime().optional(),
209
+ });
210
+ ```
211
+
212
+ **AuditResult**: Structured feedback from the Auditor.
213
+
214
+ ```typescript
215
+ export const AuditResult = z.object({
216
+ verdict: z.enum(["approved", "rejected", "needs_revision"]),
217
+ policyViolations: z.array(z.string()).default([]),
218
+ suggestions: z.array(z.string()).default([]),
219
+ auditedAt: z.string().datetime(),
220
+ });
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Graph Nodes
226
+
227
+ Each node is an async function that receives the current state and returns a partial state update.
228
+
229
+ ### Thinker Node
230
+
231
+ Generates or revises a research plan. Prompts the LLM with structured output requirements:
232
+
233
+ ```typescript
234
+ async function thinkerNode(state: ResearchState): Promise<Partial<ResearchState>> {
235
+ const isRevision = state.planRevisionCount > 0 && state.rejectionFeedback;
236
+
237
+ const system = `
238
+ You are the Thinker node of DeepTrust, an autonomous research agent.
239
+ Return ONLY a valid JSON object matching:
240
+ {
241
+ "objective": string,
242
+ "steps": Array<{ "id": UUID, "tool": string, "input": string, "rationale": string }>,
243
+ "estimatedTokenBudget": number,
244
+ "createdAt": ISO8601,
245
+ "revision": number
246
+ }`;
247
+
248
+ const userMessage = isRevision
249
+ ? `Research question: "${state.userQuery}"\n\nPREVIOUS PLAN REJECTED:\n${state.rejectionFeedback}`
250
+ : `Research question: "${state.userQuery}"`;
251
+
252
+ const rawThought = await chatComplete(system, userMessage);
253
+ const parsed = extractJSON(rawThought);
254
+ const plan = ResearchPlan.parse({ ...parsed, revision: state.planRevisionCount });
255
+
256
+ return {
257
+ plan,
258
+ status: "thinking",
259
+ rejectionFeedback: null,
260
+ reasoning: appendReasoning(state, { node: "thinker", summary: "..." }),
261
+ updatedAt: new Date().toISOString(),
262
+ };
263
+ }
264
+ ```
265
+
266
+ ### Auditor Node
267
+
268
+ Validates plans against `POLICY.md`. Returns structured violations and suggestions:
269
+
270
+ ```typescript
271
+ async function auditorNode(state: ResearchState): Promise<Partial<ResearchState>> {
272
+ const policy = loadPolicy();
273
+
274
+ const system = `
275
+ You are the Auditor node. Evaluate research plans against policy.
276
+ Return ONLY: { "verdict": "approved"|"rejected"|"needs_revision", ... }`;
277
+
278
+ const rawThought = await chatComplete(system, `POLICY:\n${policy}\n\nPLAN:\n${JSON.stringify(state.plan)}`);
279
+ const auditResult = AuditResult.parse(extractJSON(rawThought));
280
+
281
+ const isRejected = auditResult.verdict !== "approved";
282
+
283
+ return {
284
+ auditResult,
285
+ rejectionFeedback: isRejected ? formatFeedback(auditResult) : null,
286
+ planRevisionCount: isRejected ? state.planRevisionCount + 1 : state.planRevisionCount,
287
+ status: isRejected ? "thinking" : "awaiting_approval",
288
+ };
289
+ }
290
+ ```
291
+
292
+ ### HITL Gate Node
293
+
294
+ Uses LangGraph's `interrupt()` primitive to pause execution and write a checkpoint:
295
+
296
+ ```typescript
297
+ async function hitlGateNode(state: ResearchState): Promise<Partial<ResearchState>> {
298
+ interrupt({
299
+ message: "Plan ready for review. Set humanApproved=true to continue.",
300
+ plan: state.plan,
301
+ auditResult: state.auditResult,
302
+ });
303
+
304
+ return { updatedAt: new Date().toISOString() };
305
+ }
306
+ ```
307
+
308
+ ### Tool Executor Node
309
+
310
+ Iterates through plan steps. Each invocation processes one step and increments `currentStepIndex`:
311
+
312
+ ```typescript
313
+ async function toolExecutorNode(state: ResearchState): Promise<Partial<ResearchState>> {
314
+ const step = state.plan.steps[state.currentStepIndex];
315
+ const output = await dispatchTool(step.tool, step.input);
316
+
317
+ const updatedSteps = state.plan.steps.map((s, i) =>
318
+ i === state.currentStepIndex ? { ...s, output, executedAt: new Date().toISOString() } : s
319
+ );
320
+
321
+ return {
322
+ plan: { ...state.plan, steps: updatedSteps },
323
+ currentStepIndex: state.currentStepIndex + 1,
324
+ status: "executing",
325
+ };
326
+ }
327
+ ```
328
+
329
+ ### Synthesizer Node
330
+
331
+ Aggregates all step outputs into a final report:
332
+
333
+ ```typescript
334
+ async function synthesizerNode(state: ResearchState): Promise<Partial<ResearchState>> {
335
+ const stepsContext = state.plan.steps
336
+ .map((s, i) => `Step ${i + 1} [${s.tool}]: ${s.output}`)
337
+ .join("\n\n");
338
+
339
+ const finalReport = await chatComplete(
340
+ "Write a comprehensive research report.",
341
+ `Objective: ${state.plan.objective}\n\nResults:\n${stepsContext}`
342
+ );
343
+
344
+ return { finalReport, status: "complete" };
345
+ }
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Routing and Conditional Edges
351
+
352
+ LangGraph uses routing functions to determine the next node based on current state.
353
+
354
+ ### Post-Audit Routing
355
+
356
+ ```typescript
357
+ function routeAfterAudit(state: ResearchState): "thinker" | "hitl_gate" | typeof END {
358
+ if (state.planRevisionCount >= state.maxPlanRevisions) {
359
+ return END; // Safety ceiling reached
360
+ }
361
+ if (state.auditResult?.verdict !== "approved") {
362
+ return "thinker"; // Loop back for revision
363
+ }
364
+ return "hitl_gate"; // Proceed to human approval
365
+ }
366
+ ```
367
+
368
+ ### Post-HITL Routing
369
+
370
+ ```typescript
371
+ function routeAfterHitl(state: ResearchState): "tool_executor" | typeof END {
372
+ if (!state.humanApproved) {
373
+ return END; // Fail-safe if approval missing
374
+ }
375
+ return "tool_executor";
376
+ }
377
+ ```
378
+
379
+ ### Post-Tool Routing
380
+
381
+ ```typescript
382
+ function routeAfterToolStep(state: ResearchState): "tool_executor" | "synthesizer" {
383
+ if (state.currentStepIndex < state.plan.steps.length) {
384
+ return "tool_executor"; // More steps remain
385
+ }
386
+ return "synthesizer"; // All steps complete
387
+ }
388
+ ```
389
+
390
+ ---
391
+
392
+ ## LLM Integration
393
+
394
+ ### Hugging Face Transformers
395
+
396
+ The project uses `@huggingface/transformers` for local inference. Models are cached to `.hf-cache/` for persistence across restarts.
397
+
398
+ ```typescript
399
+ import { pipeline, TextGenerationPipeline, env } from "@huggingface/transformers";
400
+
401
+ env.cacheDir = process.env.HF_CACHE_DIR || "./.hf-cache";
402
+
403
+ const MODEL_ID = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-360M-Instruct";
404
+
405
+ let generatorPromise: Promise<TextGenerationPipeline> | null = null;
406
+
407
+ export function loadModel(onProgress?: ProgressCallback): Promise<TextGenerationPipeline> {
408
+ if (generatorPromise) return generatorPromise;
409
+
410
+ generatorPromise = pipeline("text-generation", MODEL_ID, {
411
+ progress_callback: (data) => {
412
+ onProgress?.({
413
+ status: data.status === "progress" ? "downloading" : "loading",
414
+ progress: Math.round((data.progress || 0) * 100),
415
+ file: data.file || "",
416
+ message: `Downloading ${data.file?.split("/").pop()}`,
417
+ });
418
+ },
419
+ });
420
+
421
+ return generatorPromise;
422
+ }
423
+ ```
424
+
425
+ ### Chat Completion Interface
426
+
427
+ ```typescript
428
+ export async function chatComplete(systemPrompt: string, userMessage: string): Promise<string> {
429
+ const generator = await loadModel();
430
+
431
+ const output = await generator(
432
+ [
433
+ { role: "system", content: systemPrompt },
434
+ { role: "user", content: userMessage },
435
+ ],
436
+ { max_new_tokens: 4096, do_sample: true, temperature: 0.7 }
437
+ );
438
+
439
+ const result = output[0] as { generated_text: Array<{ role: string; content: string }> };
440
+ return result.generated_text.find((m) => m.role === "assistant")?.content ?? "";
441
+ }
442
+ ```
443
+
444
+ ### JSON Extraction
445
+
446
+ Small models often produce malformed JSON. The `extractJSON` utility handles common issues:
447
+
448
+ ```typescript
449
+ export function extractJSON(text: string): unknown {
450
+ // Try direct parse
451
+ try { return JSON.parse(text); } catch {}
452
+
453
+ // Remove markdown fences
454
+ const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/g, "");
455
+
456
+ // Extract JSON object
457
+ const match = cleaned.match(/\{[\s\S]*\}/);
458
+ if (match) {
459
+ try { return JSON.parse(match[0]); } catch {}
460
+ }
461
+
462
+ throw new Error(`Could not extract JSON from: ${text.slice(0, 200)}`);
463
+ }
464
+ ```
465
+
466
+ ---
467
+
468
+ ## API Layer
469
+
470
+ ### Research Endpoint (SSE)
471
+
472
+ `POST /api/research` streams research state updates as **Server-Sent Events** so the client can show progress immediately and parse events by type. Using SSE (instead of raw NDJSON) gives a standard, well-supported streaming protocol and allows future event names (e.g. `ping`, `heartbeat`) without changing the wire format.
473
+
474
+ **Request body:** `{ "query": string, "knowledge"?: Array<{ id, type, label, meta? }> }`. The `knowledge` array is sent by the workspace when the user adds files, URLs, or notes in the Context panel; the backend currently uses only `query` and may later use `knowledge` for RAG or plan conditioning.
475
+
476
+ **Response:** `Content-Type: text/event-stream`. Each message is an SSE message:
477
+
478
+ - `event: start` — First event; signals that the run has started (enables optimistic UI).
479
+ - `event: research` — One per graph node update; `data` is `{ node, state }`.
480
+ - `event: error` — On exception; `data` includes `node: "_error"` and `state.errorMessage`.
481
+
482
+ ```typescript
483
+ // Server: send helper
484
+ const send = (event: string, payload: { node: string; state: Record<string, unknown> }) => {
485
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`));
486
+ };
487
+ send("start", { node: "_start", state: { status: "started", ... } });
488
+ for await (const event of runResearch(query)) {
489
+ send("research", event);
490
+ }
491
+ // On catch: send("error", { node: "_error", state: { status: "failed", errorMessage } });
492
+ ```
493
+
494
+ ### Model Loading Endpoint
495
+
496
+ `GET /api/model/load?...modelId=...&dtype=...` (or POST with same query) streams download/load progress via SSE. The client parses `data: {...}` lines to drive the progress bar and status pill. See [Frontend: Real-Time Workspace](#frontend-real-time-workspace) for how the UI consumes these streams.
497
+
498
+ ---
499
+
500
+ ## Frontend: Real-Time Workspace
501
+
502
+ The React client (`app/page.tsx`) is a single-page workspace that mirrors a Cursor/Gemini-style flow: immediate, non-blocking feedback, streaming AI responses, and a dedicated context/knowledge area.
503
+
504
+ ### What the UI Provides
505
+
506
+ | Area | Purpose |
507
+ |------|--------|
508
+ | **Chat** | User messages and assistant replies. When a run finishes, the final report is streamed **word-by-word** into the last assistant message to mimic a live conversation. |
509
+ | **Context / Knowledge** | Drag-and-drop zone for PDFs, text files, or URLs; optional fields to add URLs and short notes. Stored in React state and sent as `knowledge` with each research request for future backend use. |
510
+ | **Quick-action chips** | A row of buttons below the input (e.g. “Help me learn this topic”, “Summarize these docs”) that set or extend the query and trigger a run—Gemini-style shortcuts. |
511
+ | **Starter cards** | Empty state with example prompts (e.g. “How does Gemini Pro work…”) that fill the input and can be run in one click. |
512
+ | **Model card** | Compact panel for model selection, load/progress, and status (Ready / Loading / Error). |
513
+ | **Reasoning trace** | Scrollable list of the latest reasoning entries from the event stream (node + summary) for observability. |
514
+
515
+ ### Optimistic UI and Shimmer
516
+
517
+ - On “Run Research”, the UI immediately appends the user message and a placeholder assistant message with a shimmer skeleton, then consumes SSE and updates that message when the final report arrives.
518
+ - Shimmer and loading states use Tailwind (e.g. `animate-pulse`, neutral backgrounds) so the interface feels responsive even when the agent is still planning or executing.
519
+
520
+ ### SSE Consumption (Research)
521
+
522
+ The client uses `EventSource`-style parsing on the `ReadableStream`: split by `\n\n`, then for each line look for `event:` and `data:` and dispatch by event type. Accumulated `research` events update both the reasoning trace and the chat when `finalReport` is present.
523
+
524
+ ```typescript
525
+ // Conceptual: read stream, split by double newline, parse "event:" and "data:"
526
+ const chunks = buffer.split("\n\n");
527
+ for (const chunk of chunks) {
528
+ const eventMatch = chunk.match(/event:\s*(\w+)/);
529
+ const dataMatch = chunk.match(/data:\s*(\{[\s\S]*\})/);
530
+ if (eventMatch && dataMatch) {
531
+ const payload = JSON.parse(dataMatch[1]);
532
+ if (eventMatch[1] === "research") setEvents((prev) => [...prev, payload]);
533
+ // ... handle start, error; when payload.state.finalReport exists, run word-by-word animation
534
+ }
535
+ }
536
+ ```
537
+
538
+ ### Word-by-Word Streaming
539
+
540
+ When an event contains `state.finalReport`, the full text is not dumped at once. A small timer (e.g. every 40ms) reveals the report word-by-word in the last assistant message and clears the “streaming” state when done. This keeps the same SSE event payload while making the reply feel live.
541
+
542
+ ---
543
+
544
+ ## Running the Project
545
+
546
+ ### Prerequisites
547
+
548
+ - Node.js 20+
549
+ - npm or pnpm
550
+
551
+ ### Installation
552
+
553
+ ```bash
554
+ npm install
555
+ ```
556
+
557
+ ### Build the LLM worker (required for local inference)
558
+
559
+ Inference runs in a Node.js worker thread. Compile the worker once before using the app locally:
560
+
561
+ ```bash
562
+ npm run build:worker
563
+ ```
564
+
565
+ This writes `dist/llm/worker-entry.js` and `dist/llm/pipeline.js`. The production build runs this step automatically.
566
+
567
+ ### Development
568
+
569
+ ```bash
570
+ npm run dev
571
+ ```
572
+
573
+ Open http://localhost:3000. Click "Load Model" to download and initialize the LLM, then run research queries.
574
+
575
+ If you see an error that the worker was not found, run `npm run build:worker` first.
576
+
577
+ ### Production build
578
+
579
+ ```bash
580
+ npm run build
581
+ ```
582
+
583
+ This runs `build:worker` then builds the Next.js app with webpack. The app is served with:
584
+
585
+ ```bash
586
+ npm run start
587
+ ```
588
+
589
+ ### First run
590
+
591
+ The first model load downloads weights to `.hf-cache/` (approximately 400MB for SmolLM2-360M Q4). Subsequent loads are fast.
592
+
593
+ ### Deploy (Render)
594
+
595
+ The repo includes a **Dockerfile** and config for [Render](https://render.com). Render builds the image in the cloud (no local Docker required). See **[DEPLOY.md](DEPLOY.md)** for steps.
596
+
597
+ ---
598
+
599
+ ## Configuration
600
+
601
+ Create `.env.local` from `.env.example`:
602
+
603
+ ```bash
604
+ # Model selection
605
+ HF_MODEL=HuggingFaceTB/SmolLM2-360M-Instruct
606
+ HF_CACHE_DIR=./.hf-cache
607
+ ```
608
+
609
+ ### Observability (LangSmith)
610
+
611
+ To make agent behavior observable and debug failures (e.g. thinker returning invalid JSON), use [LangSmith](https://smith.langchain.com/). Set in `.env.local`:
612
+
613
+ ```bash
614
+ LANGCHAIN_TRACING_V2=true
615
+ LANGCHAIN_API_KEY=your-langsmith-api-key
616
+ LANGCHAIN_PROJECT=deeptrust
617
+ ```
618
+
619
+ With tracing enabled, every graph run is recorded. You can inspect prompts, raw LLM outputs, and state transitions in the LangSmith UI, which helps diagnose schema validation errors and long-running or looping runs.
620
+
621
+ ### Available Models
622
+
623
+ | Model | Size | Speed | Quality |
624
+ |-------|------|-------|---------|
625
+ | `HuggingFaceTB/SmolLM2-360M-Instruct` | 400MB | Fast | Basic |
626
+ | `HuggingFaceTB/SmolLM2-1.7B-Instruct` | 1.7GB | Moderate | Better |
627
+
628
+ ---
629
+
630
+ ## Key Concepts Demonstrated
631
+
632
+ 1. **State Machines for Agents**: Using LangGraph to model complex, cyclic agent workflows
633
+ 2. **Type-Safe State**: Zod schemas with TypeScript inference for runtime validation
634
+ 3. **Local LLM Inference**: Running models in-process without external API dependencies
635
+ 4. **Streaming Responses**: Server-Sent Events and ReadableStream for real-time updates
636
+ 5. **Human-in-the-Loop**: Checkpoint interrupts for manual approval gates
637
+ 6. **Revision Loops**: Cyclic graph edges for iterative refinement with safety ceilings
638
+
639
+ ---
640
+
641
+ ## License
642
+
643
+ MIT
app/api/model/load/route.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Avoid loading @huggingface/transformers on Vercel (no native runtime / disk); use dynamic import only when needed.
2
+ const isVercel = process.env.VERCEL === "1";
3
+
4
+ function unsupportedStream(encoder: TextEncoder): ReadableStream<Uint8Array> {
5
+ const msg = {
6
+ status: "error",
7
+ progress: 0,
8
+ message:
9
+ "Model loading is not supported on Vercel (serverless). Run the app locally or self-host for local inference.",
10
+ models: [] as { id: string; label: string; dtype?: string; sizeNote?: string }[],
11
+ };
12
+ return new ReadableStream({
13
+ start(controller) {
14
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
15
+ controller.close();
16
+ },
17
+ });
18
+ }
19
+
20
+ export async function GET(request: Request) {
21
+ const encoder = new TextEncoder();
22
+
23
+ if (isVercel) {
24
+ return new Response(unsupportedStream(encoder), {
25
+ headers: {
26
+ "Content-Type": "text/event-stream",
27
+ "Cache-Control": "no-cache",
28
+ Connection: "keep-alive",
29
+ },
30
+ });
31
+ }
32
+
33
+ const { loadModel, getModelStatus, MODELS } = await import("@/lib/agent/llm");
34
+ const { searchParams } = new URL(request.url);
35
+ const modelId = searchParams.get("modelId") ?? undefined;
36
+ const dtype = searchParams.get("dtype") ?? undefined;
37
+
38
+ const stream = new ReadableStream({
39
+ async start(controller) {
40
+ const send = (data: object) => {
41
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
42
+ };
43
+
44
+ const status = await getModelStatus(modelId, dtype);
45
+ const requestedId = modelId ?? status.modelId;
46
+ const requestedDtype = dtype ?? status.dtype;
47
+ if (
48
+ status.status === "ready" &&
49
+ status.modelId === requestedId &&
50
+ status.dtype === requestedDtype
51
+ ) {
52
+ send({ ...status, models: status.models ?? MODELS });
53
+ controller.close();
54
+ return;
55
+ }
56
+
57
+ send({
58
+ status: "loading",
59
+ progress: 0,
60
+ message: "Starting model load...",
61
+ modelId: modelId ?? status.modelId,
62
+ dtype: dtype ?? status.dtype,
63
+ models: status.models ?? MODELS,
64
+ });
65
+
66
+ try {
67
+ await loadModel(modelId, dtype as "q4" | "fp16" | "fp32" | undefined, (progress) => {
68
+ send({ ...progress, models: (progress as { models?: typeof MODELS }).models ?? MODELS });
69
+ });
70
+ controller.close();
71
+ } catch (error) {
72
+ send({
73
+ status: "error",
74
+ progress: 0,
75
+ message: error instanceof Error ? error.message : "Unknown error",
76
+ modelId: modelId ?? undefined,
77
+ dtype: dtype ?? undefined,
78
+ models: MODELS,
79
+ });
80
+ controller.close();
81
+ }
82
+ },
83
+ });
84
+
85
+ return new Response(stream, {
86
+ headers: {
87
+ "Content-Type": "text/event-stream",
88
+ "Cache-Control": "no-cache",
89
+ Connection: "keep-alive",
90
+ },
91
+ });
92
+ }
93
+
94
+ export async function POST() {
95
+ if (isVercel) {
96
+ return Response.json({
97
+ status: "error",
98
+ progress: 0,
99
+ file: "",
100
+ message:
101
+ "Model loading is not supported on Vercel (serverless). Run the app locally or self-host for local inference.",
102
+ models: [],
103
+ });
104
+ }
105
+
106
+ const { getModelStatus, MODELS } = await import("@/lib/agent/llm");
107
+ const status = await getModelStatus();
108
+ return Response.json({ ...status, models: status.models ?? MODELS });
109
+ }
app/api/research/approve/route.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { approveAndResume } from "@/lib/agent";
2
+ import { NextRequest } from "next/server";
3
+
4
+ export async function POST(req: NextRequest) {
5
+ const body = await req.json();
6
+ const { threadId } = body as { threadId?: string };
7
+
8
+ if (!threadId) {
9
+ return new Response(
10
+ JSON.stringify({ error: "threadId is required to approve a run" }),
11
+ { status: 400, headers: { "Content-Type": "application/json" } }
12
+ );
13
+ }
14
+
15
+ const encoder = new TextEncoder();
16
+
17
+ const stream = new ReadableStream({
18
+ async start(controller) {
19
+ const send = (
20
+ event: string,
21
+ payload: { node: string; state: Record<string, unknown> }
22
+ ) => {
23
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
24
+ controller.enqueue(encoder.encode(data));
25
+ };
26
+
27
+ try {
28
+ for await (const event of approveAndResume(threadId)) {
29
+ send("research", event);
30
+ }
31
+ } catch (err) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ send("error", {
34
+ node: "_error",
35
+ state: {
36
+ status: "failed",
37
+ errorMessage: message,
38
+ },
39
+ });
40
+ } finally {
41
+ controller.close();
42
+ }
43
+ },
44
+ });
45
+
46
+ return new Response(stream, {
47
+ headers: {
48
+ "Content-Type": "text/event-stream; charset=utf-8",
49
+ "Cache-Control": "no-cache, no-transform",
50
+ Connection: "keep-alive",
51
+ "X-Accel-Buffering": "no",
52
+ },
53
+ });
54
+ }
55
+
app/api/research/route.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { runResearch } from "@/lib/agent";
2
+ import { NextRequest } from "next/server";
3
+
4
+ export async function POST(req: NextRequest) {
5
+ const body = await req.json();
6
+ const {
7
+ query,
8
+ retrievedContext,
9
+ contextUrls,
10
+ }: {
11
+ query: string;
12
+ retrievedContext?: string;
13
+ contextUrls?: string[];
14
+ } = body;
15
+
16
+ const encoder = new TextEncoder();
17
+
18
+ const stream = new ReadableStream({
19
+ async start(controller) {
20
+ const send = (
21
+ event: string,
22
+ payload: { node: string; state: Record<string, unknown> }
23
+ ) => {
24
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
25
+ controller.enqueue(encoder.encode(data));
26
+ };
27
+
28
+ // Initial "cursor-like" optimistic event
29
+ send("start", {
30
+ node: "_start",
31
+ state: {
32
+ status: "started",
33
+ plan: { objective: "Starting…", steps: [] },
34
+ reasoning: [
35
+ {
36
+ node: "_start",
37
+ summary:
38
+ "Research started. First step (planning) may take 1–2 minutes on slower devices.",
39
+ },
40
+ ],
41
+ },
42
+ });
43
+
44
+ try {
45
+ const options =
46
+ retrievedContext != null || (contextUrls?.length ?? 0) > 0
47
+ ? { knowledgeContext: retrievedContext ?? "", contextUrls: contextUrls ?? [] }
48
+ : undefined;
49
+ for await (const event of runResearch(query, "Research Session", options)) {
50
+ if (event.node === "__interrupt__") {
51
+ // Surface HITL interrupt to the client and close the stream.
52
+ send("hitl_waiting", event);
53
+ return;
54
+ }
55
+ send("research", event);
56
+ }
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ send("error", {
60
+ node: "_error",
61
+ state: {
62
+ status: "failed",
63
+ errorMessage: message,
64
+ },
65
+ });
66
+ } finally {
67
+ controller.close();
68
+ }
69
+ },
70
+ });
71
+
72
+ return new Response(stream, {
73
+ headers: {
74
+ "Content-Type": "text/event-stream; charset=utf-8",
75
+ "Cache-Control": "no-cache, no-transform",
76
+ Connection: "keep-alive",
77
+ "X-Accel-Buffering": "no",
78
+ },
79
+ });
80
+ }
app/favicon.ico ADDED
app/globals.css ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --background: #0a0a0a;
18
+ --foreground: #ededed;
19
+ }
20
+ }
21
+
22
+ body {
23
+ background: var(--background);
24
+ color: var(--foreground);
25
+ font-family: Arial, Helvetica, sans-serif;
26
+ }
app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "Create Next App",
17
+ description: "Generated by create next app",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en">
27
+ <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
+ >
30
+ {children}
31
+ </body>
32
+ </html>
33
+ );
34
+ }
app/page.tsx ADDED
@@ -0,0 +1,1120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+
5
+ interface ModelProgress {
6
+ status: "idle" | "loading" | "downloading" | "ready" | "error";
7
+ progress: number;
8
+ file: string;
9
+ message: string;
10
+ modelId?: string;
11
+ dtype?: string;
12
+ models?: ModelOption[];
13
+ }
14
+
15
+ interface ModelOption {
16
+ id: string;
17
+ label: string;
18
+ dtype?: "q4" | "fp16" | "fp32";
19
+ sizeNote?: string;
20
+ }
21
+
22
+ interface ResearchEvent {
23
+ node: string;
24
+ state: {
25
+ threadId?: string;
26
+ interrupt?: unknown;
27
+ status?: string;
28
+ plan?: {
29
+ objective: string;
30
+ steps: Array<{ tool: string; input: string; rationale?: string }>;
31
+ };
32
+ auditResult?: {
33
+ verdict: "approved" | "rejected" | "needs_revision";
34
+ policyViolations?: string[];
35
+ suggestions?: string[];
36
+ };
37
+ finalReport?: string;
38
+ reasoning?: Array<{ node: string; summary: string }>;
39
+ errorMessage?: string;
40
+ };
41
+ }
42
+
43
+ type ChatRole = "user" | "assistant";
44
+
45
+ interface ChatMessage {
46
+ id: string;
47
+ role: ChatRole;
48
+ content: string;
49
+ isStreaming?: boolean;
50
+ }
51
+
52
+ type KnowledgeItemType = "file" | "url" | "note";
53
+
54
+ interface KnowledgeItem {
55
+ id: string;
56
+ type: KnowledgeItemType;
57
+ label: string;
58
+ meta?: string;
59
+ status?: "pending" | "indexing" | "indexed" | "error";
60
+ }
61
+
62
+ const DEFAULT_MODELS: ModelOption[] = [
63
+ {
64
+ id: "HuggingFaceTB/SmolLM2-135M-Instruct",
65
+ label: "SmolLM2 135M (Q4, tiny)",
66
+ dtype: "q4",
67
+ sizeNote: "~150–200 MB (approx)",
68
+ },
69
+ {
70
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
71
+ label: "SmolLM2 360M (Q4)",
72
+ dtype: "q4",
73
+ sizeNote: "~388 MB",
74
+ },
75
+ {
76
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
77
+ label: "SmolLM2 360M (FP16)",
78
+ dtype: "fp16",
79
+ sizeNote: "~725 MB",
80
+ },
81
+ {
82
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
83
+ label: "SmolLM2 360M (full)",
84
+ dtype: "fp32",
85
+ sizeNote: "~1.45 GB",
86
+ },
87
+ ];
88
+
89
+ const QUICK_ACTIONS = [
90
+ "Create image-ready research brief",
91
+ "Help me learn this topic",
92
+ "Summarize these docs for me",
93
+ "Audit my assumptions",
94
+ "Turn this into an implementation plan",
95
+ ];
96
+
97
+ const PREVIEW_QUERIES = [
98
+ "How does Gemini Pro work for deep research workflows?",
99
+ "Design a learning plan for mastering LangGraph in 30 days.",
100
+ "Compare self-hosted vs managed LLM stacks for a fintech startup.",
101
+ ];
102
+
103
+ export default function DeepTrustWorkspace() {
104
+ const [modelStatus, setModelStatus] = useState<ModelProgress>({
105
+ status: "idle",
106
+ progress: 0,
107
+ file: "",
108
+ message: "Model not loaded",
109
+ });
110
+ const [selectedModelIndex, setSelectedModelIndex] = useState(0);
111
+ const models = modelStatus.models?.length ? modelStatus.models : DEFAULT_MODELS;
112
+ const selectedModel = models[selectedModelIndex] ?? null;
113
+
114
+ const [query, setQuery] = useState(PREVIEW_QUERIES[0]);
115
+ const [chat, setChat] = useState<ChatMessage[]>([]);
116
+ const [events, setEvents] = useState<ResearchEvent[]>([]);
117
+ const [hitlThreadId, setHitlThreadId] = useState<string | null>(null);
118
+ const [hitlPayload, setHitlPayload] = useState<unknown | null>(null);
119
+ const [error, setError] = useState<string | null>(null);
120
+ const [isStreaming, setIsStreaming] = useState(false);
121
+ const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeItem[]>([]);
122
+ const [isDragOver, setIsDragOver] = useState(false);
123
+ const [noteDraft, setNoteDraft] = useState("");
124
+ const [urlDraft, setUrlDraft] = useState("");
125
+
126
+ const streamAbortRef = useRef<AbortController | null>(null);
127
+ const streamingTargetRef = useRef<string | null>(null);
128
+ const streamingTimerRef = useRef<number | null>(null);
129
+ const pendingFullTextRef = useRef<string | null>(null);
130
+
131
+ const chatScrollRef = useRef<HTMLDivElement | null>(null);
132
+
133
+ useEffect(() => {
134
+ fetch("/api/model/load", { method: "POST" })
135
+ .then((res) => res.json())
136
+ .then((data: ModelProgress) => {
137
+ setModelStatus((prev) => ({ ...prev, ...data }));
138
+ if (data.models?.length && selectedModelIndex >= data.models.length) {
139
+ setSelectedModelIndex(0);
140
+ }
141
+ })
142
+ .catch(() => {});
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, []);
145
+
146
+ const hydrateKnowledge = useCallback(() => {
147
+ import("@/lib/knowledge")
148
+ .then(({ listKnowledgeItems }) => listKnowledgeItems())
149
+ .then((items) => setKnowledgeItems(items))
150
+ .catch(() => {});
151
+ }, []);
152
+ useEffect(() => {
153
+ hydrateKnowledge();
154
+ }, [hydrateKnowledge]);
155
+
156
+ const startStreamingAnimation = useCallback((fullText: string, messageId: string) => {
157
+ if (!fullText) return;
158
+ if (streamingTimerRef.current) {
159
+ window.clearInterval(streamingTimerRef.current);
160
+ }
161
+
162
+ const words = fullText.split(/\s+/);
163
+ let index = 0;
164
+
165
+ streamingTargetRef.current = messageId;
166
+
167
+ setChat((prev) =>
168
+ prev.map((m) =>
169
+ m.id === messageId
170
+ ? {
171
+ ...m,
172
+ content: "",
173
+ isStreaming: true,
174
+ }
175
+ : m
176
+ )
177
+ );
178
+
179
+ const timer = window.setInterval(() => {
180
+ index += 1;
181
+ const nextContent = words.slice(0, index).join(" ");
182
+
183
+ setChat((prev) =>
184
+ prev.map((m) =>
185
+ m.id === messageId
186
+ ? {
187
+ ...m,
188
+ content: nextContent,
189
+ }
190
+ : m
191
+ )
192
+ );
193
+
194
+ if (index >= words.length) {
195
+ if (streamingTimerRef.current) {
196
+ window.clearInterval(streamingTimerRef.current);
197
+ }
198
+ streamingTimerRef.current = null;
199
+ pendingFullTextRef.current = null;
200
+ setChat((prev) =>
201
+ prev.map((m) =>
202
+ m.id === messageId
203
+ ? {
204
+ ...m,
205
+ isStreaming: false,
206
+ }
207
+ : m
208
+ )
209
+ );
210
+ }
211
+ }, 40);
212
+
213
+ streamingTimerRef.current = timer;
214
+ }, []);
215
+
216
+ useEffect(() => {
217
+ if (chatScrollRef.current) {
218
+ chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
219
+ }
220
+ }, [chat]);
221
+
222
+ const isModelReady =
223
+ modelStatus.status === "ready" &&
224
+ selectedModel &&
225
+ modelStatus.modelId === selectedModel.id &&
226
+ modelStatus.dtype === selectedModel.dtype;
227
+
228
+ const isModelLoading =
229
+ modelStatus.status === "loading" || modelStatus.status === "downloading";
230
+
231
+ const loadModel = useCallback(async () => {
232
+ if (!selectedModel) return;
233
+ setModelStatus((prev) => ({ ...prev, status: "loading", message: "Starting..." }));
234
+
235
+ try {
236
+ const params = new URLSearchParams({ modelId: selectedModel.id });
237
+ if (selectedModel.dtype) params.set("dtype", selectedModel.dtype);
238
+ const response = await fetch(`/api/model/load?${params.toString()}`);
239
+ const reader = response.body?.getReader();
240
+ if (!reader) throw new Error("No response body");
241
+
242
+ const decoder = new TextDecoder();
243
+ let buffer = "";
244
+
245
+ while (true) {
246
+ const { done, value } = await reader.read();
247
+ if (done) break;
248
+
249
+ buffer += decoder.decode(value, { stream: true });
250
+ const chunks = buffer.split("\n\n");
251
+ buffer = chunks.pop() || "";
252
+
253
+ for (const chunk of chunks) {
254
+ if (chunk.startsWith("data: ")) {
255
+ try {
256
+ const data = JSON.parse(chunk.slice(6)) as ModelProgress;
257
+ setModelStatus((prev) => ({ ...prev, ...data }));
258
+ } catch {
259
+ // ignore malformed progress chunks
260
+ }
261
+ }
262
+ }
263
+ }
264
+ } catch (err) {
265
+ setModelStatus((prev) => ({
266
+ ...prev,
267
+ status: "error",
268
+ message: err instanceof Error ? err.message : "Failed to load model",
269
+ }));
270
+ }
271
+ }, [selectedModel]);
272
+
273
+ const registerKnowledgeFiles = useCallback((files: FileList | null) => {
274
+ if (!files || files.length === 0) return;
275
+ const pdfs = Array.from(files).filter((f) => f.type === "application/pdf");
276
+ import("@/lib/knowledge").then(({ addPdfFile }) => {
277
+ for (const file of pdfs) {
278
+ const tempId = `temp-${file.name}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
279
+ setKnowledgeItems((prev) => [
280
+ ...prev,
281
+ { id: tempId, type: "file" as KnowledgeItemType, label: file.name, meta: "Indexing…", status: "indexing" as const },
282
+ ]);
283
+ addPdfFile(file)
284
+ .then((meta: KnowledgeItem) => {
285
+ setKnowledgeItems((prev) => prev.map((x) => (x.id === tempId ? { ...meta, status: "indexed" as const } : x)));
286
+ })
287
+ .catch(() => {
288
+ setKnowledgeItems((prev) => prev.map((x) => (x.id === tempId ? { ...x, status: "error" as const, meta: "Failed" } : x)));
289
+ });
290
+ }
291
+ });
292
+ }, []);
293
+
294
+ const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
295
+ event.preventDefault();
296
+ event.stopPropagation();
297
+ setIsDragOver(false);
298
+
299
+ if (event.dataTransfer.files?.length) registerKnowledgeFiles(event.dataTransfer.files);
300
+
301
+ const urlPayload =
302
+ event.dataTransfer.getData("text/uri-list") ||
303
+ event.dataTransfer.getData("text/plain");
304
+
305
+ if (urlPayload && /^https?:\/\//i.test(urlPayload.trim())) {
306
+ const url = urlPayload.trim();
307
+ import("@/lib/knowledge")
308
+ .then(({ addUrl }) => addUrl(url))
309
+ .then((meta) => setKnowledgeItems((prev) => [...prev, meta]))
310
+ .catch(() => {});
311
+ }
312
+ };
313
+
314
+ const handleDragOver: React.DragEventHandler<HTMLDivElement> = (event) => {
315
+ event.preventDefault();
316
+ event.stopPropagation();
317
+ setIsDragOver(true);
318
+ };
319
+
320
+ const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
321
+ event.preventDefault();
322
+ event.stopPropagation();
323
+ setIsDragOver(false);
324
+ };
325
+
326
+ const handleAddNote = () => {
327
+ const value = noteDraft.trim();
328
+ if (!value) return;
329
+ import("@/lib/knowledge")
330
+ .then(({ addNote }) => addNote(value))
331
+ .then((meta) => {
332
+ setKnowledgeItems((prev) => [...prev, meta]);
333
+ setNoteDraft("");
334
+ })
335
+ .catch(() => {});
336
+ };
337
+
338
+ const handleAddUrl = () => {
339
+ const value = urlDraft.trim();
340
+ if (!value) return;
341
+ import("@/lib/knowledge")
342
+ .then(({ addUrl }) => addUrl(value))
343
+ .then((meta) => {
344
+ setKnowledgeItems((prev) => [...prev, meta]);
345
+ setUrlDraft("");
346
+ })
347
+ .catch(() => {});
348
+ };
349
+
350
+ const handleRemoveKnowledgeItem = (id: string) => {
351
+ import("@/lib/knowledge")
352
+ .then(({ removeKnowledgeDocument }) => removeKnowledgeDocument(id))
353
+ .then(() => setKnowledgeItems((prev) => prev.filter((x) => x.id !== id)))
354
+ .catch(() => {});
355
+ };
356
+
357
+ const resetStreaming = () => {
358
+ if (streamAbortRef.current) {
359
+ streamAbortRef.current.abort();
360
+ streamAbortRef.current = null;
361
+ }
362
+ if (streamingTimerRef.current) {
363
+ window.clearInterval(streamingTimerRef.current);
364
+ streamingTimerRef.current = null;
365
+ }
366
+ pendingFullTextRef.current = null;
367
+ streamingTargetRef.current = null;
368
+ setIsStreaming(false);
369
+ };
370
+
371
+ const runResearch = useCallback(
372
+ async (promptOverride?: string) => {
373
+ const nextQuery = (promptOverride ?? query).trim();
374
+ if (!nextQuery || !isModelReady || isStreaming) return;
375
+
376
+ if (streamAbortRef.current) {
377
+ streamAbortRef.current.abort();
378
+ }
379
+
380
+ const controller = new AbortController();
381
+ streamAbortRef.current = controller;
382
+
383
+ setError(null);
384
+ setEvents([]);
385
+ setIsStreaming(true);
386
+
387
+ const userMessage: ChatMessage = {
388
+ id: `user-${Date.now()}`,
389
+ role: "user",
390
+ content: nextQuery,
391
+ };
392
+ const assistantMessage: ChatMessage = {
393
+ id: `assistant-${Date.now()}`,
394
+ role: "assistant",
395
+ content: "",
396
+ isStreaming: true,
397
+ };
398
+
399
+ setChat((prev) => [...prev, userMessage, assistantMessage]);
400
+
401
+ try {
402
+ let retrievedContext = "";
403
+ let contextUrls: string[] = [];
404
+ if (knowledgeItems.length > 0) {
405
+ const { retrieve } = await import("@/lib/knowledge");
406
+ const result = await retrieve(nextQuery);
407
+ retrievedContext = result.retrievedContext;
408
+ contextUrls = result.contextUrls;
409
+ }
410
+ const response = await fetch("/api/research", {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify({
414
+ query: nextQuery,
415
+ retrievedContext,
416
+ contextUrls,
417
+ }),
418
+ signal: controller.signal,
419
+ });
420
+
421
+ if (!response.ok) {
422
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
423
+ }
424
+
425
+ const reader = response.body?.getReader();
426
+ if (!reader) {
427
+ throw new Error("No response body");
428
+ }
429
+
430
+ const decoder = new TextDecoder();
431
+ let buffer = "";
432
+ let hadError = false;
433
+ const assistantId = assistantMessage.id;
434
+
435
+ while (true) {
436
+ const { done, value } = await reader.read();
437
+ if (done) break;
438
+
439
+ buffer += decoder.decode(value, { stream: true });
440
+ const eventsRaw = buffer.split("\n\n");
441
+ buffer = eventsRaw.pop() || "";
442
+
443
+ for (const raw of eventsRaw) {
444
+ const lines = raw.split("\n");
445
+ let eventType = "message";
446
+ let dataLine = "";
447
+ for (const line of lines) {
448
+ if (line.startsWith("event: ")) {
449
+ eventType = line.slice(7);
450
+ }
451
+ if (line.startsWith("data: ")) {
452
+ dataLine = line.slice(6);
453
+ }
454
+ }
455
+
456
+ if (!dataLine) continue;
457
+
458
+ try {
459
+ const parsed = JSON.parse(dataLine) as ResearchEvent;
460
+ if (eventType === "hitl_waiting" && parsed.node === "__interrupt__") {
461
+ setHitlThreadId(parsed.state.threadId ?? null);
462
+ setHitlPayload(parsed.state.interrupt ?? null);
463
+ setIsStreaming(false);
464
+ continue;
465
+ }
466
+
467
+ setEvents((prev) => [...prev, parsed]);
468
+
469
+ if (parsed.node === "_error" || parsed.state.status === "failed") {
470
+ const message =
471
+ parsed.state.errorMessage ?? "Research failed. See server logs for details.";
472
+ setError(message);
473
+ hadError = true;
474
+ resetStreaming();
475
+ }
476
+
477
+ if (parsed.state.finalReport && !hadError) {
478
+ pendingFullTextRef.current = parsed.state.finalReport;
479
+ }
480
+
481
+ if (pendingFullTextRef.current && !streamingTimerRef.current) {
482
+ startStreamingAnimation(pendingFullTextRef.current, assistantId);
483
+ }
484
+ } catch (e) {
485
+ console.warn("Failed to parse SSE event:", e);
486
+ }
487
+ }
488
+ }
489
+
490
+ if (!hadError) {
491
+ setIsStreaming(false);
492
+ }
493
+ } catch (err) {
494
+ if ((err as Error).name === "AbortError") {
495
+ return;
496
+ }
497
+
498
+ setError(err instanceof Error ? err.message : "Unknown error");
499
+ resetStreaming();
500
+ }
501
+ },
502
+ [
503
+ knowledgeItems,
504
+ isModelReady,
505
+ isStreaming,
506
+ query,
507
+ startStreamingAnimation,
508
+ resetStreaming,
509
+ ]
510
+ );
511
+
512
+ const handleQuickAction = (template: string) => {
513
+ const composed = query ? `${query}\n\n${template}` : template;
514
+ setQuery(composed);
515
+ void runResearch(composed);
516
+ };
517
+
518
+ const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
519
+ event.preventDefault();
520
+ void runResearch();
521
+ };
522
+
523
+ const isInputDisabled = !isModelReady || isStreaming;
524
+
525
+ const handleApprovePlan = useCallback(async () => {
526
+ if (!hitlThreadId) return;
527
+
528
+ setIsStreaming(true);
529
+ setError(null);
530
+
531
+ const assistantMessage: ChatMessage = {
532
+ id: `assistant-${Date.now()}`,
533
+ role: "assistant",
534
+ content: "",
535
+ isStreaming: true,
536
+ };
537
+ setChat((prev) => [...prev, assistantMessage]);
538
+
539
+ try {
540
+ const response = await fetch("/api/research/approve", {
541
+ method: "POST",
542
+ headers: { "Content-Type": "application/json" },
543
+ body: JSON.stringify({ threadId: hitlThreadId }),
544
+ });
545
+
546
+ if (!response.ok) {
547
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
548
+ }
549
+
550
+ const reader = response.body?.getReader();
551
+ if (!reader) {
552
+ throw new Error("No response body");
553
+ }
554
+
555
+ const decoder = new TextDecoder();
556
+ let buffer = "";
557
+ let hadError = false;
558
+ const assistantId = assistantMessage.id;
559
+
560
+ while (true) {
561
+ const { done, value } = await reader.read();
562
+ if (done) break;
563
+
564
+ buffer += decoder.decode(value, { stream: true });
565
+ const eventsRaw = buffer.split("\n\n");
566
+ buffer = eventsRaw.pop() || "";
567
+
568
+ for (const raw of eventsRaw) {
569
+ const lines = raw.split("\n");
570
+ let eventType = "message";
571
+ let dataLine = "";
572
+ for (const line of lines) {
573
+ if (line.startsWith("event: ")) {
574
+ eventType = line.slice(7);
575
+ }
576
+ if (line.startsWith("data: ")) {
577
+ dataLine = line.slice(6);
578
+ }
579
+ }
580
+
581
+ if (!dataLine) continue;
582
+
583
+ try {
584
+ const parsed = JSON.parse(dataLine) as ResearchEvent;
585
+ if (eventType === "error" || parsed.node === "_error" || parsed.state.status === "failed") {
586
+ const message =
587
+ parsed.state.errorMessage ?? "Research failed after approval. See server logs for details.";
588
+ setError(message);
589
+ hadError = true;
590
+ resetStreaming();
591
+ }
592
+
593
+ setEvents((prev) => [...prev, parsed]);
594
+
595
+ if (parsed.state.finalReport && !hadError) {
596
+ pendingFullTextRef.current = parsed.state.finalReport;
597
+ }
598
+
599
+ if (pendingFullTextRef.current && !streamingTimerRef.current) {
600
+ startStreamingAnimation(pendingFullTextRef.current, assistantId);
601
+ }
602
+ } catch (e) {
603
+ console.warn("Failed to parse SSE event (approve):", e);
604
+ }
605
+ }
606
+ }
607
+
608
+ if (!hadError) {
609
+ setIsStreaming(false);
610
+ }
611
+ } catch (err) {
612
+ setError(err instanceof Error ? err.message : "Unknown error during approval");
613
+ resetStreaming();
614
+ } finally {
615
+ setHitlThreadId(null);
616
+ setHitlPayload(null);
617
+ }
618
+ }, [
619
+ hitlThreadId,
620
+ resetStreaming,
621
+ startStreamingAnimation,
622
+ ]);
623
+
624
+ const handleRejectPlan = useCallback(() => {
625
+ setHitlThreadId(null);
626
+ setHitlPayload(null);
627
+ setIsStreaming(false);
628
+ setChat((prev) => [
629
+ ...prev,
630
+ {
631
+ id: `assistant-${Date.now()}`,
632
+ role: "assistant",
633
+ content:
634
+ "The proposed plan was rejected. Please refine your question or constraints, then try again.",
635
+ },
636
+ ]);
637
+ }, []);
638
+
639
+ return (
640
+ <div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
641
+ <main className="flex-1 flex flex-col lg:flex-row gap-6 px-4 sm:px-8 py-6 max-w-6xl mx-auto w-full">
642
+ <section className="flex-1 flex flex-col border border-zinc-900/80 bg-zinc-950/60 rounded-3xl shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
643
+ <header className="px-6 pt-5 pb-4 border-b border-zinc-900/80 flex items-center justify-between">
644
+ <div>
645
+ <p className="text-sm font-medium text-zinc-400">Hi there</p>
646
+ <h2 className="text-2xl sm:text-3xl font-semibold tracking-tight">
647
+ Where should we start?
648
+ </h2>
649
+ </div>
650
+ <div
651
+ className={`px-3 py-1 rounded-full text-xs font-medium border ${
652
+ isModelReady
653
+ ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
654
+ : isModelLoading
655
+ ? "border-sky-500/40 bg-sky-500/10 text-sky-300"
656
+ : modelStatus.status === "error"
657
+ ? "border-red-500/40 bg-red-500/10 text-red-300"
658
+ : "border-zinc-700 bg-zinc-900 text-zinc-400"
659
+ }`}
660
+ >
661
+ {isModelReady && "Model ready"}
662
+ {isModelLoading && "Loading model…"}
663
+ {modelStatus.status === "error" && "Model error"}
664
+ {modelStatus.status === "idle" && "Model not loaded"}
665
+ </div>
666
+ </header>
667
+
668
+ <div className="flex-1 flex flex-col">
669
+ <div
670
+ ref={chatScrollRef}
671
+ className="flex-1 px-6 py-4 space-y-4 overflow-y-auto scrollbar-thin scrollbar-thumb-zinc-800 scrollbar-track-transparent"
672
+ >
673
+ {chat.length === 0 && (
674
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
675
+ {PREVIEW_QUERIES.map((example) => (
676
+ <button
677
+ key={example}
678
+ type="button"
679
+ disabled={!isModelReady}
680
+ onClick={() => void runResearch(example)}
681
+ className="group text-left rounded-2xl border border-zinc-800 bg-zinc-900/60 px-4 py-3 text-sm text-zinc-300 hover:border-zinc-500/80 hover:bg-zinc-900 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
682
+ >
683
+ <span className="block font-medium text-zinc-100 mb-1">
684
+ Try this
685
+ </span>
686
+ <span className="block text-zinc-400 text-xs line-clamp-3">
687
+ {example}
688
+ </span>
689
+ </button>
690
+ ))}
691
+ </div>
692
+ )}
693
+
694
+ {chat.map((message) => (
695
+ <div
696
+ key={message.id}
697
+ className={`flex ${
698
+ message.role === "user" ? "justify-end" : "justify-start"
699
+ }`}
700
+ >
701
+ <div
702
+ className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-sm ${
703
+ message.role === "user"
704
+ ? "bg-zinc-100 text-zinc-900"
705
+ : "bg-zinc-900 text-zinc-100 border border-zinc-800"
706
+ }`}
707
+ >
708
+ {message.content || (message.isStreaming && (
709
+ <div className="space-y-2">
710
+ <div className="h-3 rounded-full bg-zinc-700/60 animate-pulse" />
711
+ <div className="h-3 w-2/3 rounded-full bg-zinc-800/60 animate-pulse" />
712
+ </div>
713
+ ))}
714
+ </div>
715
+ </div>
716
+ ))}
717
+ </div>
718
+
719
+ <div className="px-4 pb-4 pt-2 border-t border-zinc-900/80">
720
+ <form
721
+ onSubmit={handleSubmit}
722
+ className="flex flex-col gap-3 rounded-2xl bg-zinc-950/80 border border-zinc-800 px-3 pt-2.5 pb-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]"
723
+ >
724
+ <textarea
725
+ rows={2}
726
+ value={query}
727
+ onChange={(event) => setQuery(event.target.value)}
728
+ disabled={isInputDisabled}
729
+ placeholder="Ask DeepTrust anything about your code, docs, or ideas…"
730
+ className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500 text-zinc-100"
731
+ />
732
+ <div className="flex items-center justify-between gap-2">
733
+ <div className="flex flex-wrap gap-1.5">
734
+ {QUICK_ACTIONS.map((action) => (
735
+ <button
736
+ key={action}
737
+ type="button"
738
+ disabled={isInputDisabled}
739
+ onClick={() => handleQuickAction(action)}
740
+ className="rounded-full border border-zinc-800 bg-zinc-900/70 px-3 py-1 text-[11px] text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
741
+ >
742
+ {action}
743
+ </button>
744
+ ))}
745
+ </div>
746
+ <div className="flex items-center gap-2">
747
+ {isStreaming && (
748
+ <button
749
+ type="button"
750
+ onClick={resetStreaming}
751
+ className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded-full border border-zinc-800"
752
+ >
753
+ Stop
754
+ </button>
755
+ )}
756
+ <button
757
+ type="submit"
758
+ disabled={isInputDisabled}
759
+ className="inline-flex items-center justify-center rounded-full bg-zinc-100 text-zinc-950 text-xs font-medium px-4 py-2 hover:bg-white transition-colors disabled:bg-zinc-700 disabled:text-zinc-300 disabled:cursor-not-allowed"
760
+ >
761
+ {isStreaming ? "Thinking…" : "Run"}
762
+ </button>
763
+ </div>
764
+ </div>
765
+ </form>
766
+ {error && (
767
+ <p className="mt-2 text-xs text-red-400 border border-red-900/70 bg-red-950/60 rounded-xl px-3 py-2">
768
+ {error}
769
+ </p>
770
+ )}
771
+ </div>
772
+ </div>
773
+ </section>
774
+
775
+ <aside className="w-full lg:w-[320px] flex flex-col gap-4">
776
+ <div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-4 space-y-3 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
777
+ <div className="flex items-center justify-between">
778
+ <div>
779
+ <p className="text-xs uppercase tracking-wide text-zinc-500">
780
+ Model
781
+ </p>
782
+ <p className="text-sm font-medium">
783
+ {modelStatus.modelId || "Select a model"}
784
+ </p>
785
+ </div>
786
+ <span
787
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium border ${
788
+ isModelReady
789
+ ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
790
+ : isModelLoading
791
+ ? "border-sky-500/40 bg-sky-500/10 text-sky-300"
792
+ : modelStatus.status === "error"
793
+ ? "border-red-500/40 bg-red-500/10 text-red-300"
794
+ : "border-zinc-700 bg-zinc-900 text-zinc-400"
795
+ }`}
796
+ >
797
+ {isModelReady && "Ready"}
798
+ {isModelLoading && "Loading…"}
799
+ {modelStatus.status === "error" && "Error"}
800
+ {modelStatus.status === "idle" && "Not loaded"}
801
+ </span>
802
+ </div>
803
+
804
+ {models.length > 0 && (
805
+ <div>
806
+ <select
807
+ value={selectedModelIndex}
808
+ onChange={(event) => setSelectedModelIndex(Number(event.target.value))}
809
+ disabled={isModelLoading}
810
+ className="w-full mt-1 rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500 disabled:opacity-50 disabled:cursor-not-allowed"
811
+ >
812
+ {models.map((model, index) => (
813
+ <option
814
+ key={`${model.id}-${model.dtype ?? "default"}`}
815
+ value={index}
816
+ >
817
+ {model.label} {model.sizeNote ? `— ${model.sizeNote}` : ""}
818
+ </option>
819
+ ))}
820
+ </select>
821
+ </div>
822
+ )}
823
+
824
+ {isModelLoading && (
825
+ <div className="space-y-1.5">
826
+ <div className="flex justify-between text-[11px] text-zinc-400">
827
+ <span>{modelStatus.message}</span>
828
+ <span>{modelStatus.progress}%</span>
829
+ </div>
830
+ <div className="h-1.5 rounded-full bg-zinc-900 overflow-hidden">
831
+ <div
832
+ className="h-full bg-sky-500 transition-all duration-300"
833
+ style={{ width: `${modelStatus.progress}%` }}
834
+ />
835
+ </div>
836
+ </div>
837
+ )}
838
+
839
+ {!isModelReady && !isModelLoading && (
840
+ <button
841
+ type="button"
842
+ onClick={loadModel}
843
+ className="w-full inline-flex items-center justify-center text-xs font-medium rounded-xl bg-zinc-100 text-zinc-950 py-2.5 hover:bg-white transition-colors"
844
+ >
845
+ Load model
846
+ </button>
847
+ )}
848
+
849
+ {modelStatus.status === "error" && (
850
+ <button
851
+ type="button"
852
+ onClick={loadModel}
853
+ className="w-full text-[11px] text-red-300 underline underline-offset-2 hover:no-underline text-left"
854
+ >
855
+ Retry loading
856
+ </button>
857
+ )}
858
+ </div>
859
+
860
+ <div
861
+ onDrop={handleDrop}
862
+ onDragOver={handleDragOver}
863
+ onDragLeave={handleDragLeave}
864
+ className={`rounded-3xl border-2 border-dashed px-4 py-4 space-y-3 transition-colors cursor-pointer ${
865
+ isDragOver
866
+ ? "border-zinc-200 bg-zinc-900/80"
867
+ : "border-zinc-700/80 bg-zinc-950/60"
868
+ }`}
869
+ >
870
+ <div className="flex items-center justify-between gap-2">
871
+ <div>
872
+ <p className="text-xs uppercase tracking-wide text-zinc-500">
873
+ Context
874
+ </p>
875
+ <p className="text-sm font-medium">
876
+ Drop PDFs, notes, or URLs
877
+ </p>
878
+ </div>
879
+ </div>
880
+ <p className="text-xs text-zinc-500">
881
+ Drag files or links here. DeepTrust will use them as additional
882
+ knowledge when answering.
883
+ </p>
884
+
885
+ <div className="flex gap-2">
886
+ <label className="inline-flex cursor-pointer rounded-xl border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-[11px] text-zinc-200 hover:bg-zinc-800 transition-colors">
887
+ Attach files
888
+ <input
889
+ type="file"
890
+ accept="application/pdf"
891
+ multiple
892
+ className="hidden"
893
+ onChange={(event) => registerKnowledgeFiles(event.target.files)}
894
+ />
895
+ </label>
896
+ </div>
897
+
898
+ <div className="space-y-2 pt-1">
899
+ <div className="flex gap-2">
900
+ <input
901
+ type="text"
902
+ value={urlDraft}
903
+ onChange={(event) => setUrlDraft(event.target.value)}
904
+ placeholder="Paste URL"
905
+ className="flex-1 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
906
+ />
907
+ <button
908
+ type="button"
909
+ onClick={handleAddUrl}
910
+ className="rounded-xl border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-[11px] text-zinc-200 hover:bg-zinc-800"
911
+ >
912
+ Add
913
+ </button>
914
+ </div>
915
+ <div className="flex gap-2">
916
+ <input
917
+ type="text"
918
+ value={noteDraft}
919
+ onChange={(event) => setNoteDraft(event.target.value)}
920
+ placeholder="Short note or hint"
921
+ className="flex-1 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
922
+ />
923
+ <button
924
+ type="button"
925
+ onClick={handleAddNote}
926
+ className="rounded-xl border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-[11px] text-zinc-200 hover:bg-zinc-800"
927
+ >
928
+ Save
929
+ </button>
930
+ </div>
931
+ </div>
932
+
933
+ {knowledgeItems.length > 0 && (
934
+ <div className="max-h-40 overflow-y-auto space-y-1 pt-1">
935
+ {knowledgeItems.map((item) => (
936
+ <div
937
+ key={item.id}
938
+ className="flex items-center justify-between gap-2 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-200"
939
+ >
940
+ <span className="truncate min-w-0">
941
+ {item.type === "file" && "📄 "}
942
+ {item.type === "url" && "🔗 "}
943
+ {item.type === "note" && "✏️ "}
944
+ {item.label}
945
+ </span>
946
+ <span className="flex items-center gap-1.5 shrink-0">
947
+ {item.meta && (
948
+ <span className="text-[10px] text-zinc-500">
949
+ {item.meta}
950
+ </span>
951
+ )}
952
+ <button
953
+ type="button"
954
+ onClick={() => handleRemoveKnowledgeItem(item.id)}
955
+ className="rounded p-0.5 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
956
+ aria-label="Remove"
957
+ >
958
+ ×
959
+ </button>
960
+ </span>
961
+ </div>
962
+ ))}
963
+ </div>
964
+ )}
965
+ </div>
966
+
967
+ {events.length > 0 && (
968
+ <div className="space-y-3">
969
+ {/* HITL approval banner */}
970
+ {hitlThreadId && (
971
+ <div className="rounded-3xl border border-amber-500/40 bg-amber-500/5 p-3 space-y-1.5">
972
+ <p className="text-xs font-medium text-amber-200">
973
+ Human review required
974
+ </p>
975
+ <p className="text-[11px] text-amber-100/90">
976
+ The agent has prepared a plan and is waiting for your approval before executing tools.
977
+ </p>
978
+ <div className="flex items-center gap-2 pt-1">
979
+ <button
980
+ type="button"
981
+ onClick={handleApprovePlan}
982
+ className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-emerald-500 text-emerald-950 hover:bg-emerald-400 transition-colors"
983
+ >
984
+ Approve & run tools
985
+ </button>
986
+ <button
987
+ type="button"
988
+ onClick={handleRejectPlan}
989
+ className="px-3 py-1.5 rounded-full text-[11px] font-medium border border-amber-400/60 text-amber-100 hover:bg-amber-500/10 transition-colors"
990
+ >
991
+ Reject plan
992
+ </button>
993
+ </div>
994
+ </div>
995
+ )}
996
+
997
+ {/* Plan & audit snapshot */}
998
+ {(() => {
999
+ const latestWithPlan = [...events]
1000
+ .reverse()
1001
+ .find((e) => e.state.plan);
1002
+ const latestWithAudit = [...events]
1003
+ .reverse()
1004
+ .find((e) => e.state.auditResult);
1005
+ if (!latestWithPlan && !latestWithAudit) return null;
1006
+
1007
+ const plan = latestWithPlan?.state.plan;
1008
+ const audit = latestWithAudit?.state.auditResult;
1009
+
1010
+ return (
1011
+ <div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-3 space-y-2">
1012
+ <div className="flex items-center justify-between gap-2">
1013
+ <p className="text-xs font-medium text-zinc-400">
1014
+ Plan & audit
1015
+ </p>
1016
+ {audit && (
1017
+ <span
1018
+ className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
1019
+ audit.verdict === "approved"
1020
+ ? "bg-emerald-500/10 text-emerald-300 border border-emerald-500/30"
1021
+ : audit.verdict === "rejected"
1022
+ ? "bg-red-500/10 text-red-300 border border-red-500/30"
1023
+ : "bg-amber-500/10 text-amber-300 border border-amber-500/30"
1024
+ }`}
1025
+ >
1026
+ Audit: {audit.verdict}
1027
+ </span>
1028
+ )}
1029
+ </div>
1030
+ {plan && (
1031
+ <div className="space-y-1.5">
1032
+ <p className="text-[11px] text-zinc-300">
1033
+ {plan.objective}
1034
+ </p>
1035
+ <ol className="space-y-1 max-h-24 overflow-y-auto">
1036
+ {plan.steps.map((step, idx) => (
1037
+ <li
1038
+ key={`${step.tool}-${idx}`}
1039
+ className="text-[11px] text-zinc-400 flex gap-1.5"
1040
+ >
1041
+ <span className="mt-0.5 text-zinc-500">
1042
+ {idx + 1}.
1043
+ </span>
1044
+ <span className="flex-1">
1045
+ <span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500 mr-1">
1046
+ {step.tool}
1047
+ </span>
1048
+ {step.input}
1049
+ </span>
1050
+ </li>
1051
+ ))}
1052
+ </ol>
1053
+ </div>
1054
+ )}
1055
+ {audit?.policyViolations &&
1056
+ audit.policyViolations.length > 0 && (
1057
+ <div className="pt-1 border-t border-zinc-800/80 mt-1">
1058
+ <p className="text-[10px] font-medium text-red-300 mb-0.5">
1059
+ Policy flags
1060
+ </p>
1061
+ <ul className="space-y-0.5">
1062
+ {audit.policyViolations.map((v, i) => (
1063
+ <li
1064
+ key={`${v}-${i}`}
1065
+ className="text-[10px] text-red-200/90"
1066
+ >
1067
+ • {v}
1068
+ </li>
1069
+ ))}
1070
+ </ul>
1071
+ </div>
1072
+ )}
1073
+ </div>
1074
+ );
1075
+ })()}
1076
+
1077
+ {/* Reasoning trace */}
1078
+ <div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-3 space-y-1.5">
1079
+ <p className="text-xs font-medium text-zinc-400 mb-1">
1080
+ Reasoning trace
1081
+ </p>
1082
+ <div className="max-h-40 overflow-y-auto space-y-1.5">
1083
+ {events
1084
+ .filter((event) => event.state.reasoning?.length)
1085
+ .map((event, index) => {
1086
+ const latest =
1087
+ event.state.reasoning?.[
1088
+ event.state.reasoning.length - 1
1089
+ ];
1090
+ if (!latest) return null;
1091
+ return (
1092
+ <div
1093
+ key={`${event.node}-${index}`}
1094
+ className="rounded-2xl bg-zinc-900/80 border border-zinc-800 px-3 py-1.5"
1095
+ >
1096
+ <div className="flex items-center justify-between mb-0.5">
1097
+ <span className="text-[10px] font-mono text-zinc-500">
1098
+ {event.node}
1099
+ </span>
1100
+ {event.state.status && (
1101
+ <span className="text-[10px] text-zinc-500">
1102
+ {event.state.status}
1103
+ </span>
1104
+ )}
1105
+ </div>
1106
+ <p className="text-[11px] text-zinc-300 line-clamp-3">
1107
+ {latest.summary}
1108
+ </p>
1109
+ </div>
1110
+ );
1111
+ })}
1112
+ </div>
1113
+ </div>
1114
+ </div>
1115
+ )}
1116
+ </aside>
1117
+ </main>
1118
+ </div>
1119
+ );
1120
+ }
dist/llm/pipeline.js ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ /**
3
+ * Pipeline — runs inside the LLM worker thread only.
4
+ * Hugging Face Transformers + onnxruntime-node; state lives in this thread.
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.MODELS = exports.MODEL_ID = void 0;
11
+ exports.getModelStatus = getModelStatus;
12
+ exports.loadModel = loadModel;
13
+ exports.chatComplete = chatComplete;
14
+ const transformers_1 = require("@huggingface/transformers");
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ transformers_1.env.cacheDir =
17
+ process.env.HF_CACHE_DIR ||
18
+ node_path_1.default.join(process.cwd(), ".hf-cache");
19
+ // Default to a very small instruct model for low-spec machines.
20
+ // If this model ID does not exist or you prefer another tiny model,
21
+ // set HF_MODEL in your environment to override.
22
+ exports.MODEL_ID = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
23
+ exports.MODELS = [
24
+ {
25
+ id: "HuggingFaceTB/SmolLM2-135M-Instruct",
26
+ label: "SmolLM2 135M (Q4, tiny)",
27
+ dtype: "q4",
28
+ sizeNote: "~150–200 MB (approx)",
29
+ },
30
+ {
31
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
32
+ label: "SmolLM2 360M (Q4)",
33
+ dtype: "q4",
34
+ sizeNote: "~388 MB",
35
+ },
36
+ {
37
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
38
+ label: "SmolLM2 360M (FP16)",
39
+ dtype: "fp16",
40
+ sizeNote: "~725 MB",
41
+ },
42
+ {
43
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
44
+ label: "SmolLM2 360M (full)",
45
+ dtype: "fp32",
46
+ sizeNote: "~1.45 GB",
47
+ },
48
+ ];
49
+ let currentModelId = exports.MODELS[0].id;
50
+ let currentDtype = exports.MODELS[0].dtype;
51
+ let generatorPromise = null;
52
+ let isModelLoaded = false;
53
+ let currentProgress = 0;
54
+ let currentStatus = "idle";
55
+ let currentFile = "";
56
+ function getModelStatus(forModelId, forDtype) {
57
+ const isOther = forModelId !== undefined && (forModelId !== currentModelId || forDtype !== currentDtype);
58
+ if (isOther) {
59
+ return {
60
+ status: "idle",
61
+ progress: 0,
62
+ file: "",
63
+ message: "Model not loaded",
64
+ modelId: forModelId,
65
+ dtype: forDtype,
66
+ };
67
+ }
68
+ if (isModelLoaded) {
69
+ return {
70
+ status: "ready",
71
+ progress: 100,
72
+ file: "",
73
+ message: "Model ready",
74
+ modelId: currentModelId,
75
+ dtype: currentDtype,
76
+ };
77
+ }
78
+ if (generatorPromise) {
79
+ return {
80
+ status: currentStatus,
81
+ progress: currentProgress,
82
+ file: currentFile,
83
+ message: currentFile ? `Downloading ${currentFile}` : "Loading model...",
84
+ modelId: currentModelId,
85
+ dtype: currentDtype,
86
+ };
87
+ }
88
+ return {
89
+ status: "idle",
90
+ progress: 0,
91
+ file: "",
92
+ message: "Model not loaded",
93
+ modelId: currentModelId,
94
+ dtype: currentDtype,
95
+ };
96
+ }
97
+ function loadModel(modelId, dtype, onProgress) {
98
+ console.log("[worker] loadModel called");
99
+ const nextId = modelId ?? currentModelId;
100
+ const nextDtype = dtype ?? currentDtype;
101
+ if (nextId !== currentModelId || nextDtype !== currentDtype) {
102
+ generatorPromise = null;
103
+ isModelLoaded = false;
104
+ currentModelId = nextId;
105
+ currentDtype = nextDtype;
106
+ currentStatus = "idle";
107
+ currentProgress = 0;
108
+ currentFile = "";
109
+ }
110
+ if (generatorPromise) {
111
+ return generatorPromise;
112
+ }
113
+ console.log(`\n🔄 [worker] Loading model: ${currentModelId}${currentDtype ? ` (${currentDtype})` : ""}`);
114
+ console.log(` Cache directory: ${transformers_1.env.cacheDir}\n`);
115
+ currentStatus = "loading";
116
+ const startTime = Date.now();
117
+ const pipelineOptions = {
118
+ progress_callback: (progressData) => {
119
+ currentStatus = progressData.status === "progress" ? "downloading" : "loading";
120
+ currentFile = progressData.file || progressData.name || "";
121
+ currentProgress = Math.round(progressData.progress ?? 0);
122
+ const update = {
123
+ status: currentStatus,
124
+ progress: currentProgress,
125
+ file: currentFile,
126
+ message: currentFile
127
+ ? `Downloading ${currentFile.split("/").pop()} (${currentProgress}%)`
128
+ : `${progressData.status}...`,
129
+ modelId: currentModelId,
130
+ dtype: currentDtype,
131
+ };
132
+ console.log(` ${update.message}`);
133
+ onProgress?.(update);
134
+ },
135
+ };
136
+ if (currentDtype) {
137
+ pipelineOptions.dtype = currentDtype;
138
+ }
139
+ const pipelinePromise = (0, transformers_1.pipeline)("text-generation", currentModelId, pipelineOptions);
140
+ generatorPromise = pipelinePromise
141
+ .then((gen) => {
142
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
143
+ console.log(`\n✅ [worker] Model loaded in ${elapsed}s\n`);
144
+ isModelLoaded = true;
145
+ currentStatus = "ready";
146
+ currentProgress = 100;
147
+ onProgress?.({
148
+ status: "ready",
149
+ progress: 100,
150
+ file: "",
151
+ message: "Model ready",
152
+ modelId: currentModelId,
153
+ dtype: currentDtype,
154
+ });
155
+ return gen;
156
+ })
157
+ .catch((err) => {
158
+ currentStatus = "error";
159
+ generatorPromise = null;
160
+ onProgress?.({
161
+ status: "error",
162
+ progress: 0,
163
+ file: "",
164
+ message: err.message,
165
+ modelId: currentModelId,
166
+ dtype: currentDtype,
167
+ });
168
+ throw err;
169
+ });
170
+ return generatorPromise;
171
+ }
172
+ async function chatComplete(systemPrompt, userMessage) {
173
+ console.log("[worker] chatComplete called");
174
+ const generator = await loadModel();
175
+ const messages = [
176
+ { role: "system", content: systemPrompt },
177
+ { role: "user", content: userMessage },
178
+ ];
179
+ const preview = userMessage.slice(0, 60).replace(/\n/g, " ");
180
+ console.log(`🤖 [worker] Generating response for: "${preview}..."`);
181
+ const startTime = Date.now();
182
+ const output = await generator(messages, {
183
+ max_new_tokens: 512,
184
+ do_sample: true,
185
+ temperature: 0.7,
186
+ });
187
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
188
+ const result = output[0];
189
+ const assistantMessage = result.generated_text.find((msg) => msg.role === "assistant");
190
+ if (!assistantMessage) {
191
+ throw new Error("No assistant response generated");
192
+ }
193
+ console.log(`✅ [worker] Generated ${assistantMessage.content.length} chars in ${elapsed}s`);
194
+ return assistantMessage.content;
195
+ }
dist/llm/worker-entry.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ /**
3
+ * Worker entry — runs in a Node worker_thread. Handles getStatus, load, chat and forwards progress.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_worker_threads_1 = require("node:worker_threads");
7
+ const pipeline_1 = require("./pipeline");
8
+ function reply(msg) {
9
+ node_worker_threads_1.parentPort?.postMessage(msg);
10
+ }
11
+ node_worker_threads_1.parentPort?.on("message", (msg) => {
12
+ const { id, type, payload } = msg;
13
+ (async () => {
14
+ try {
15
+ switch (type) {
16
+ case "getStatus": {
17
+ const status = (0, pipeline_1.getModelStatus)(payload.modelId, payload.dtype);
18
+ reply({ id, type: "resolve", payload: { ...status, models: pipeline_1.MODELS } });
19
+ break;
20
+ }
21
+ case "load": {
22
+ await (0, pipeline_1.loadModel)(payload.modelId, payload.dtype, (progress) => {
23
+ reply({
24
+ id,
25
+ type: "progress",
26
+ payload: { ...progress, models: pipeline_1.MODELS },
27
+ });
28
+ });
29
+ const status = (0, pipeline_1.getModelStatus)(payload.modelId, payload.dtype);
30
+ reply({ id, type: "resolve", payload: { ...status, models: pipeline_1.MODELS } });
31
+ break;
32
+ }
33
+ case "chat": {
34
+ const text = await (0, pipeline_1.chatComplete)(payload.systemPrompt, payload.userMessage);
35
+ reply({ id, type: "resolve", payload: text });
36
+ break;
37
+ }
38
+ default:
39
+ reply({ id, type: "reject", payload: `Unknown message type: ${msg.type}` });
40
+ }
41
+ }
42
+ catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ reply({ id, type: "reject", payload: message });
45
+ }
46
+ })();
47
+ });
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepTrust Architecture
2
+
3
+ This document provides a detailed technical overview of the DeepTrust Research Agent architecture, covering the state machine design, data flow, and implementation patterns.
4
+
5
+ ## System Overview
6
+
7
+ DeepTrust is a research automation system that orchestrates an LLM through a multi-stage workflow. The system decomposes research questions into executable plans, validates them against policy, executes tool calls, and synthesizes results into reports.
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────────────────────┐
11
+ │ FRONTEND (app/page.tsx) │
12
+ │ ┌─────────────┐ ┌─────────────────────────────────────────────────────┐ │
13
+ │ │ Model Card │ │ Chat + Context panel │ │
14
+ │ │ (SSE load) │ │ • Messages (user / assistant with word-by-word) │ │
15
+ │ └──────┬──────┘ │ • Knowledge drop zone (files, URLs, notes) │ │
16
+ │ │ │ • Quick-action chips, preview prompts │ │
17
+ │ │ │ • Reasoning trace (node summaries) │ │
18
+ │ │ └──────────────────────────┬──────────────────────────┘ │
19
+ └─────────┼───────────────────────────────────┼─────────────────────────────┘
20
+ │ │
21
+ ▼ ▼
22
+ ┌─────────────────────────────────────────────────────────────────────────────┐
23
+ │ API LAYER │
24
+ │ ┌─────────────────────┐ ┌─────────────────────────────────────┐ │
25
+ │ │ GET/POST │ │ POST /api/research │ │
26
+ │ │ /api/model/load │ │ (SSE: event + data per research step)│ │
27
+ │ │ (SSE progress) │ └─────────────────┬───────────────────┘ │
28
+ │ └─────────┬───────────┘ │ │
29
+ └────────────┼─────────────────────────────────┼─────────────────────────────┘
30
+ │ │
31
+ ▼ ▼
32
+ ┌─────────────────────────────────────────────────────────────────────────────┐
33
+ │ AGENT CORE (lib/agent) │
34
+ │ ┌─────────────────┐ ┌─────────────────────────────────────────────────┐ │
35
+ │ │ LLM Client │ │ StateGraph │ │
36
+ │ │ (worker thread) │ │ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │ │
37
+ │ │ loadModel() │ │ │ Thinker │──│ Auditor │──│ Tool Executor │ │ │
38
+ │ │ chatComplete() │◄─┼──└────▲────┘ └────┬────┘ └──────┬───────┘ │ │
39
+ │ └─────────────────┘ │ │ │ │ │ │
40
+ │ │ └────────────┘ ▼ │ │
41
+ │ │ ┌───────────────┐ │ │
42
+ │ │ │ Synthesizer │ │ │
43
+ │ │ └───────────────��� │ │
44
+ │ └─────────────────────────────────────────────────┘ │
45
+ └─────────────────────────────────────────────────────────────────────────────┘
46
+ ```
47
+
48
+ ## State Machine Design
49
+
50
+ ### LangGraph StateGraph
51
+
52
+ The agent is implemented as a LangGraph `StateGraph<ResearchState>`. LangGraph provides:
53
+
54
+ 1. **Typed State Channels**: Each state field has a merge strategy
55
+ 2. **Conditional Routing**: Functions determine the next node based on state
56
+ 3. **Checkpointing**: State snapshots enable pause/resume workflows
57
+ 4. **Streaming**: Events are yielded as nodes complete
58
+
59
+ ### State Flow
60
+
61
+ ```
62
+ Initial State
63
+
64
+
65
+ ┌─────────┐
66
+ │ thinker │ ◄──────────────────────────────┐
67
+ └────┬────┘ │
68
+ │ produces plan │
69
+ ▼ │
70
+ ┌─────────┐ │
71
+ │ auditor │ │
72
+ └────┬────┘ │
73
+ │ │
74
+ ├── verdict: "rejected" ──────────────┘
75
+ │ (planRevisionCount++)
76
+
77
+ ├── planRevisionCount >= max ──────► END
78
+
79
+ └── verdict: "approved"
80
+
81
+
82
+ ┌───────────┐
83
+ │ hitl_gate │ ◄─── interrupt() pauses here
84
+ └─────┬─────┘
85
+ │ humanApproved = true (external)
86
+
87
+ ┌───────────────┐
88
+ │ tool_executor │ ◄─────────────┐
89
+ └───────┬───────┘ │
90
+ │ │
91
+ ├── more steps ─────────┘
92
+
93
+ └── all steps done
94
+
95
+
96
+ ┌─────────────┐
97
+ │ synthesizer │
98
+ └──────┬──────┘
99
+
100
+
101
+ END
102
+ ```
103
+
104
+ ### Channel Merge Strategies
105
+
106
+ LangGraph channels define how incoming state updates merge with existing state:
107
+
108
+ | Field | Strategy | Rationale |
109
+ |-------|----------|-----------|
110
+ | `reasoning` | Append | Accumulates full reasoning trace |
111
+ | All others | Replace | Last-write-wins for scalar values |
112
+
113
+ ```typescript
114
+ channels: {
115
+ reasoning: {
116
+ value: (existing, incoming) => [...(existing ?? []), ...(incoming ?? [])],
117
+ default: () => [],
118
+ },
119
+ plan: { value: (_, n) => n },
120
+ // ...
121
+ }
122
+ ```
123
+
124
+ ## Data Schemas
125
+
126
+ ### Type System Philosophy
127
+
128
+ All data structures use Zod for runtime validation. TypeScript types are inferred from Zod schemas, ensuring a single source of truth.
129
+
130
+ ```typescript
131
+ // Schema definition
132
+ export const ResearchStep = z.object({
133
+ id: z.string().uuid(),
134
+ tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
135
+ input: z.string().min(1),
136
+ rationale: z.string(),
137
+ output: z.string().optional(),
138
+ });
139
+
140
+ // Type inference (no manual duplication)
141
+ export type ResearchStep = z.infer<typeof ResearchStep>;
142
+ ```
143
+
144
+ ### Schema Hierarchy
145
+
146
+ ```
147
+ ResearchState (root)
148
+ ├── threadId: UUID
149
+ ├── userQuery: string
150
+ ├── plan: ResearchPlan | null
151
+ │ ├── objective: string
152
+ │ ├── steps: ResearchStep[]
153
+ │ │ ├── id: UUID
154
+ │ │ ├── tool: enum
155
+ │ │ ├── input: string
156
+ │ │ ├── rationale: string
157
+ │ │ └── output?: string
158
+ │ ├── estimatedTokenBudget: number
159
+ │ └── revision: number
160
+ ├── auditResult: AuditResult | null
161
+ │ ├── verdict: enum
162
+ │ ├── policyViolations: string[]
163
+ │ └── suggestions: string[]
164
+ ├── reasoning: ReasoningEntry[]
165
+ │ ├── node: enum
166
+ │ ├── timestamp: datetime
167
+ │ ├── summary: string
168
+ │ └── rawThought?: string
169
+ └── status: enum
170
+ ```
171
+
172
+ ## Node Implementation Patterns
173
+
174
+ ### Node Function Signature
175
+
176
+ All nodes follow the same pattern:
177
+
178
+ ```typescript
179
+ async function nodeName(state: ResearchState): Promise<Partial<ResearchState>> {
180
+ // 1. Read required state
181
+ // 2. Perform computation (LLM calls, tool execution, etc.)
182
+ // 3. Return partial state update
183
+ }
184
+ ```
185
+
186
+ LangGraph merges the returned partial state into the existing state using channel strategies.
187
+
188
+ ### Prompt Engineering Pattern
189
+
190
+ Each LLM-calling node structures prompts for reliable JSON output:
191
+
192
+ ```typescript
193
+ const system = `
194
+ You are the [Role] node of DeepTrust.
195
+ [Brief description of responsibility]
196
+
197
+ Return ONLY a valid JSON object matching:
198
+ {
199
+ "field1": type,
200
+ "field2": type
201
+ }
202
+
203
+ Rules:
204
+ - [Constraint 1]
205
+ - [Constraint 2]
206
+ - Do not include markdown fences or prose outside JSON.
207
+ `;
208
+
209
+ const userMessage = `[Contextual input]`;
210
+ const raw = await chatComplete(system, userMessage);
211
+ const parsed = extractJSON(raw);
212
+ const validated = Schema.parse(parsed);
213
+ ```
214
+
215
+ ### Error Handling Pattern
216
+
217
+ Nodes append to the reasoning trace even on failure, enabling debugging:
218
+
219
+ ```typescript
220
+ async function node(state: ResearchState) {
221
+ try {
222
+ // ... main logic
223
+ return { /* success state */ };
224
+ } catch (error) {
225
+ const reasoning = appendReasoning(state, {
226
+ node: "node_name",
227
+ summary: `Error: ${error.message}`,
228
+ });
229
+ return {
230
+ status: "failed",
231
+ errorMessage: error.message,
232
+ reasoning,
233
+ };
234
+ }
235
+ }
236
+ ```
237
+
238
+ ## Routing Logic
239
+
240
+ ### Conditional Edges
241
+
242
+ LangGraph `addConditionalEdges` accepts a router function that returns the next node name:
243
+
244
+ ```typescript
245
+ graph.addConditionalEdges("auditor", routeAfterAudit, {
246
+ thinker: "thinker",
247
+ hitl_gate: "hitl_gate",
248
+ [END]: END,
249
+ });
250
+ ```
251
+
252
+ The mapping object defines legal transitions. If the router returns a key not in the map, LangGraph throws an error.
253
+
254
+ ### Router Functions
255
+
256
+ Routers are pure functions that inspect state:
257
+
258
+ ```typescript
259
+ function routeAfterAudit(state: ResearchState): "thinker" | "hitl_gate" | typeof END {
260
+ // Safety ceiling check
261
+ if (state.planRevisionCount >= state.maxPlanRevisions) {
262
+ return END;
263
+ }
264
+
265
+ // Rejection triggers revision
266
+ if (state.auditResult?.verdict !== "approved") {
267
+ return "thinker";
268
+ }
269
+
270
+ // Approval proceeds to HITL
271
+ return "hitl_gate";
272
+ }
273
+ ```
274
+
275
+ ## LLM Integration Layer
276
+
277
+ ### Architecture
278
+
279
+ ```
280
+ ┌─────────────────────────────────────────────────────────────┐
281
+ │ llm/index.ts │
282
+ ├─────────────────────────────────────────────────────────────┤
283
+ │ Module-level State │
284
+ │ ├── generatorPromise: Promise<TextGenerationPipeline> │
285
+ │ ├── isModelLoaded: boolean │
286
+ │ └── currentProgress: number │
287
+ ├─────────────────────────────────────────────────────────────┤
288
+ │ Exports │
289
+ │ ├── loadModel(onProgress?) → Promise<Pipeline> │
290
+ │ ├── chatComplete(system, user) → Promise<string> │
291
+ │ ├── getModelStatus() → ModelProgress │
292
+ │ └── MODEL_ID: string │
293
+ └─────────────────────────────────────────────────────────────┘
294
+ ```
295
+
296
+ ### Lazy Loading Pattern
297
+
298
+ The model is loaded once and reused across all requests:
299
+
300
+ ```typescript
301
+ let generatorPromise: Promise<TextGenerationPipeline> | null = null;
302
+
303
+ export function loadModel(): Promise<TextGenerationPipeline> {
304
+ if (generatorPromise) return generatorPromise; // Return cached promise
305
+
306
+ generatorPromise = pipeline("text-generation", MODEL_ID, {
307
+ progress_callback: handleProgress,
308
+ });
309
+
310
+ return generatorPromise;
311
+ }
312
+ ```
313
+
314
+ ### Progress Streaming
315
+
316
+ The Hugging Face Transformers library supports progress callbacks:
317
+
318
+ ```typescript
319
+ pipeline("text-generation", MODEL_ID, {
320
+ progress_callback: (data: { status: string; file?: string; progress?: number }) => {
321
+ // data.status: "download", "progress", "ready"
322
+ // data.file: current file being downloaded
323
+ // data.progress: 0-1 fraction
324
+ },
325
+ });
326
+ ```
327
+
328
+ ## API Design
329
+
330
+ ### Research Streaming: Server-Sent Events (SSE)
331
+
332
+ Research results are streamed as **Server-Sent Events** so the client can distinguish event types and get low-latency, non-blocking updates. The response uses `Content-Type: text/event-stream` and each message has an `event` name plus a `data` payload (JSON).
333
+
334
+ **Why SSE (not NDJSON):** Standard SSE gives a single, well-understood protocol for streaming; event names (`start`, `research`, `error`) allow the client to handle each kind of message without guessing. Proxies and browsers handle SSE well, and we can add `ping`/`heartbeat` later without changing the wire format.
335
+
336
+ **Wire format:**
337
+
338
+ ```
339
+ event: start
340
+ data: {"node":"_start","state":{"status":"started","plan":{...},"reasoning":[...]}}
341
+
342
+ event: research
343
+ data: {"node":"thinker","state":{"status":"thinking","plan":{...}}}
344
+
345
+ event: research
346
+ data: {"node":"auditor","state":{"status":"awaiting_approval","auditResult":{...}}}
347
+
348
+ event: research
349
+ data: {"node":"synthesizer","state":{"status":"complete","finalReport":"..."}}
350
+
351
+ event: error
352
+ data: {"node":"_error","state":{"status":"failed","errorMessage":"..."}}
353
+ ```
354
+
355
+ **Server (route):** The route encodes each event with `event: <name>\ndata: <JSON>\n\n` and enqueues into a `ReadableStream`, then closes the stream when the graph run finishes or throws.
356
+
357
+ **Client:** The client reads the response body with `getReader()`, accumulates chunks, splits on `\n\n` to get full SSE messages, then for each message parses the `event:` line and the `data:` line (JSON). Events of type `research` (and `start`) are appended to the events list; the last event carrying `finalReport` is used to drive the word-by-word streaming animation in the chat.
358
+
359
+ ### Model Loading Protocol
360
+
361
+ Model load also uses SSE for download progress:
362
+
363
+ ```
364
+ data: {"status":"downloading","progress":25,"file":"model.onnx"}\n\n
365
+ data: {"status":"downloading","progress":50,"file":"model.onnx"}\n\n
366
+ data: {"status":"ready","progress":100,"modelId":"...","dtype":"q4"}\n\n
367
+ ```
368
+
369
+ SSE format: `data: ` prefix, JSON body, double newline (`\n\n`) between events.
370
+
371
+ ## Frontend Architecture
372
+
373
+ The workspace (`app/page.tsx`) is built for a Cursor/Gemini-like flow: immediate feedback, non-blocking streaming, and clear separation between chat, context, and observability.
374
+
375
+ ### Layout and Responsibilities
376
+
377
+ | Area | Purpose |
378
+ |------|--------|
379
+ | **Chat** | User messages and assistant replies. Assistant messages show a shimmer placeholder while waiting, then the final report is revealed word-by-word for a live-conversation feel. |
380
+ | **Context panel** | Knowledge drop zone: drag-and-drop files (PDF, text, etc.) or add URLs/notes. Items are listed and sent as `knowledge` in the research request for future agent use. |
381
+ | **Model card** | Model selection, load/progress, status pill (Ready / Loading / Error). Uses the same SSE pattern as model load API. |
382
+ | **Reasoning trace** | Scrollable list of the latest reasoning summaries per node so you can follow the graph’s flow while the chat shows the final answer. |
383
+
384
+ ### Optimistic UI and Streaming Flow
385
+
386
+ 1. **On submit:** The client immediately appends a user message and an assistant placeholder (with shimmer) to the chat and sets `isStreaming = true`. No wait for the first byte.
387
+ 2. **SSE consumption:** `POST /api/research` is read with `response.body.getReader()`. Chunks are decoded and split on `\n\n`. Each SSE message is parsed for `event:` and `data:`; `start` and `research` events are appended to the events list.
388
+ 3. **Final report:** When an event contains `state.finalReport`, that text is stored and a word-by-word animation is started for the latest assistant message: a timer (e.g. every 40ms) reveals the next word until the full report is shown, then `isStreaming` is cleared.
389
+ 4. **Abort/cleanup:** A ref holds an `AbortController` for the in-flight request; starting a new run aborts the previous one and clears the streaming timer so only one “live” reply runs at a time.
390
+
391
+ ### Knowledge / Context Flow
392
+
393
+ - **Drop zone:** Accepts drag-and-drop files and paste/drop of URLs (`text/uri-list` or plain text). Files and URLs are turned into `KnowledgeItem` entries (id, type, label, optional meta).
394
+ - **Request payload:** The research request sends `{ query, knowledge: knowledgeItems }`. The backend currently uses only `query`; `knowledge` is reserved for future use (e.g. RAG, plan conditioning).
395
+ - **UI copy:** The panel explains that added context can be used to ground answers; when the agent supports it, no frontend change is required beyond the existing payload.
396
+
397
+ ### Quick Actions and Starter Cards
398
+
399
+ - **Quick-action chips** below the input (e.g. “Help me learn this topic”, “Summarize these docs”) set or extend the query and optionally trigger a run, similar to Gemini suggestion chips.
400
+ - **Starter cards** in the empty state show example prompts (e.g. “How does Gemini Pro work…”) and populate the input or start a run when clicked, so the app feels ready to use without typing.
401
+
402
+ ### Why This Structure
403
+
404
+ - **Single page:** All controls (model, context, chat, trace) stay on one screen to reduce context switching and match a “flow state” tool.
405
+ - **SSE end-to-end:** Both research and model load use SSE so the client has one mental model: stream events, parse by type, update UI.
406
+ - **Word-by-word:** The synthesizer returns the full report in one event; animating it word-by-word on the client gives a streaming feel without changing the backend contract.
407
+
408
+ ## File Organization
409
+
410
+ ### Separation of Concerns
411
+
412
+ | Directory | Responsibility |
413
+ |-----------|----------------|
414
+ | `lib/agent/nodes/` | Individual node implementations |
415
+ | `lib/agent/llm/` | LLM client abstraction |
416
+ | `lib/agent/utils/` | Shared utilities (JSON extraction, policy loading) |
417
+ | `lib/agent/` | Graph construction, state schemas, routing |
418
+ | `app/api/` | HTTP endpoints |
419
+ | `app/` | React UI components |
420
+
421
+ ### Import Hierarchy
422
+
423
+ ```
424
+ app/api/research/route.ts
425
+ └── @/lib/agent (public API)
426
+ └── graph.ts
427
+ ├── state.ts
428
+ ├── nodes/index.ts
429
+ │ ├── thinker.ts → llm, utils, state
430
+ │ ├── auditor.ts → llm, utils, state
431
+ │ └── ...
432
+ └── routing.ts → state
433
+ ```
434
+
435
+ ## Checkpointing and Persistence
436
+
437
+ ### MemorySaver (Development)
438
+
439
+ Default checkpointer stores state in memory. State is lost on server restart.
440
+
441
+ ```typescript
442
+ import { MemorySaver } from "@langchain/langgraph";
443
+ const checkpointer = new MemorySaver();
444
+ ```
445
+
446
+ ### Production Persistence
447
+
448
+ For production, swap to a persistent checkpointer:
449
+
450
+ ```typescript
451
+ import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
452
+ const checkpointer = await PostgresSaver.fromConnString(process.env.DATABASE_URL);
453
+ ```
454
+
455
+ ### Thread-Based State
456
+
457
+ Each research session has a unique `threadId`. The checkpointer keys state by thread:
458
+
459
+ ```typescript
460
+ const config = { configurable: { thread_id: initialState.threadId } };
461
+
462
+ // Stream with checkpointing
463
+ for await (const event of graph.stream(initialState, config)) { ... }
464
+
465
+ // Resume from checkpoint
466
+ await graph.updateState(config, { humanApproved: true });
467
+ for await (const event of graph.stream(null, config)) { ... }
468
+ ```
469
+
470
+ ## Security Considerations
471
+
472
+ ### Policy Enforcement
473
+
474
+ The Auditor node validates plans against `POLICY.md` before execution. Policy rules should cover:
475
+
476
+ - Data access restrictions
477
+ - External request limits
478
+ - Allowed tool types
479
+ - Content guidelines
480
+
481
+ ### Tool Sandboxing
482
+
483
+ Tool implementations (currently stubs) should sandbox external operations:
484
+
485
+ - Network requests: Rate limiting, allowlists
486
+ - Code execution: Containerized environments
487
+ - File access: Scoped to specific directories
488
+
489
+ ### Input Validation
490
+
491
+ All state mutations pass through Zod schemas, preventing malformed data from propagating.
492
+
493
+ ---
494
+
495
+ ## Future Considerations
496
+
497
+ ### Potential Enhancements
498
+
499
+ 1. **Persistent Checkpointing**: PostgreSQL or Redis for production state storage
500
+ 2. **Tool Implementations**: Real web search (Tavily), document fetch (Playwright), code execution
501
+ 3. **Multi-Model Support**: Router to select appropriate model per task complexity
502
+ 4. **Observability**: OpenTelemetry traces for node-level metrics
503
+ 5. **Parallel Tool Execution**: Execute independent steps concurrently
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
instrumentation.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Next.js instrumentation — runs once when the Node server starts.
3
+ * Preloads the LLM model so the first request doesn't wait for download + load.
4
+ * Skip on Vercel (no local inference). Disable with DEEPTRUST_LOAD_MODEL_AT_STARTUP=0.
5
+ * Skip on Hugging Face Spaces so the Space becomes healthy immediately (model loads on first use).
6
+ *
7
+ * Logs progress from the worker so long-running downloads/loads are visible in Space logs.
8
+ * After PRELOAD_TIMEOUT_MS (default 10 min) logs a timeout message if still loading.
9
+ */
10
+
11
+ const PRELOAD_TIMEOUT_MS = Number(process.env.DEEPTRUST_PRELOAD_TIMEOUT_MS) || 10 * 60 * 1000;
12
+
13
+ /** True when running inside a Hugging Face Space (avoids startup preload so health check passes). */
14
+ function isHuggingFaceSpace(): boolean {
15
+ if (process.env.SPACE_ID || process.env.HF_SPACE_ID) return true;
16
+ if (process.env.PORT === "7860") return true; // Spaces default Docker app_port
17
+ return false;
18
+ }
19
+
20
+ export async function register() {
21
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
22
+ if (process.env.VERCEL === "1") return;
23
+ if (process.env.DEEPTRUST_LOAD_MODEL_AT_STARTUP === "0" || process.env.DEEPTRUST_LOAD_MODEL_AT_STARTUP === "false") return;
24
+ if (isHuggingFaceSpace()) {
25
+ console.log("[instrumentation] Hugging Face Space detected; skipping startup model preload (model will load on first use).");
26
+ return;
27
+ }
28
+
29
+ const { loadModel } = await import("@/lib/agent/llm");
30
+ const modelId = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
31
+ console.log(`[instrumentation] Preloading model in background: ${modelId}`);
32
+
33
+ let completed = false;
34
+ const timeoutId = setTimeout(() => {
35
+ if (!completed) {
36
+ console.log(
37
+ `[instrumentation] Model preload still in progress (timeout after ${PRELOAD_TIMEOUT_MS / 60000} min). Check worker logs for download/load progress.`
38
+ );
39
+ }
40
+ }, PRELOAD_TIMEOUT_MS);
41
+
42
+ const onProgress = (p: { status: string; message: string; file?: string; progress?: number }) => {
43
+ console.log(`[instrumentation] ${p.message}${p.progress != null ? ` ${p.progress}%` : ""}`);
44
+ };
45
+
46
+ void loadModel(undefined, "q4", onProgress)
47
+ .then(() => {
48
+ completed = true;
49
+ console.log("[instrumentation] Model preload complete.");
50
+ })
51
+ .catch((err) => {
52
+ completed = true;
53
+ console.error("[instrumentation] Model preload failed:", err instanceof Error ? err.message : err);
54
+ })
55
+ .finally(() => clearTimeout(timeoutId));
56
+ }
lib/agent/POLICY.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Research Policy
2
+ - Do not access private or personal data.
3
+ - Do not make more than 10 external requests per session.
4
+ - All sources must be publicly accessible.
lib/agent/graph.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * graph.ts — DeepTrust Research Agent
3
+ *
4
+ * Defines the full StateGraph lifecycle:
5
+ *
6
+ * ┌──────────────────────────────────────────────────────────┐
7
+ * │ │
8
+ * │ [START] ──► thinker ──► auditor ──► tool_executor │
9
+ * │ ▲ │ │ │
10
+ * │ │ reject │ │ │
11
+ * │ └───────────┘ ▼ │
12
+ * │ synthesizer │
13
+ * │ │ │
14
+ * │ [END] │
15
+ * └──────────────────────────────────────────────────────────┘
16
+ *
17
+ * HITL interrupt fires between auditor approval and tool_executor,
18
+ * giving operators a chance to review the plan before any tool runs.
19
+ *
20
+ * Every node writes to `state.reasoning` for full observability.
21
+ */
22
+
23
+ import { StateGraph, END, START, MemorySaver, Command } from "@langchain/langgraph";
24
+ import { v4 as uuidv4 } from "uuid";
25
+
26
+ import {
27
+ ResearchState,
28
+ ReasoningEntry,
29
+ createInitialState,
30
+ } from "./state";
31
+
32
+ import {
33
+ thinkerNode,
34
+ auditorNode,
35
+ hitlGateNode,
36
+ toolExecutorNode,
37
+ synthesizerNode,
38
+ } from "./nodes";
39
+
40
+ import {
41
+ routeAfterAudit,
42
+ routeAfterHitl,
43
+ routeAfterToolStep,
44
+ } from "./routing";
45
+
46
+ // ─────────────────────────────────────────────────────────────
47
+ // Graph construction
48
+ // ─────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Builds and compiles the DeepTrust StateGraph.
52
+ *
53
+ * The `checkpointer` defaults to MemorySaver (in-process, dev-only).
54
+ * Pass a PostgresSaver instance for production persistence.
55
+ */
56
+ export function buildDeepTrustGraph(
57
+ checkpointer: MemorySaver = new MemorySaver()
58
+ ) {
59
+ const graph = new StateGraph<ResearchState>({
60
+ channels: {
61
+ threadId: { value: (_, n) => n },
62
+ sessionName: { value: (_, n) => n },
63
+ userQuery: { value: (_, n) => n },
64
+ knowledgeContext: { value: (_, n) => n },
65
+ contextUrls: { value: (_, n) => n },
66
+ plan: { value: (_, n) => n },
67
+ rejectionFeedback:{ value: (_, n) => n },
68
+ planRevisionCount:{ value: (_, n) => n },
69
+ maxPlanRevisions: { value: (_, n) => n },
70
+ auditResult: { value: (_, n) => n },
71
+ currentStepIndex: { value: (_, n) => n },
72
+ humanApproved: { value: (_, n) => n },
73
+ finalReport: { value: (_, n) => n },
74
+ reasoning: {
75
+ value: (existing: ReasoningEntry[], incoming: ReasoningEntry[]) =>
76
+ [...(existing ?? []), ...(incoming ?? [])],
77
+ default: () => [],
78
+ },
79
+ status: { value: (_, n) => n },
80
+ updatedAt: { value: (_, n) => n },
81
+ errorMessage: { value: (_, n) => n },
82
+ },
83
+ })
84
+ // Nodes
85
+ .addNode("thinker", thinkerNode)
86
+ .addNode("auditor", auditorNode)
87
+ .addNode("hitl_gate", hitlGateNode)
88
+ .addNode("tool_executor", toolExecutorNode)
89
+ .addNode("synthesizer", synthesizerNode)
90
+
91
+ // Edges
92
+ .addEdge(START, "thinker")
93
+ .addEdge("thinker", "auditor")
94
+
95
+ .addConditionalEdges("auditor", routeAfterAudit, {
96
+ thinker: "thinker",
97
+ hitl_gate: "hitl_gate",
98
+ [END]: END,
99
+ })
100
+
101
+ .addConditionalEdges("hitl_gate", routeAfterHitl, {
102
+ tool_executor: "tool_executor",
103
+ [END]: END,
104
+ })
105
+
106
+ .addConditionalEdges("tool_executor", routeAfterToolStep, {
107
+ tool_executor: "tool_executor",
108
+ synthesizer: "synthesizer",
109
+ })
110
+
111
+ .addEdge("synthesizer", END);
112
+
113
+ return graph.compile({ checkpointer, interruptBefore: [] });
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────
117
+ // Singleton instance
118
+ // ─────────────────────────────────────────────────────────────
119
+
120
+ const checkpointer = new MemorySaver();
121
+ export const deepTrustGraph = buildDeepTrustGraph(checkpointer);
122
+
123
+ // ─────────────────────────────────────────────────────────────
124
+ // Public API
125
+ // ─────���───────────────────────────────────────────────────────
126
+
127
+ export interface RunResearchOptions {
128
+ knowledgeContext?: string;
129
+ contextUrls?: string[];
130
+ /**
131
+ * Optional metadata forwarded to LangGraph / LangSmith.
132
+ * Useful for grouping and searching traces.
133
+ */
134
+ metadata?: Record<string, unknown>;
135
+ }
136
+
137
+ /**
138
+ * Start a new research session and stream events to the caller.
139
+ * Optionally pass retrieved context from client-side RAG (knowledgeContext, contextUrls).
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * for await (const event of runResearch("What caused the 2008 crisis?")) {
144
+ * console.log(event);
145
+ * }
146
+ * for await (const event of runResearch("Summarize my docs", "Session", { knowledgeContext: "..." })) {
147
+ * console.log(event);
148
+ * }
149
+ * ```
150
+ */
151
+ export async function* runResearch(
152
+ userQuery: string,
153
+ sessionName = "Research Session",
154
+ options: RunResearchOptions = {}
155
+ ): AsyncGenerator<{ node: string; state: Partial<ResearchState> }> {
156
+ const initialState = createInitialState({
157
+ threadId: uuidv4(),
158
+ userQuery,
159
+ sessionName,
160
+ knowledgeContext: options.knowledgeContext,
161
+ contextUrls: options.contextUrls,
162
+ });
163
+
164
+ const config = {
165
+ configurable: { thread_id: initialState.threadId },
166
+ metadata: {
167
+ project: process.env.LANGCHAIN_PROJECT ?? "deeptrust",
168
+ run_name: "DeepTrust research session",
169
+ source: "deeptrust-ui",
170
+ ...(options.metadata ?? {}),
171
+ },
172
+ };
173
+
174
+ for await (const event of await deepTrustGraph.stream(initialState, config)) {
175
+ // LangGraph interrupts surface under the __interrupt__ key.
176
+ if ("__interrupt__" in event) {
177
+ const interruptInfo = (event as any).__interrupt__;
178
+ yield {
179
+ node: "__interrupt__",
180
+ // Expose threadId so callers can resume the run.
181
+ state: {
182
+ threadId: initialState.threadId,
183
+ interrupt: interruptInfo,
184
+ } as any,
185
+ };
186
+ return;
187
+ }
188
+
189
+ for (const [node, state] of Object.entries(event)) {
190
+ yield { node, state: state as Partial<ResearchState> };
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Resume a paused session after human approval.
197
+ *
198
+ * The caller passes the threadId and streams the remaining events.
199
+ */
200
+ export async function* approveAndResume(
201
+ threadId: string
202
+ ): AsyncGenerator<{ node: string; state: Partial<ResearchState> }> {
203
+ const config = { configurable: { thread_id: threadId } };
204
+
205
+ await deepTrustGraph.updateState(config, { humanApproved: true });
206
+
207
+ // Resume execution from the last checkpoint.
208
+ const command = new Command({
209
+ resume: { approved: true },
210
+ });
211
+
212
+ for await (const event of await deepTrustGraph.stream(command, config)) {
213
+ for (const [node, state] of Object.entries(event)) {
214
+ yield { node, state: state as Partial<ResearchState> };
215
+ }
216
+ }
217
+ }
lib/agent/index.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DeepTrust Research Agent — Public API
3
+ */
4
+
5
+ // Graph and public functions
6
+ export {
7
+ buildDeepTrustGraph,
8
+ deepTrustGraph,
9
+ runResearch,
10
+ approveAndResume,
11
+ } from "./graph";
12
+ export type { RunResearchOptions } from "./graph";
13
+
14
+ // State types and helpers
15
+ export {
16
+ ResearchState,
17
+ ResearchPlan,
18
+ ResearchStep,
19
+ AuditResult,
20
+ ReasoningEntry,
21
+ NodeName,
22
+ createInitialState,
23
+ appendReasoning,
24
+ } from "./state";
25
+
26
+ // LLM client (for advanced usage)
27
+ export { chatComplete, loadModel, getModelStatus, MODEL_ID, MODELS } from "./llm";
28
+ export type { ModelProgress, ProgressCallback, ModelOption } from "./llm";
29
+
30
+ // Utilities
31
+ export { extractJSON, loadPolicy } from "./utils";
32
+
lib/agent/llm/index.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LLM Client — server-side inference via a worker thread
3
+ *
4
+ * Pipeline runs in lib/agent/llm/worker-entry (compiled to dist/llm). This module
5
+ * spawns the worker and proxies getModelStatus, loadModel, chatComplete so API routes
6
+ * and the agent keep the same API. Keeps the main Node process non-blocking.
7
+ */
8
+
9
+ import { Worker } from "node:worker_threads";
10
+ import path from "node:path";
11
+ import fs from "node:fs";
12
+
13
+ import type { ModelOption, ModelProgress } from "./pipeline";
14
+
15
+ // Static list for API responses; worker also returns models in getStatus/load payloads
16
+ export type { ModelOption, ModelProgress };
17
+ export const MODEL_ID =
18
+ process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
19
+
20
+ export const MODELS: ModelOption[] = [
21
+ {
22
+ id: "HuggingFaceTB/SmolLM2-135M-Instruct",
23
+ label: "SmolLM2 135M (Q4, tiny)",
24
+ dtype: "q4",
25
+ sizeNote: "~150–200 MB (approx)",
26
+ },
27
+ {
28
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
29
+ label: "SmolLM2 360M (Q4)",
30
+ dtype: "q4",
31
+ sizeNote: "~388 MB",
32
+ },
33
+ {
34
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
35
+ label: "SmolLM2 360M (FP16)",
36
+ dtype: "fp16",
37
+ sizeNote: "~725 MB",
38
+ },
39
+ {
40
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
41
+ label: "SmolLM2 360M (full)",
42
+ dtype: "fp32",
43
+ sizeNote: "~1.45 GB",
44
+ },
45
+ ];
46
+
47
+ export type ProgressCallback = (progress: ModelProgress) => void;
48
+
49
+ let worker: Worker | null = null;
50
+ const pending = new Map<
51
+ string,
52
+ { resolve: (v: unknown) => void; reject: (e: Error) => void; onProgress?: ProgressCallback }
53
+ >();
54
+
55
+ function getWorkerPath(): string {
56
+ return path.join(process.cwd(), "dist", "llm", "worker-entry.js");
57
+ }
58
+
59
+ function getWorker(): Worker {
60
+ if (worker) return worker;
61
+ const workerPath = getWorkerPath();
62
+ if (!fs.existsSync(workerPath)) {
63
+ throw new Error(
64
+ `LLM worker not found at ${workerPath}. Run "npm run build:worker" (or "npm run build") first.`
65
+ );
66
+ }
67
+ worker = new Worker(workerPath, {
68
+ stdout: true,
69
+ stderr: true,
70
+ });
71
+ worker.on("message", (msg: { id: string; type: string; payload: unknown }) => {
72
+ const entry = pending.get(msg.id);
73
+ if (!entry) return;
74
+ if (msg.type === "progress" && entry.onProgress) {
75
+ entry.onProgress(msg.payload as ModelProgress);
76
+ return;
77
+ }
78
+ pending.delete(msg.id);
79
+ if (msg.type === "resolve") entry.resolve(msg.payload);
80
+ else if (msg.type === "reject") entry.reject(new Error(String(msg.payload)));
81
+ });
82
+ worker.on("error", (err) => {
83
+ for (const [, entry] of pending) entry.reject(err);
84
+ pending.clear();
85
+ });
86
+ worker.on("exit", (code) => {
87
+ if (code !== 0) {
88
+ for (const [, entry] of pending) entry.reject(new Error(`Worker exited with code ${code}`));
89
+ pending.clear();
90
+ }
91
+ worker = null;
92
+ });
93
+ return worker;
94
+ }
95
+
96
+ function send<T>(type: string, payload: unknown, onProgress?: ProgressCallback): Promise<T> {
97
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
98
+ return new Promise<T>((resolve, reject) => {
99
+ pending.set(id, { resolve: resolve as (v: unknown) => void, reject, onProgress });
100
+ getWorker().postMessage({ id, type, payload });
101
+ });
102
+ }
103
+
104
+ export type ModelStatusResponse = ModelProgress & { models: ModelOption[] };
105
+
106
+ export function getModelStatus(forModelId?: string, forDtype?: string): Promise<ModelStatusResponse> {
107
+ return send<ModelStatusResponse>("getStatus", { modelId: forModelId, dtype: forDtype });
108
+ }
109
+
110
+ /** Resolves when the model is loaded; progress is reported via onProgress. */
111
+ export function loadModel(
112
+ modelId?: string,
113
+ dtype?: ModelOption["dtype"],
114
+ onProgress?: ProgressCallback
115
+ ): Promise<ModelStatusResponse> {
116
+ return send<ModelStatusResponse>("load", { modelId, dtype }, onProgress);
117
+ }
118
+
119
+ export async function chatComplete(systemPrompt: string, userMessage: string): Promise<string> {
120
+ return send<string>("chat", { systemPrompt, userMessage });
121
+ }
lib/agent/llm/pipeline.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pipeline — runs inside the LLM worker thread only.
3
+ * Hugging Face Transformers + onnxruntime-node; state lives in this thread.
4
+ */
5
+
6
+ import { pipeline, TextGenerationPipeline, env } from "@huggingface/transformers";
7
+ import path from "node:path";
8
+
9
+ env.cacheDir =
10
+ process.env.HF_CACHE_DIR ||
11
+ path.join(process.cwd(), ".hf-cache");
12
+
13
+ // Default to a very small instruct model for low-spec machines.
14
+ // If this model ID does not exist or you prefer another tiny model,
15
+ // set HF_MODEL in your environment to override.
16
+ export const MODEL_ID =
17
+ process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
18
+
19
+ export interface ModelOption {
20
+ id: string;
21
+ label: string;
22
+ dtype?: "q4" | "fp16" | "fp32";
23
+ sizeNote?: string;
24
+ }
25
+
26
+ export const MODELS: ModelOption[] = [
27
+ {
28
+ id: "HuggingFaceTB/SmolLM2-135M-Instruct",
29
+ label: "SmolLM2 135M (Q4, tiny)",
30
+ dtype: "q4",
31
+ sizeNote: "~150–200 MB (approx)",
32
+ },
33
+ {
34
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
35
+ label: "SmolLM2 360M (Q4)",
36
+ dtype: "q4",
37
+ sizeNote: "~388 MB",
38
+ },
39
+ {
40
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
41
+ label: "SmolLM2 360M (FP16)",
42
+ dtype: "fp16",
43
+ sizeNote: "~725 MB",
44
+ },
45
+ {
46
+ id: "HuggingFaceTB/SmolLM2-360M-Instruct",
47
+ label: "SmolLM2 360M (full)",
48
+ dtype: "fp32",
49
+ sizeNote: "~1.45 GB",
50
+ },
51
+ ];
52
+
53
+ let currentModelId = MODELS[0].id;
54
+ let currentDtype = MODELS[0].dtype;
55
+ let generatorPromise: Promise<TextGenerationPipeline> | null = null;
56
+ let isModelLoaded = false;
57
+ let currentProgress = 0;
58
+ let currentStatus = "idle";
59
+ let currentFile = "";
60
+
61
+ export interface ModelProgress {
62
+ status: "idle" | "loading" | "downloading" | "ready" | "error";
63
+ progress: number;
64
+ file: string;
65
+ message: string;
66
+ modelId?: string;
67
+ dtype?: string;
68
+ }
69
+
70
+ export function getModelStatus(forModelId?: string, forDtype?: string): ModelProgress {
71
+ const isOther = forModelId !== undefined && (forModelId !== currentModelId || forDtype !== currentDtype);
72
+ if (isOther) {
73
+ return {
74
+ status: "idle",
75
+ progress: 0,
76
+ file: "",
77
+ message: "Model not loaded",
78
+ modelId: forModelId,
79
+ dtype: forDtype,
80
+ };
81
+ }
82
+ if (isModelLoaded) {
83
+ return {
84
+ status: "ready",
85
+ progress: 100,
86
+ file: "",
87
+ message: "Model ready",
88
+ modelId: currentModelId,
89
+ dtype: currentDtype,
90
+ };
91
+ }
92
+ if (generatorPromise) {
93
+ return {
94
+ status: currentStatus as ModelProgress["status"],
95
+ progress: currentProgress,
96
+ file: currentFile,
97
+ message: currentFile ? `Downloading ${currentFile}` : "Loading model...",
98
+ modelId: currentModelId,
99
+ dtype: currentDtype,
100
+ };
101
+ }
102
+ return {
103
+ status: "idle",
104
+ progress: 0,
105
+ file: "",
106
+ message: "Model not loaded",
107
+ modelId: currentModelId,
108
+ dtype: currentDtype,
109
+ };
110
+ }
111
+
112
+ export type ProgressCallback = (progress: ModelProgress) => void;
113
+
114
+ export function loadModel(
115
+ modelId?: string,
116
+ dtype?: ModelOption["dtype"],
117
+ onProgress?: ProgressCallback
118
+ ): Promise<TextGenerationPipeline> {
119
+ const nextId = modelId ?? currentModelId;
120
+ const nextDtype = dtype ?? currentDtype;
121
+
122
+ if (nextId !== currentModelId || nextDtype !== currentDtype) {
123
+ generatorPromise = null;
124
+ isModelLoaded = false;
125
+ currentModelId = nextId;
126
+ currentDtype = nextDtype;
127
+ currentStatus = "idle";
128
+ currentProgress = 0;
129
+ currentFile = "";
130
+ }
131
+
132
+ if (generatorPromise) {
133
+ return generatorPromise;
134
+ }
135
+
136
+ console.log(`\n🔄 [worker] Loading model: ${currentModelId}${currentDtype ? ` (${currentDtype})` : ""}`);
137
+ console.log(` Cache directory: ${env.cacheDir}\n`);
138
+
139
+ currentStatus = "loading";
140
+ const startTime = Date.now();
141
+
142
+ const pipelineOptions: Parameters<typeof pipeline>[2] = {
143
+ progress_callback: (progressData: {
144
+ status: string;
145
+ name?: string;
146
+ file?: string;
147
+ loaded?: number;
148
+ total?: number;
149
+ progress?: number;
150
+ }) => {
151
+ currentStatus = progressData.status === "progress" ? "downloading" : "loading";
152
+ currentFile = progressData.file || progressData.name || "";
153
+ currentProgress = Math.round(progressData.progress ?? 0);
154
+
155
+ const update: ModelProgress = {
156
+ status: currentStatus as ModelProgress["status"],
157
+ progress: currentProgress,
158
+ file: currentFile,
159
+ message: currentFile
160
+ ? `Downloading ${currentFile.split("/").pop()} (${currentProgress}%)`
161
+ : `${progressData.status}...`,
162
+ modelId: currentModelId,
163
+ dtype: currentDtype,
164
+ };
165
+
166
+ console.log(` ${update.message}`);
167
+ onProgress?.(update);
168
+ },
169
+ };
170
+ if (currentDtype) {
171
+ (pipelineOptions as Record<string, unknown>).dtype = currentDtype;
172
+ }
173
+
174
+ const pipelinePromise = pipeline("text-generation", currentModelId, pipelineOptions);
175
+ generatorPromise = (pipelinePromise as Promise<TextGenerationPipeline>)
176
+ .then((gen) => {
177
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
178
+ console.log(`\n✅ [worker] Model loaded in ${elapsed}s\n`);
179
+ isModelLoaded = true;
180
+ currentStatus = "ready";
181
+ currentProgress = 100;
182
+ onProgress?.({
183
+ status: "ready",
184
+ progress: 100,
185
+ file: "",
186
+ message: "Model ready",
187
+ modelId: currentModelId,
188
+ dtype: currentDtype,
189
+ });
190
+ return gen;
191
+ })
192
+ .catch((err) => {
193
+ currentStatus = "error";
194
+ generatorPromise = null;
195
+ onProgress?.({
196
+ status: "error",
197
+ progress: 0,
198
+ file: "",
199
+ message: err.message,
200
+ modelId: currentModelId,
201
+ dtype: currentDtype,
202
+ });
203
+ throw err;
204
+ });
205
+
206
+ return generatorPromise;
207
+ }
208
+
209
+ export async function chatComplete(
210
+ systemPrompt: string,
211
+ userMessage: string
212
+ ): Promise<string> {
213
+ const generator = await loadModel();
214
+
215
+ const messages = [
216
+ { role: "system", content: systemPrompt },
217
+ { role: "user", content: userMessage },
218
+ ];
219
+
220
+ const preview = userMessage.slice(0, 60).replace(/\n/g, " ");
221
+ console.log(`🤖 [worker] Generating response for: "${preview}..."`);
222
+ const startTime = Date.now();
223
+
224
+ const output = await generator(messages, {
225
+ max_new_tokens: 4096,
226
+ do_sample: true,
227
+ temperature: 0.7,
228
+ });
229
+
230
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
231
+
232
+ const result = output[0] as { generated_text: Array<{ role: string; content: string }> };
233
+ const assistantMessage = result.generated_text.find(
234
+ (msg) => msg.role === "assistant"
235
+ );
236
+
237
+ if (!assistantMessage) {
238
+ throw new Error("No assistant response generated");
239
+ }
240
+
241
+ console.log(`✅ [worker] Generated ${assistantMessage.content.length} chars in ${elapsed}s`);
242
+ return assistantMessage.content;
243
+ }
lib/agent/llm/worker-entry.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Worker entry — runs in a Node worker_thread. Handles getStatus, load, chat and forwards progress.
3
+ */
4
+
5
+ import { parentPort } from "node:worker_threads";
6
+ import {
7
+ getModelStatus,
8
+ loadModel,
9
+ chatComplete,
10
+ MODELS,
11
+ type ModelProgress,
12
+ type ModelOption,
13
+ } from "./pipeline";
14
+
15
+ type Incoming =
16
+ | { id: string; type: "getStatus"; payload: { modelId?: string; dtype?: string } }
17
+ | { id: string; type: "load"; payload: { modelId?: string; dtype?: ModelOption["dtype"] } }
18
+ | { id: string; type: "chat"; payload: { systemPrompt: string; userMessage: string } };
19
+
20
+ type Outgoing =
21
+ | { id: string; type: "resolve"; payload: unknown }
22
+ | { id: string; type: "progress"; payload: ModelProgress & { models: ModelOption[] } }
23
+ | { id: string; type: "reject"; payload: string };
24
+
25
+ function reply(msg: Outgoing) {
26
+ parentPort?.postMessage(msg);
27
+ }
28
+
29
+ parentPort?.on("message", (msg: Incoming) => {
30
+ const { id, type, payload } = msg;
31
+
32
+ (async () => {
33
+ try {
34
+ switch (type) {
35
+ case "getStatus": {
36
+ const status = getModelStatus(payload.modelId, payload.dtype);
37
+ reply({ id, type: "resolve", payload: { ...status, models: MODELS } });
38
+ break;
39
+ }
40
+ case "load": {
41
+ await loadModel(payload.modelId, payload.dtype, (progress) => {
42
+ reply({
43
+ id,
44
+ type: "progress",
45
+ payload: { ...progress, models: MODELS },
46
+ });
47
+ });
48
+ const status = getModelStatus(payload.modelId, payload.dtype);
49
+ reply({ id, type: "resolve", payload: { ...status, models: MODELS } });
50
+ break;
51
+ }
52
+ case "chat": {
53
+ const text = await chatComplete(payload.systemPrompt, payload.userMessage);
54
+ reply({ id, type: "resolve", payload: text });
55
+ break;
56
+ }
57
+ default:
58
+ reply({ id, type: "reject", payload: `Unknown message type: ${(msg as { type: string }).type}` });
59
+ }
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ reply({ id, type: "reject", payload: message });
63
+ }
64
+ })();
65
+ });
lib/agent/nodes/auditor.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Auditor Node — validates plans against organizational policy
3
+ */
4
+
5
+ import { chatComplete } from "../llm";
6
+ import { extractJSON, loadPolicy } from "../utils";
7
+ import { ResearchState, AuditResult, appendReasoning } from "../state";
8
+
9
+ /**
10
+ * Compares the current plan against POLICY.md.
11
+ * Sets `auditResult` with verdict + structured feedback.
12
+ * If rejected, also sets `rejectionFeedback` and increments
13
+ * `planRevisionCount` so the router can loop back to thinker.
14
+ */
15
+ export async function auditorNode(
16
+ state: ResearchState
17
+ ): Promise<Partial<ResearchState>> {
18
+ if (!state.plan) {
19
+ throw new Error("auditorNode called with no plan in state");
20
+ }
21
+
22
+ const policy = loadPolicy();
23
+
24
+ const system = `
25
+ You are the Auditor node of DeepTrust.
26
+ You evaluate research plans against an organisational policy and return a structured verdict.
27
+
28
+ Return ONLY a valid JSON object matching:
29
+ {
30
+ "verdict": "approved" | "rejected" | "needs_revision",
31
+ "policyViolations": string[],
32
+ "suggestions": string[],
33
+ "auditedAt": string (ISO 8601)
34
+ }
35
+
36
+ Rules:
37
+ - "approved" means the plan fully complies with policy.
38
+ - "rejected" means the plan has hard violations that cannot be patched.
39
+ - "needs_revision" means soft issues exist but the plan is salvageable.
40
+ - Treat "needs_revision" as rejection for routing purposes.
41
+ - Do not include markdown fences or any prose outside the JSON object.
42
+ `.trim();
43
+
44
+ const userMessage = `
45
+ POLICY:
46
+ ${policy}
47
+
48
+ PLAN TO AUDIT:
49
+ ${JSON.stringify(state.plan, null, 2)}
50
+ `.trim();
51
+
52
+ const rawThought = await chatComplete(system, userMessage);
53
+
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = extractJSON(rawThought);
57
+ } catch {
58
+ throw new Error(`Auditor produced non-JSON output: ${rawThought.slice(0, 300)}`);
59
+ }
60
+
61
+ const auditResult = AuditResult.parse({
62
+ ...(parsed as object),
63
+ auditedAt: new Date().toISOString(),
64
+ });
65
+
66
+ const isRejected = auditResult.verdict !== "approved";
67
+
68
+ const rejectionFeedback: string | null = isRejected
69
+ ? [
70
+ `Verdict: ${auditResult.verdict}`,
71
+ auditResult.policyViolations.length
72
+ ? `Violations:\n${auditResult.policyViolations.map((v) => ` - ${v}`).join("\n")}`
73
+ : null,
74
+ auditResult.suggestions.length
75
+ ? `Suggestions:\n${auditResult.suggestions.map((s) => ` - ${s}`).join("\n")}`
76
+ : null,
77
+ ]
78
+ .filter(Boolean)
79
+ .join("\n\n")
80
+ : null;
81
+
82
+ const reasoning = appendReasoning(state, {
83
+ node: "auditor",
84
+ summary: isRejected
85
+ ? `Plan REJECTED (${auditResult.verdict}). ${auditResult.policyViolations.length} violation(s) found.`
86
+ : "Plan APPROVED — no policy violations detected.",
87
+ rawThought,
88
+ });
89
+
90
+ return {
91
+ auditResult,
92
+ rejectionFeedback,
93
+ planRevisionCount: isRejected
94
+ ? state.planRevisionCount + 1
95
+ : state.planRevisionCount,
96
+ status: isRejected ? "thinking" : "awaiting_approval",
97
+ reasoning,
98
+ updatedAt: new Date().toISOString(),
99
+ };
100
+ }
101
+
lib/agent/nodes/hitl-gate.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * HITL Gate Node — Human-in-the-Loop interrupt point
3
+ */
4
+
5
+ import { interrupt } from "@langchain/langgraph";
6
+ import { ResearchState, appendReasoning } from "../state";
7
+
8
+ /**
9
+ * Pauses the graph and surfaces the plan to the operator.
10
+ * When the graph is resumed (via .updateState()), the caller
11
+ * must set `state.humanApproved = true` to proceed.
12
+ *
13
+ * Using LangGraph's `interrupt()` primitive ensures the
14
+ * checkpoint is written BEFORE the interrupt fires, so the
15
+ * session can be resumed from any client or process.
16
+ */
17
+ export async function hitlGateNode(
18
+ state: ResearchState
19
+ ): Promise<Partial<ResearchState>> {
20
+ const reasoning = appendReasoning(state, {
21
+ node: "thinker", // logged under thinker namespace for UI grouping
22
+ summary: "⏸ HITL gate: awaiting human approval before tool execution.",
23
+ });
24
+
25
+ // This call writes a checkpoint and suspends until the graph
26
+ // is resumed externally. The value passed to interrupt() is
27
+ // surfaced to the caller of `graph.stream()`.
28
+ interrupt({
29
+ message: "Plan ready for review. Set humanApproved=true to continue.",
30
+ plan: state.plan,
31
+ auditResult: state.auditResult,
32
+ });
33
+
34
+ // Code below only runs after the interrupt is resolved.
35
+ return {
36
+ reasoning,
37
+ updatedAt: new Date().toISOString(),
38
+ };
39
+ }
40
+
lib/agent/nodes/index.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export { thinkerNode } from "./thinker";
2
+ export { auditorNode } from "./auditor";
3
+ export { hitlGateNode } from "./hitl-gate";
4
+ export { toolExecutorNode } from "./tool-executor";
5
+ export { synthesizerNode } from "./synthesizer";
6
+
lib/agent/nodes/synthesizer.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Synthesizer Node — produces the final research report
3
+ */
4
+
5
+ import { chatComplete } from "../llm";
6
+ import { ResearchState, appendReasoning } from "../state";
7
+
8
+ /**
9
+ * Reads all step outputs and produces the final research report.
10
+ */
11
+ export async function synthesizerNode(
12
+ state: ResearchState
13
+ ): Promise<Partial<ResearchState>> {
14
+ if (!state.plan) throw new Error("synthesizerNode called with no plan");
15
+
16
+ const system = `
17
+ You are the Synthesizer node of DeepTrust.
18
+ You receive a research plan with all step outputs filled in,
19
+ and you write a comprehensive, well-structured research report.
20
+
21
+ Guidelines:
22
+ - Lead with an executive summary.
23
+ - Organise findings by theme, not by tool execution order.
24
+ - Cite which step produced each finding.
25
+ - End with actionable conclusions.
26
+ `.trim();
27
+
28
+ const stepsContext = state.plan.steps
29
+ .map(
30
+ (s, i) =>
31
+ `Step ${i + 1} [${s.tool}] — "${s.input}"\nOutput: ${s.output ?? "(no output)"}`
32
+ )
33
+ .join("\n\n---\n\n");
34
+
35
+ const knowledgeBlock =
36
+ state.knowledgeContext?.trim() ?
37
+ `
38
+
39
+ User's local knowledge (use to ground or cite the report where relevant):
40
+ ${state.knowledgeContext}
41
+ `
42
+ : "";
43
+
44
+ const userMessage = `
45
+ Objective: ${state.plan.objective}
46
+
47
+ Research results:
48
+ ${stepsContext}
49
+ ${knowledgeBlock}
50
+ `.trim();
51
+
52
+ const finalReport = await chatComplete(system, userMessage);
53
+
54
+ const reasoning = appendReasoning(state, {
55
+ node: "synthesizer",
56
+ summary: `Final report synthesized (${finalReport.length} chars).`,
57
+ });
58
+
59
+ return {
60
+ finalReport,
61
+ status: "complete",
62
+ reasoning,
63
+ updatedAt: new Date().toISOString(),
64
+ };
65
+ }
66
+
lib/agent/nodes/thinker.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Thinker Node — produces or revises a structured ResearchPlan
3
+ *
4
+ * Uses safeParse + retry with Zod error feedback so that invalid LLM output
5
+ * (e.g. missing fields, wrong types) is corrected by the model instead of
6
+ * causing a 500. After max retries, throws so the route can stream an error event.
7
+ */
8
+
9
+ import { chatComplete } from "../llm";
10
+ import { extractJSON } from "../utils";
11
+ import { ResearchState, ResearchPlan, appendReasoning } from "../state";
12
+ import { v4 as uuidv4 } from "uuid";
13
+
14
+ const MAX_PLAN_ATTEMPTS = 3;
15
+
16
+ /**
17
+ * Format Zod errors for inclusion in the next prompt so the LLM can self-correct.
18
+ */
19
+ function formatZodErrors(issues: { path: unknown[]; message: string }[]): string {
20
+ return issues
21
+ .map((i) => ` - ${i.path.map((p) => String(p)).join(".")}: ${i.message}`)
22
+ .join("\n");
23
+ }
24
+
25
+ const UUID_REGEX =
26
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
27
+
28
+ /**
29
+ * Normalize LLM output so it passes ResearchPlan: fix non-UUID step ids and
30
+ * missing rationales (small/local models often omit these).
31
+ */
32
+ function normalizePlan(raw: Record<string, unknown>): Record<string, unknown> {
33
+ const steps = Array.isArray(raw.steps) ? raw.steps : [];
34
+ const normalizedSteps = steps.map((s: unknown, i: number) => {
35
+ const step = typeof s === "object" && s !== null ? (s as Record<string, unknown>) : {};
36
+ const id =
37
+ typeof step.id === "string" && UUID_REGEX.test(step.id) ? step.id : uuidv4();
38
+ const rationale =
39
+ typeof step.rationale === "string" && step.rationale.length > 0
40
+ ? step.rationale
41
+ : typeof step.input === "string"
42
+ ? step.input
43
+ : `Step ${i + 1}`;
44
+ return {
45
+ ...step,
46
+ id,
47
+ rationale,
48
+ input: typeof step.input === "string" ? step.input : "",
49
+ tool: step.tool ?? "web_search",
50
+ };
51
+ });
52
+
53
+ const createdAt =
54
+ typeof raw.createdAt === "string" && raw.createdAt.length > 0
55
+ ? raw.createdAt
56
+ : new Date().toISOString();
57
+ const estimatedTokenBudget =
58
+ typeof raw.estimatedTokenBudget === "number" && raw.estimatedTokenBudget > 0
59
+ ? raw.estimatedTokenBudget
60
+ : 2048;
61
+
62
+ const objective =
63
+ typeof raw.objective === "string" && raw.objective.trim().length > 0
64
+ ? raw.objective.trim()
65
+ : "Research objective";
66
+
67
+ return {
68
+ ...raw,
69
+ objective,
70
+ steps: normalizedSteps,
71
+ createdAt,
72
+ estimatedTokenBudget,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Produces (or revises) a structured ResearchPlan.
78
+ * When `state.rejectionFeedback` is set, the model receives the
79
+ * Auditor's critique and is instructed to produce a corrected plan.
80
+ *
81
+ * If the LLM returns invalid JSON or fails schema validation, we retry up to
82
+ * MAX_PLAN_ATTEMPTS times, feeding the validation errors back into the prompt.
83
+ */
84
+ export async function thinkerNode(
85
+ state: ResearchState
86
+ ): Promise<Partial<ResearchState>> {
87
+ const isRevision = state.planRevisionCount > 0 && state.rejectionFeedback;
88
+
89
+ const system = `
90
+ You are the Thinker node of DeepTrust, an autonomous research agent.
91
+ Your sole job is to decompose a research question into a concrete, step-by-step plan.
92
+
93
+ Return ONLY a valid JSON object that matches this TypeScript type:
94
+ {
95
+ "objective": string,
96
+ "steps": Array<{
97
+ "id": string (UUID v4),
98
+ "tool": "web_search" | "document_fetch" | "code_interpreter" | "summarize",
99
+ "input": string,
100
+ "rationale": string
101
+ }>,
102
+ "estimatedTokenBudget": number,
103
+ "createdAt": string (ISO 8601),
104
+ "revision": number
105
+ }
106
+
107
+ Rules:
108
+ - Maximum 20 steps.
109
+ - Every step must have a clear rationale.
110
+ - Do not include markdown fences or any prose outside the JSON object.
111
+ `.trim();
112
+
113
+ let revisionContext = isRevision
114
+ ? `\n\nPREVIOUS PLAN WAS REJECTED. Auditor feedback:\n${state.rejectionFeedback}\n\nRevision #${state.planRevisionCount + 1}: Produce a corrected plan.`
115
+ : "";
116
+
117
+ let knowledgeBlock = "";
118
+ if (state.knowledgeContext?.trim()) {
119
+ knowledgeBlock = `\n\nThe user provided the following retrieved context from their local knowledge base. Use it to inform the plan and prefer steps that leverage this context where relevant:\n\n${state.knowledgeContext}`;
120
+ }
121
+ if (state.contextUrls?.length) {
122
+ knowledgeBlock += `\n\nThe user also referenced these URLs (consider document_fetch steps for them where useful): ${state.contextUrls.join(", ")}`;
123
+ }
124
+
125
+ let userMessage = `Research question: "${state.userQuery}"${revisionContext}${knowledgeBlock}`;
126
+
127
+ let lastRawThought: string | null = null;
128
+ let lastParseError: string | null = null;
129
+
130
+ for (let attempt = 1; attempt <= MAX_PLAN_ATTEMPTS; attempt++) {
131
+ const parseFeedback =
132
+ lastParseError &&
133
+ `\n\nYour previous response had validation errors. Fix them and return ONLY valid JSON:\n${lastParseError}`;
134
+
135
+ const rawThought = await chatComplete(
136
+ system,
137
+ userMessage + (parseFeedback ?? "")
138
+ );
139
+ lastRawThought = rawThought;
140
+
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = extractJSON(rawThought);
144
+ } catch {
145
+ lastParseError = `Could not parse as JSON. Output started with: ${rawThought.slice(0, 200)}`;
146
+ continue;
147
+ }
148
+
149
+ const normalized = normalizePlan(
150
+ typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {}
151
+ );
152
+ const withRevision = {
153
+ ...normalized,
154
+ revision: state.planRevisionCount,
155
+ };
156
+
157
+ const result = ResearchPlan.safeParse(withRevision);
158
+ if (result.success) {
159
+ const plan = result.data;
160
+ const reasoning = appendReasoning(state, {
161
+ node: "thinker",
162
+ summary: isRevision
163
+ ? `Revised plan (attempt ${state.planRevisionCount + 1}): ${plan.steps.length} steps for "${plan.objective}"`
164
+ : `Initial plan created: ${plan.steps.length} steps for "${plan.objective}"`,
165
+ rawThought,
166
+ });
167
+
168
+ return {
169
+ plan,
170
+ status: "thinking",
171
+ rejectionFeedback: null,
172
+ reasoning,
173
+ updatedAt: new Date().toISOString(),
174
+ };
175
+ }
176
+
177
+ lastParseError = formatZodErrors(result.error.issues);
178
+ }
179
+
180
+ // Fallback: if the model failed to produce valid JSON after all attempts,
181
+ // synthesize a minimal but valid single-step plan directly from the user query
182
+ // so the run can still proceed instead of hard-failing.
183
+ const fallbackPlanResult = ResearchPlan.safeParse({
184
+ objective:
185
+ typeof state.userQuery === "string" && state.userQuery.trim().length > 0
186
+ ? state.userQuery.trim()
187
+ : "Research objective",
188
+ steps: [
189
+ {
190
+ id: uuidv4(),
191
+ tool: "web_search",
192
+ input:
193
+ typeof state.userQuery === "string" && state.userQuery.trim().length > 0
194
+ ? state.userQuery.trim()
195
+ : "Initial research query",
196
+ rationale:
197
+ typeof lastRawThought === "string" && lastRawThought.trim().length > 0
198
+ ? lastRawThought.slice(0, 500)
199
+ : "Initial search to understand the question.",
200
+ },
201
+ ],
202
+ estimatedTokenBudget: 2048,
203
+ createdAt: new Date().toISOString(),
204
+ revision: state.planRevisionCount,
205
+ });
206
+
207
+ if (fallbackPlanResult.success) {
208
+ const plan = fallbackPlanResult.data;
209
+ const reasoning = appendReasoning(state, {
210
+ node: "thinker",
211
+ summary: `Fallback plan created after ${MAX_PLAN_ATTEMPTS} failed JSON attempts: ${plan.steps.length} step for "${plan.objective}"`,
212
+ rawThought: lastRawThought ?? undefined,
213
+ });
214
+
215
+ return {
216
+ plan,
217
+ status: "thinking",
218
+ rejectionFeedback: null,
219
+ reasoning,
220
+ updatedAt: new Date().toISOString(),
221
+ };
222
+ }
223
+
224
+ throw new Error(
225
+ `Thinker failed to produce a valid plan after ${MAX_PLAN_ATTEMPTS} attempts. Last validation errors:\n${lastParseError ?? "unknown"}\n\nLast output (excerpt): ${(lastRawThought ?? "").slice(0, 400)}`
226
+ );
227
+ }
228
+
lib/agent/nodes/tool-executor.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tool Executor Node — executes research plan steps
3
+ */
4
+
5
+ import { ResearchState, ResearchPlan, ResearchStep, appendReasoning } from "../state";
6
+
7
+ /**
8
+ * Executes one step at a time (the step at `currentStepIndex`).
9
+ * After each execution the graph loops back through the router;
10
+ * when all steps are complete it advances to synthesizer.
11
+ *
12
+ * Real tool integrations (Tavily, Playwright, etc.) replace the
13
+ * stub `dispatchTool` function.
14
+ */
15
+ export async function toolExecutorNode(
16
+ state: ResearchState
17
+ ): Promise<Partial<ResearchState>> {
18
+ if (!state.plan) throw new Error("toolExecutorNode called with no plan");
19
+
20
+ const step = state.plan.steps[state.currentStepIndex];
21
+ if (!step) throw new Error(`No step at index ${state.currentStepIndex}`);
22
+
23
+ // Dispatch the tool
24
+ const output = await dispatchTool(step.tool, step.input);
25
+
26
+ // Persist the output back into the plan's steps array immutably
27
+ const updatedSteps = state.plan.steps.map((s, i) =>
28
+ i === state.currentStepIndex
29
+ ? { ...s, output, executedAt: new Date().toISOString() }
30
+ : s
31
+ );
32
+
33
+ const updatedPlan = ResearchPlan.parse({
34
+ ...state.plan,
35
+ steps: updatedSteps,
36
+ });
37
+
38
+ const reasoning = appendReasoning(state, {
39
+ node: "tool_executor",
40
+ summary: `Executed step ${state.currentStepIndex + 1}/${state.plan.steps.length}: [${step.tool}] "${step.input.slice(0, 80)}…"`,
41
+ rawThought: output,
42
+ });
43
+
44
+ return {
45
+ plan: updatedPlan,
46
+ currentStepIndex: state.currentStepIndex + 1,
47
+ status: "executing",
48
+ reasoning,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Dispatch a tool call. Replace stubs with real implementations.
55
+ */
56
+ async function dispatchTool(
57
+ tool: ResearchStep["tool"],
58
+ input: string
59
+ ): Promise<string> {
60
+ switch (tool) {
61
+ case "web_search":
62
+ // e.g. return await tavilySearch(input);
63
+ return `[STUB] web_search result for: "${input}"`;
64
+ case "document_fetch":
65
+ // e.g. return await fetchDocument(input);
66
+ return `[STUB] document_fetch result for: "${input}"`;
67
+ case "code_interpreter":
68
+ // e.g. return await runSandboxedCode(input);
69
+ return `[STUB] code_interpreter result for: "${input}"`;
70
+ case "summarize":
71
+ // e.g. return await summarizeText(input);
72
+ return `[STUB] summarize result for: "${input}"`;
73
+ default: {
74
+ const exhaustiveCheck: never = tool;
75
+ throw new Error(`Unknown tool: ${exhaustiveCheck}`);
76
+ }
77
+ }
78
+ }
79
+
lib/agent/routing.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Routing functions — conditional edges for the state graph
3
+ */
4
+
5
+ import { END } from "@langchain/langgraph";
6
+ import { ResearchState } from "./state";
7
+
8
+ /**
9
+ * After auditor: loop back to thinker or advance to HITL gate.
10
+ */
11
+ export function routeAfterAudit(
12
+ state: ResearchState
13
+ ): "thinker" | "hitl_gate" | typeof END {
14
+ // Hard stop — too many failed revisions
15
+ if (state.planRevisionCount >= state.maxPlanRevisions) {
16
+ return END;
17
+ }
18
+ if (state.auditResult?.verdict !== "approved") {
19
+ return "thinker";
20
+ }
21
+ return "hitl_gate";
22
+ }
23
+
24
+ /**
25
+ * After HITL gate: block until human approves, then execute.
26
+ */
27
+ export function routeAfterHitl(
28
+ state: ResearchState
29
+ ): "tool_executor" | typeof END {
30
+ if (!state.humanApproved) {
31
+ // Graph should not reach here without approval; fail safe.
32
+ return END;
33
+ }
34
+ return "tool_executor";
35
+ }
36
+
37
+ /**
38
+ * After each tool step: keep executing or move to synthesizer.
39
+ */
40
+ export function routeAfterToolStep(
41
+ state: ResearchState
42
+ ): "tool_executor" | "synthesizer" {
43
+ const totalSteps = state.plan?.steps.length ?? 0;
44
+ if (state.currentStepIndex < totalSteps) {
45
+ return "tool_executor";
46
+ }
47
+ return "synthesizer";
48
+ }
49
+
lib/agent/state.ts ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * state.ts — DeepTrust Research Agent
3
+ *
4
+ * Single source of truth for every field that flows through the
5
+ * StateGraph. Zod gives us runtime validation; the inferred TS
6
+ * types keep every node strictly typed with zero `any` leakage.
7
+ */
8
+
9
+ import { z } from "zod";
10
+
11
+ // ─────────────────────────────────────────────────────────────
12
+ // Enumerations
13
+ // ─────────────────────────────────────────────────────────────
14
+
15
+ /** Every legal node name in the graph – used to type routing returns. */
16
+ export const NodeName = z.enum([
17
+ "thinker",
18
+ "auditor",
19
+ "tool_executor",
20
+ "synthesizer",
21
+ "__end__",
22
+ ]);
23
+ export type NodeName = z.infer<typeof NodeName>;
24
+
25
+ /** Disposition returned by the Auditor node. */
26
+ export const AuditVerdict = z.enum(["approved", "rejected", "needs_revision"]);
27
+ export type AuditVerdict = z.infer<typeof AuditVerdict>;
28
+
29
+ /** Lifecycle of a single research run. */
30
+ export const RunStatus = z.enum([
31
+ "idle",
32
+ "thinking",
33
+ "awaiting_approval", // HITL interrupt point
34
+ "executing",
35
+ "synthesizing",
36
+ "complete",
37
+ "failed",
38
+ ]);
39
+ export type RunStatus = z.infer<typeof RunStatus>;
40
+
41
+ // ─────────────────────────────────────────────────────────────
42
+ // Sub-schemas
43
+ // ─────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A single step inside a research plan.
47
+ * The Thinker produces an array of these; the Tool Executor
48
+ * works through them one-by-one.
49
+ */
50
+ export const ResearchStep = z.object({
51
+ id: z.string().uuid(),
52
+ tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
53
+ input: z.string().min(1),
54
+ rationale: z.string(),
55
+ /** Populated by the Tool Executor after the step runs. */
56
+ output: z.string().optional(),
57
+ executedAt: z.string().datetime().optional(),
58
+ });
59
+ export type ResearchStep = z.infer<typeof ResearchStep>;
60
+
61
+ /**
62
+ * The structured plan produced by the Thinker and evaluated
63
+ * by the Auditor before any tool is touched.
64
+ */
65
+ export const ResearchPlan = z.object({
66
+ objective: z.string().min(1),
67
+ steps: z.array(ResearchStep).min(1).max(20),
68
+ estimatedTokenBudget: z.number().int().positive(),
69
+ createdAt: z.string().datetime(),
70
+ revision: z.number().int().nonnegative().default(0),
71
+ });
72
+ export type ResearchPlan = z.infer<typeof ResearchPlan>;
73
+
74
+ /**
75
+ * One entry in the reasoning trail — every node appends here
76
+ * so the UI can replay the full thought process.
77
+ */
78
+ export const ReasoningEntry = z.object({
79
+ node: NodeName,
80
+ timestamp: z.string().datetime(),
81
+ summary: z.string(),
82
+ /** Optional verbatim model output for deep-dive inspection. */
83
+ rawThought: z.string().optional(),
84
+ });
85
+ export type ReasoningEntry = z.infer<typeof ReasoningEntry>;
86
+
87
+ /** Structured feedback from the Auditor when it rejects a plan. */
88
+ export const AuditResult = z.object({
89
+ verdict: AuditVerdict,
90
+ policyViolations: z.array(z.string()).default([]),
91
+ suggestions: z.array(z.string()).default([]),
92
+ auditedAt: z.string().datetime(),
93
+ });
94
+ export type AuditResult = z.infer<typeof AuditResult>;
95
+
96
+ // ─────────────────────────────────────────────────────────────
97
+ // Root State Schema
98
+ // ─────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * ResearchState — the complete, checkpointable state object.
102
+ *
103
+ * LangGraph.js will serialise/deserialise this on every step,
104
+ * so every field must be JSON-safe. The checkpointer stores
105
+ * snapshots keyed by `threadId`, enabling full rehydration.
106
+ */
107
+ export const ResearchState = z.object({
108
+ // ── Identity & Persistence ───────────────────────────────
109
+ /** Stable identifier — passed to the checkpointer as the config key. */
110
+ threadId: z.string().uuid(),
111
+ /** Human-readable label shown in the UI. */
112
+ sessionName: z.string().default("Unnamed Session"),
113
+
114
+ // ── Input ────────────────────────────────────────────────
115
+ /** The raw research question submitted by the user. */
116
+ userQuery: z.string().min(1),
117
+
118
+ /**
119
+ * Retrieved context from the user's local knowledge base (client-side RAG).
120
+ * Passed into Thinker and Synthesizer so the LLM can use it when planning and reporting.
121
+ */
122
+ knowledgeContext: z.string().optional(),
123
+
124
+ /**
125
+ * URLs the user added as references. Thinker can emit document_fetch steps for these.
126
+ */
127
+ contextUrls: z.array(z.string()).optional(),
128
+
129
+ // ── Planning ─────────────────────────────────────────────
130
+ /** Current plan produced by the Thinker. Null before first plan. */
131
+ plan: ResearchPlan.nullable().default(null),
132
+
133
+ /**
134
+ * Feedback injected into the Thinker's context when the Auditor
135
+ * rejects a plan. Cleared after each successful audit.
136
+ */
137
+ rejectionFeedback: z.string().nullable().default(null),
138
+
139
+ /** How many times the plan has cycled through Thinker→Auditor. */
140
+ planRevisionCount: z.number().int().nonnegative().default(0),
141
+
142
+ /** Safety ceiling — prevents runaway revision loops. */
143
+ maxPlanRevisions: z.number().int().positive().default(5),
144
+
145
+ // ── Auditing ─────────────────────────────────────────────
146
+ /** The most recent audit result. Null before first audit. */
147
+ auditResult: AuditResult.nullable().default(null),
148
+
149
+ // ── Execution ────────────────────────────────────────────
150
+ /** Index into `plan.steps` for the currently-executing step. */
151
+ currentStepIndex: z.number().int().nonnegative().default(0),
152
+
153
+ /**
154
+ * Whether the user has explicitly approved the plan.
155
+ * The graph checks this before transitioning to tool_executor.
156
+ */
157
+ humanApproved: z.boolean().default(false),
158
+
159
+ // ── Output ───────────────────────────────────────────────
160
+ /** Final synthesized report. Null until synthesizer runs. */
161
+ finalReport: z.string().nullable().default(null),
162
+
163
+ // ── Observability ────────────────────────────────────────
164
+ /**
165
+ * Append-only log of every node's reasoning.
166
+ * The UI streams this list to render a live "thought process" view.
167
+ */
168
+ reasoning: z.array(ReasoningEntry).default([]),
169
+
170
+ // ── Lifecycle ────────────────────────────────────────────
171
+ status: RunStatus.default("idle"),
172
+
173
+ /** ISO timestamp of the last state mutation. */
174
+ updatedAt: z.string().datetime(),
175
+
176
+ /** Non-null when the run terminated with an unrecoverable error. */
177
+ errorMessage: z.string().nullable().default(null),
178
+ });
179
+
180
+ export type ResearchState = z.infer<typeof ResearchState>;
181
+
182
+ // ─────────────────────────────────────────────────────────────
183
+ // Helpers
184
+ // ─────────────────────────────────────────────────────────────
185
+
186
+ /** Append a reasoning entry without mutating the caller's reference. */
187
+ export function appendReasoning(
188
+ state: ResearchState,
189
+ entry: Omit<ReasoningEntry, "timestamp">
190
+ ): ReasoningEntry[] {
191
+ const stamped: ReasoningEntry = {
192
+ ...entry,
193
+ timestamp: new Date().toISOString(),
194
+ };
195
+ return [...state.reasoning, stamped];
196
+ }
197
+
198
+ /** Returns a fresh, validated initial state for a new session. */
199
+ export function createInitialState(
200
+ params: Pick<
201
+ ResearchState,
202
+ "threadId" | "userQuery" | "sessionName" | "knowledgeContext" | "contextUrls"
203
+ >
204
+ ): ResearchState {
205
+ return ResearchState.parse({
206
+ ...params,
207
+ updatedAt: new Date().toISOString(),
208
+ });
209
+ }
lib/agent/utils/extract-json.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * JSON extraction helper — handles models that add extra text
3
+ */
4
+
5
+ /**
6
+ * Extracts JSON from model output that may contain extra text,
7
+ * markdown fences, or other non-JSON content.
8
+ */
9
+ export function extractJSON(text: string): unknown {
10
+ // Try direct parse first
11
+ try {
12
+ return JSON.parse(text);
13
+ } catch {
14
+ // Continue to extraction methods
15
+ }
16
+
17
+ // Remove markdown code fences
18
+ const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/g, "");
19
+
20
+ // Try to find JSON object boundaries
21
+ const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
22
+ if (jsonMatch) {
23
+ try {
24
+ return JSON.parse(jsonMatch[0]);
25
+ } catch {
26
+ // Continue
27
+ }
28
+ }
29
+
30
+ // Try to find JSON array boundaries
31
+ const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
32
+ if (arrayMatch) {
33
+ try {
34
+ return JSON.parse(arrayMatch[0]);
35
+ } catch {
36
+ // Continue
37
+ }
38
+ }
39
+
40
+ throw new Error(`Could not extract valid JSON from: ${text.slice(0, 200)}...`);
41
+ }
42
+
lib/agent/utils/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export { extractJSON } from "./extract-json";
2
+ export { loadPolicy } from "./policy";
3
+
lib/agent/utils/policy.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Policy loader — reads POLICY.md for the Auditor node
3
+ */
4
+
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ const DEFAULT_POLICY = `
9
+ # DeepTrust Default Policy
10
+ - Never access personal, private, or confidential data sources.
11
+ - Never execute code that modifies the host filesystem.
12
+ - Never make more than 10 external HTTP requests per session.
13
+ - Research must be directly related to the user's stated objective.
14
+ - All sources must be attributable and verifiable.
15
+ `.trim();
16
+
17
+ /**
18
+ * Load the policy from POLICY.md or return a default fallback.
19
+ */
20
+ export function loadPolicy(): string {
21
+ try {
22
+ return readFileSync(join(process.cwd(), "POLICY.md"), "utf-8");
23
+ } catch {
24
+ return DEFAULT_POLICY;
25
+ }
26
+ }
27
+
lib/knowledge/chunk.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Text chunking for RAG. Fixed-size segments with overlap.
3
+ */
4
+
5
+ const DEFAULT_CHUNK_SIZE = 500;
6
+ const DEFAULT_OVERLAP = 80;
7
+
8
+ export function chunkText(
9
+ text: string,
10
+ chunkSize = DEFAULT_CHUNK_SIZE,
11
+ overlap = DEFAULT_OVERLAP
12
+ ): { text: string; startIndex: number; endIndex: number }[] {
13
+ const trimmed = text.trim();
14
+ if (!trimmed) return [];
15
+
16
+ const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
17
+ let start = 0;
18
+
19
+ while (start < trimmed.length) {
20
+ let end = Math.min(start + chunkSize, trimmed.length);
21
+ if (end < trimmed.length) {
22
+ const lastSpace = trimmed.lastIndexOf(" ", end);
23
+ if (lastSpace > start) end = lastSpace;
24
+ }
25
+ const slice = trimmed.slice(start, end);
26
+ if (slice.length > 0) {
27
+ chunks.push({ text: slice, startIndex: start, endIndex: end });
28
+ }
29
+ start = end - (end < trimmed.length ? overlap : 0);
30
+ if (start >= end) start = end;
31
+ }
32
+
33
+ return chunks;
34
+ }
lib/knowledge/db.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * IndexedDB schema and access for the knowledge store.
3
+ * Database: deeptrust-knowledge
4
+ * Stores: documents, chunks
5
+ */
6
+
7
+ import type { KnowledgeDocument, KnowledgeChunk } from "./types";
8
+
9
+ const DB_NAME = "deeptrust-knowledge";
10
+ const DB_VERSION = 1;
11
+ const STORE_DOCUMENTS = "documents";
12
+ const STORE_CHUNKS = "chunks";
13
+
14
+ let dbPromise: Promise<IDBDatabase> | null = null;
15
+
16
+ export function openDB(): Promise<IDBDatabase> {
17
+ if (typeof window === "undefined") {
18
+ return Promise.reject(new Error("IndexedDB is only available in the browser"));
19
+ }
20
+ if (dbPromise) return dbPromise;
21
+ dbPromise = new Promise((resolve, reject) => {
22
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
23
+ req.onerror = () => reject(req.error);
24
+ req.onsuccess = () => resolve(req.result);
25
+ req.onupgradeneeded = (event) => {
26
+ const db = (event.target as IDBOpenDBRequest).result;
27
+ if (!db.objectStoreNames.contains(STORE_DOCUMENTS)) {
28
+ db.createObjectStore(STORE_DOCUMENTS, { keyPath: "id" });
29
+ }
30
+ if (!db.objectStoreNames.contains(STORE_CHUNKS)) {
31
+ const chunkStore = db.createObjectStore(STORE_CHUNKS, { keyPath: "id" });
32
+ chunkStore.createIndex("byDocument", "documentId", { unique: false });
33
+ }
34
+ };
35
+ });
36
+ return dbPromise;
37
+ }
38
+
39
+ export async function putDocument(doc: KnowledgeDocument): Promise<void> {
40
+ const db = await openDB();
41
+ return new Promise((resolve, reject) => {
42
+ const tx = db.transaction(STORE_DOCUMENTS, "readwrite");
43
+ tx.objectStore(STORE_DOCUMENTS).put(doc);
44
+ tx.oncomplete = () => resolve();
45
+ tx.onerror = () => reject(tx.error);
46
+ });
47
+ }
48
+
49
+ export async function getDocument(id: string): Promise<KnowledgeDocument | undefined> {
50
+ const db = await openDB();
51
+ return new Promise((resolve, reject) => {
52
+ const tx = db.transaction(STORE_DOCUMENTS, "readonly");
53
+ const req = tx.objectStore(STORE_DOCUMENTS).get(id);
54
+ tx.oncomplete = () => resolve(req.result);
55
+ tx.onerror = () => reject(tx.error);
56
+ });
57
+ }
58
+
59
+ export async function listDocuments(): Promise<KnowledgeDocument[]> {
60
+ const db = await openDB();
61
+ return new Promise((resolve, reject) => {
62
+ const tx = db.transaction(STORE_DOCUMENTS, "readonly");
63
+ const req = tx.objectStore(STORE_DOCUMENTS).getAll();
64
+ tx.oncomplete = () => resolve(req.result ?? []);
65
+ tx.onerror = () => reject(tx.error);
66
+ });
67
+ }
68
+
69
+ export async function deleteDocument(id: string): Promise<void> {
70
+ const db = await openDB();
71
+ return new Promise((resolve, reject) => {
72
+ const tx = db.transaction([STORE_DOCUMENTS, STORE_CHUNKS], "readwrite");
73
+ const docStore = tx.objectStore(STORE_DOCUMENTS);
74
+ const chunkStore = tx.objectStore(STORE_CHUNKS);
75
+ docStore.delete(id);
76
+ const index = chunkStore.index("byDocument");
77
+ const range = IDBKeyRange.only(id);
78
+ const req = index.openCursor(range);
79
+ req.onsuccess = () => {
80
+ const cursor = req.result;
81
+ if (cursor) {
82
+ cursor.delete();
83
+ cursor.continue();
84
+ }
85
+ };
86
+ tx.oncomplete = () => resolve();
87
+ tx.onerror = () => reject(tx.error);
88
+ });
89
+ }
90
+
91
+ export async function putChunks(chunks: KnowledgeChunk[]): Promise<void> {
92
+ if (chunks.length === 0) return;
93
+ const db = await openDB();
94
+ return new Promise((resolve, reject) => {
95
+ const tx = db.transaction(STORE_CHUNKS, "readwrite");
96
+ const store = tx.objectStore(STORE_CHUNKS);
97
+ for (const chunk of chunks) store.put(chunk);
98
+ tx.oncomplete = () => resolve();
99
+ tx.onerror = () => reject(tx.error);
100
+ });
101
+ }
102
+
103
+ export async function getAllChunks(): Promise<KnowledgeChunk[]> {
104
+ const db = await openDB();
105
+ return new Promise((resolve, reject) => {
106
+ const tx = db.transaction(STORE_CHUNKS, "readonly");
107
+ const req = tx.objectStore(STORE_CHUNKS).getAll();
108
+ tx.oncomplete = () => resolve(req.result ?? []);
109
+ tx.onerror = () => reject(tx.error);
110
+ });
111
+ }
lib/knowledge/embeddings.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser-side embeddings via @xenova/transformers.
3
+ * Lazy-loads the pipeline on first use.
4
+ */
5
+
6
+ const MODEL = "Xenova/all-MiniLM-L6-v2";
7
+
8
+ let pipelinePromise: Promise<unknown> | null = null;
9
+
10
+ async function getPipeline(): Promise<unknown> {
11
+ if (typeof window === "undefined") {
12
+ throw new Error("Embeddings are only available in the browser");
13
+ }
14
+ if (!pipelinePromise) {
15
+ const { pipeline } = await import("@xenova/transformers");
16
+ pipelinePromise = pipeline("feature-extraction", MODEL);
17
+ }
18
+ return pipelinePromise;
19
+ }
20
+
21
+ /** Embed a single text. Returns normalized vector for cosine similarity. */
22
+ export async function embed(text: string): Promise<number[]> {
23
+ const pipe = (await getPipeline()) as (input: string, options?: { pooling?: string; normalize?: boolean }) => Promise<{ data: Float32Array }>;
24
+ const output = await pipe(text, { pooling: "mean", normalize: true });
25
+ const data = output.data;
26
+ if (!data) throw new Error("Embedding output has no data");
27
+ return Array.from(data);
28
+ }
29
+
30
+ /** Embed multiple texts in one batch (more efficient). */
31
+ export async function embedBatch(texts: string[]): Promise<number[][]> {
32
+ if (texts.length === 0) return [];
33
+ const pipe = (await getPipeline()) as (input: string | string[], options?: { pooling?: string; normalize?: boolean }) => Promise<{ data: Float32Array; dims: number[] }>;
34
+ const output = await pipe(texts, { pooling: "mean", normalize: true });
35
+ const data = output.data;
36
+ const dims = output.dims;
37
+ if (!data || !dims?.length) throw new Error("Batch embedding output has no data");
38
+ const dim = dims[dims.length - 1] ?? data.length;
39
+ const results: number[][] = [];
40
+ for (let i = 0; i < dims[0]; i++) {
41
+ const start = i * dim;
42
+ results.push(Array.from(data.slice(start, start + dim)));
43
+ }
44
+ return results;
45
+ }
46
+
47
+ export function cosineSimilarity(a: number[], b: number[]): number {
48
+ if (a.length !== b.length) return 0;
49
+ let dot = 0;
50
+ let normA = 0;
51
+ let normB = 0;
52
+ for (let i = 0; i < a.length; i++) {
53
+ dot += a[i] * b[i];
54
+ normA += a[i] * a[i];
55
+ normB += b[i] * b[i];
56
+ }
57
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
58
+ return denom === 0 ? 0 : dot / denom;
59
+ }
lib/knowledge/index.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Client-side knowledge store for RAG.
3
+ * Use from "use client" components only (IndexedDB + Transformers.js run in browser).
4
+ */
5
+
6
+ export type { KnowledgeDocument, KnowledgeChunk, KnowledgeItemMeta, RetrieveResult, DocumentType } from "./types";
7
+ export { listKnowledgeItems, addPdfFile, addNote, addUrl, removeKnowledgeDocument, retrieve } from "./store";
8
+ export { chunkText } from "./chunk";
9
+ export { extractTextFromPdf } from "./pdf";
10
+ export { embed, embedBatch, cosineSimilarity } from "./embeddings";
lib/knowledge/pdf.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PDF text extraction in the browser using pdfjs-dist.
3
+ * Only runs client-side.
4
+ */
5
+
6
+ export async function extractTextFromPdf(file: File): Promise<string> {
7
+ if (typeof window === "undefined") {
8
+ throw new Error("PDF extraction is only available in the browser");
9
+ }
10
+ const pdfjsLib = await import("pdfjs-dist");
11
+ const GlobalWorkerOptions = (pdfjsLib as unknown as { GlobalWorkerOptions?: { workerSrc: string } }).GlobalWorkerOptions;
12
+ if (GlobalWorkerOptions && !GlobalWorkerOptions.workerSrc) {
13
+ GlobalWorkerOptions.workerSrc = "https://unpkg.com/pdfjs-dist@4.7.76/build/pdf.worker.min.mjs";
14
+ }
15
+ const arrayBuffer = await file.arrayBuffer();
16
+ const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
17
+ const numPages = doc.numPages;
18
+ const parts: string[] = [];
19
+ for (let i = 1; i <= numPages; i++) {
20
+ const page = await doc.getPage(i);
21
+ const content = await page.getTextContent();
22
+ const text = content.items
23
+ .map((item) => ("str" in item ? item.str : ""))
24
+ .join("");
25
+ parts.push(text);
26
+ }
27
+ return parts.join("\n\n").trim();
28
+ }
lib/knowledge/store.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * High-level knowledge store: add documents, remove, list, retrieve.
3
+ * Coordinates IndexedDB, chunking, PDF extraction, and embeddings.
4
+ */
5
+
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import type { KnowledgeDocument, KnowledgeChunk, KnowledgeItemMeta, RetrieveResult } from "./types";
8
+ import { openDB, putDocument, putChunks, listDocuments, deleteDocument, getAllChunks } from "./db";
9
+ import { chunkText } from "./chunk";
10
+ import { extractTextFromPdf } from "./pdf";
11
+ import { embed, cosineSimilarity } from "./embeddings";
12
+
13
+ const TOP_K = 8;
14
+
15
+ function docToMeta(doc: KnowledgeDocument): KnowledgeItemMeta {
16
+ return {
17
+ id: doc.id,
18
+ type: doc.type,
19
+ label: doc.label,
20
+ meta: doc.type === "file" ? undefined : doc.url,
21
+ status: "indexed",
22
+ };
23
+ }
24
+
25
+ /** List all documents as UI items. */
26
+ export async function listKnowledgeItems(): Promise<KnowledgeItemMeta[]> {
27
+ const docs = await listDocuments();
28
+ return docs.map(docToMeta);
29
+ }
30
+
31
+ /** Add a PDF file: extract text, chunk, embed, store. */
32
+ export async function addPdfFile(file: File): Promise<KnowledgeItemMeta> {
33
+ const text = await extractTextFromPdf(file);
34
+ const id = uuidv4();
35
+ const doc: KnowledgeDocument = {
36
+ id,
37
+ type: "file",
38
+ label: file.name,
39
+ createdAt: new Date().toISOString(),
40
+ };
41
+ await putDocument(doc);
42
+ const chunks = chunkText(text);
43
+ const chunksWithEmbeddings: KnowledgeChunk[] = [];
44
+ for (let i = 0; i < chunks.length; i++) {
45
+ const ch = chunks[i];
46
+ const embedding = await embed(ch.text);
47
+ chunksWithEmbeddings.push({
48
+ id: `${id}-chunk-${i}`,
49
+ documentId: id,
50
+ text: ch.text,
51
+ embedding,
52
+ startIndex: ch.startIndex,
53
+ endIndex: ch.endIndex,
54
+ });
55
+ }
56
+ await putChunks(chunksWithEmbeddings);
57
+ return docToMeta(doc);
58
+ }
59
+
60
+ /** Add a note: chunk, embed, store. */
61
+ export async function addNote(label: string): Promise<KnowledgeItemMeta> {
62
+ const id = uuidv4();
63
+ const doc: KnowledgeDocument = {
64
+ id,
65
+ type: "note",
66
+ label: label.slice(0, 80) + (label.length > 80 ? "…" : ""),
67
+ createdAt: new Date().toISOString(),
68
+ };
69
+ await putDocument(doc);
70
+ const chunks = chunkText(label);
71
+ const chunksWithEmbeddings: KnowledgeChunk[] = [];
72
+ for (let i = 0; i < chunks.length; i++) {
73
+ const ch = chunks[i];
74
+ const embedding = await embed(ch.text);
75
+ chunksWithEmbeddings.push({
76
+ id: `${id}-chunk-${i}`,
77
+ documentId: id,
78
+ text: ch.text,
79
+ embedding,
80
+ startIndex: ch.startIndex,
81
+ endIndex: ch.endIndex,
82
+ });
83
+ }
84
+ if (chunksWithEmbeddings.length === 0) {
85
+ const embedding = await embed(label);
86
+ chunksWithEmbeddings.push({
87
+ id: `${id}-chunk-0`,
88
+ documentId: id,
89
+ text: label,
90
+ embedding,
91
+ startIndex: 0,
92
+ endIndex: label.length,
93
+ });
94
+ }
95
+ await putChunks(chunksWithEmbeddings);
96
+ return docToMeta(doc);
97
+ }
98
+
99
+ /** Add a URL as reference (no fetch in v1). Stored as document with one placeholder chunk so we can return contextUrls. */
100
+ export async function addUrl(url: string): Promise<KnowledgeItemMeta> {
101
+ const id = uuidv4();
102
+ const doc: KnowledgeDocument = {
103
+ id,
104
+ type: "url",
105
+ label: url,
106
+ url,
107
+ createdAt: new Date().toISOString(),
108
+ };
109
+ await putDocument(doc);
110
+ const embedding = await embed(`URL: ${url}`);
111
+ await putChunks([
112
+ {
113
+ id: `${id}-chunk-0`,
114
+ documentId: id,
115
+ text: url,
116
+ embedding,
117
+ startIndex: 0,
118
+ endIndex: url.length,
119
+ },
120
+ ]);
121
+ return docToMeta(doc);
122
+ }
123
+
124
+ /** Remove a document and all its chunks. */
125
+ export async function removeKnowledgeDocument(id: string): Promise<void> {
126
+ await deleteDocument(id);
127
+ }
128
+
129
+ /** Retrieve relevant context for a query: embed query, top-k similarity, build retrievedContext + contextUrls. */
130
+ export async function retrieve(query: string): Promise<RetrieveResult> {
131
+ const chunks = await getAllChunks();
132
+ if (chunks.length === 0) {
133
+ return { retrievedContext: "", contextUrls: [] };
134
+ }
135
+ const docs = await listDocuments();
136
+ const docMap = new Map(docs.map((d) => [d.id, d]));
137
+ const queryEmbedding = await embed(query);
138
+ const withScore = chunks.map((ch) => ({
139
+ chunk: ch,
140
+ score: cosineSimilarity(ch.embedding, queryEmbedding),
141
+ }));
142
+ withScore.sort((a, b) => b.score - a.score);
143
+ const top = withScore.slice(0, TOP_K);
144
+ const parts: string[] = [];
145
+ const urlSet = new Set<string>();
146
+ for (const { chunk } of top) {
147
+ const doc = docMap.get(chunk.documentId);
148
+ const source = doc ? doc.label : "Unknown";
149
+ parts.push(`[${source}]\n${chunk.text}`);
150
+ if (doc?.type === "url" && doc.url) urlSet.add(doc.url);
151
+ }
152
+ return {
153
+ retrievedContext: parts.join("\n\n"),
154
+ contextUrls: Array.from(urlSet),
155
+ };
156
+ }
lib/knowledge/types.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Client-side knowledge store types.
3
+ * Documents and chunks are persisted in IndexedDB.
4
+ */
5
+
6
+ export type DocumentType = "file" | "url" | "note";
7
+
8
+ export interface KnowledgeDocument {
9
+ id: string;
10
+ type: DocumentType;
11
+ label: string;
12
+ url?: string;
13
+ createdAt: string;
14
+ }
15
+
16
+ export interface KnowledgeChunk {
17
+ id: string;
18
+ documentId: string;
19
+ text: string;
20
+ embedding: number[];
21
+ startIndex: number;
22
+ endIndex: number;
23
+ }
24
+
25
+ /** UI-facing item (matches existing KnowledgeItem shape). */
26
+ export interface KnowledgeItemMeta {
27
+ id: string;
28
+ type: DocumentType;
29
+ label: string;
30
+ meta?: string;
31
+ status?: "pending" | "indexing" | "indexed" | "error";
32
+ }
33
+
34
+ export interface RetrieveResult {
35
+ retrievedContext: string;
36
+ contextUrls: string[];
37
+ }
next.config.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // Required for @huggingface/transformers in API routes: use Node runtime
5
+ // for onnxruntime-node instead of bundling (which can break or hang).
6
+ serverExternalPackages: ["onnxruntime-node"],
7
+ // Smaller Docker image: output standalone server + minimal node_modules
8
+ output: "standalone",
9
+ };
10
+
11
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "deeptrust",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "npm run build:worker && next build --webpack",
8
+ "build:worker": "tsc -p tsconfig.worker.json",
9
+ "start": "next start",
10
+ "lint": "eslint"
11
+ },
12
+ "dependencies": {
13
+ "@anthropic-ai/sdk": "^0.78.0",
14
+ "@huggingface/transformers": "^3.8.1",
15
+ "@xenova/transformers": "^2.17.2",
16
+ "next": "16.1.6",
17
+ "pdfjs-dist": "^4.7.76",
18
+ "ollama": "^0.6.3",
19
+ "react": "19.2.3",
20
+ "react-dom": "19.2.3"
21
+ },
22
+ "devDependencies": {
23
+ "@langchain/core": "^1.1.28",
24
+ "@langchain/langgraph": "^1.1.5",
25
+ "@tailwindcss/postcss": "^4",
26
+ "@types/node": "^20",
27
+ "@types/react": "^19",
28
+ "@types/react-dom": "^19",
29
+ "@types/uuid": "^10.0.0",
30
+ "eslint": "^9",
31
+ "eslint-config-next": "16.1.6",
32
+ "install": "^0.13.0",
33
+ "npm": "^11.11.0",
34
+ "tailwindcss": "^4",
35
+ "typescript": "^5",
36
+ "uuid": "^13.0.0",
37
+ "zod": "^4.3.6"
38
+ }
39
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED