Skip to content

[Feat]: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG) #13

Description

@FireTable

Feature: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)

Now that users can upload attachments (see #12), the next step is to make those attachments searchable. This issue is for the knowledge-base pipeline that ingests uploaded files, converts them to clean markdown, runs RAG + graph-RAG over the chunks, and exposes a tool-call surface so the chat agent can ground its answers.

Relates to #12 — the KB is a downstream consumer of the R2 attachments. #12 ships the storage half; this issue ships the ingestion + retrieval half.

Goals

  1. Ingestion pipeline: take an attachment row from [Feat]: chat attachments backed by Cloudflare R2 #12 (or a fresh URL), run it through PDF/OCR → markdown → chunks → embeddings → graph edges, and persist everything in Postgres.
  2. Hybrid search: combine BM25 (Postgres tsvector) + vector similarity (pgvector) via Reciprocal Rank Fusion. Pure vector or pure BM25 is not enough; we want both.
  3. Graph RAG: extract entities + relationships during ingestion, store an explicit edge graph in Postgres (no separate Neo4j). Retrieval should be able to traverse the graph starting from chunk hits.
  4. Tool-call surface: the chat agent gets new tools (search_kb, search_graph, list_documents) that query the KB. Lazy-registered per rule [Bug]: Memory tab Socials row missing email per linked provider #10.
  5. Reuse community standards at every stage — don't write a PDF parser, don't write a chunker, don't write a graph extractor. Ship wiring code only.

Pipeline overview

[attachment row from #12 / new URL]
        │
        ▼
(1) Source fetch       ← R2 GET for uploaded files; HTTP fetch for URLs
        │
        ▼
(2) Format detection   ← content-type + magic bytes (libmagic)
        │
        ▼
(3) Clean → Markdown   ← Marker (PDF, esp. with images/formulas) / Turndown (HTML) /
        │                 plain read (txt, md); OCR via PaddleOCR / Tesseract for scans
        ▼
(4) Chunk              ← semantic / recursive chunker; preserves heading + page boundaries
        │
        ▼
(5) Embed              ← OpenAI text-embedding-3-small (or BGE-M3 self-hosted)
        │
        ▼
(6) Graph extract      ← LightRAG or Microsoft GraphRAG entity/relation extraction;
        │                 OR a lighter prompt-based extractor for low-volume docs
        ▼
(7) Persist            ← kb_documents / kb_chunks (pgvector) / kb_edges / kb_entities
        │
        ▼
(8) Background re-index ← nightly job recomputes stale chunks (not part of MVP, separate issue)

Steps (1)-(7) run as a background job triggered by the upload webhook from #12, so the chat UI never blocks on ingestion. Progress is observable in the Memory tab (or a new "Knowledge" tab — design decision).

Stage-by-stage — community-standard picks

(3) Clean → Markdown

This is the highest-friction stage; PDF parsing with images + formulas is notoriously bad if you roll your own. Community standard in 2026:

Tool What it does well Why pick it
marker by DataLab PDF → markdown with formulas (LaTeX preserved), images, tables Fastest + best accuracy for academic / mixed PDFs. PyTorch, GPU optional, CPU works.
docling by IBM PDF/DOCX/PPTX → structured markdown, deep table understanding Strong on enterprise layouts (invoices, reports). Heavier than marker.
pymupdf4llm Fast PDF → markdown via PyMuPDF; loses some structure Good for trivial PDFs (text-only). Not enough for scanned or formula-heavy.
marker-pdf (newer name) Same as marker Track under the same project.

Pick: marker as the default; fall back to docling for documents where marker chokes (tables with merged cells). Don't write our own. Both are MIT/Apache.

OCR for scanned PDFs / images

The project is TS-driven, but the PDF/OCR ecosystem is Python. Three options:

Option Pros Cons
A. Sidecar Python service in docker-compose.yml Run marker + PaddleOCR as a separate container, expose a small HTTP API. Most flexible. One more service to operate.
B. Deno Deploy Sandbox for execute_code (already wired) Already have it via #10 rule; can we run Python in it? Deno Deploy Sandbox is TS-first; Python requires their Python sandbox beta — verify it handles PyTorch / heavy ML libs.
C. Hosted API (e.g. Mistral OCR, AWS Textract) No infra. Cost + external dependency, breaks self-host-first stance.

Recommendation: option A — a tiny Python sidecar (backend/kb/) running marker + a thin FastAPI. PyTorch image is heavy but well-known; Caddy / start.sh already manage multiple services, so adding one more is small. Mounted volume for any model weights.

(4) Chunking

Tool Notes
chonkie Modern, very fast, semantic + recursive chunkers. Py-first.
LangChain RecursiveCharacterTextSplitter (TS) Available today in our TS stack.
LlamaIndex SentenceSplitter Py; ships with full LlamaIndex pipeline.

Pick: chonkie for its semantic chunker (preserves heading hierarchy from marker output). The TS LangChain splitter is fine as a fallback for pure-text inputs.

(5) Embeddings

Hosted: OpenAI text-embedding-3-small (1536 dims, cheap). Self-host: BGE-M3 via Hugging Face TEI.

Recommendation: hosted OpenAI for MVP (we already depend on it for the chat model — one fewer moving part). BGE-M3 swap is a config change later.

(6) Graph RAG

This is where it's easy to over-engineer.

Tool Notes
Microsoft GraphRAG Canonical; entity + community detection; LLM-heavy.
LightRAG Lighter, incremental, similar API surface.
LlamaIndex PropertyGraphIndex (Py) Integrates with the LlamaIndex pipeline.
nano-graphrag Tiny single-file implementation; educational, not production.

Pick: LightRAG for the MVP. Microsoft GraphRAG's community detection is overkill until we know our corpus shape. LightRAG also handles incremental updates, which we need because new uploads arrive continuously.

Entity + edge extraction happens during ingestion (LLM call per chunk batch), then persisted to kb_entities + kb_edges. Retrieval uses the resulting graph to expand a query's neighborhood.

Vector + BM25 store

Already running Postgres 16. Add:

  • pgvector extension — vector similarity (<=> operator).
  • Built-in tsvector + pg_trgm — BM25-style full-text + fuzzy. Don't pull in a separate search engine.

Reciprocal Rank Fusion (RRF) in a single SQL query or in TS — pick SQL so the SQL stays the source of truth for ranking.

DB schema additions

All under lib/kb/schema.ts (mirrors lib/memory/, lib/threads/). New tables:

// One row per ingested source.
export const kbDocument = pgTable("kb_document", {
  id: text("id").primaryKey(),
  userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
  // FK to attachment.id (#12) when ingested from an upload; null for URL-sourced.
  attachmentId: text("attachment_id").references(() => attachment.id, { onDelete: "set null" }),
  // Source identity — null when attachmentId is set.
  sourceUrl: text("source_url"),
  title: text("title").notNull(),
  contentType: text("content_type").notNull(),
  markdown: text("markdown").notNull(),         // cleaned output from (3)
  status: text("status").notNull(),             // 'pending' | 'ingesting' | 'ready' | 'failed'
  errorMessage: text("error_message"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => [index("kb_document_user_idx").on(t.userId)]);

// Chunks with their embedding + tsvector.
export const kbChunk = pgTable("kb_chunk", {
  id: text("id").primaryKey(),
  documentId: text("document_id").notNull().references(() => kbDocument.id, { onDelete: "cascade" }),
  ordinal: integer("ordinal").notNull(),         // order within the document
  content: text("content").notNull(),
  embedding: vector("embedding", { dimensions: 1536 }),   // pgvector
  tsv: generatedAlwaysAs(tsvector("content")),            // Postgres generated tsvector
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => [
  index("kb_chunk_document_idx").on(t.documentId),
  index("kb_chunk_embedding_idx").using("hnsw", t.embedding.op("vector_cosine_ops")),  // pgvector HNSW
  index("kb_chunk_tsv_idx").using("gin", t.tsv),                                          // GIN for tsvector
]);

// Entities extracted by LightRAG / equivalent.
export const kbEntity = pgTable("kb_entity", {
  id: text("id").primaryKey(),
  documentId: text("document_id").notNull().references(() => kbDocument.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  type: text("type"),                            // 'person' | 'org' | 'concept' | …
  description: text("description"),
}, (t) => [
  index("kb_entity_document_idx").on(t.documentId),
  index("kb_entity_name_idx").on(t.name),
]);

// Edges (relations) between entities.
export const kbEdge = pgTable("kb_edge", {
  id: text("id").primaryKey(),
  documentId: text("document_id").notNull().references(() => kbDocument.id, { onDelete: "cascade" }),
  fromEntityId: text("from_entity_id").notNull().references(() => kbEntity.id, { onDelete: "cascade" }),
  toEntityId: text("to_entity_id").notNull().references(() => kbEntity.id, { onDelete: "cascade" }),
  relation: text("relation").notNull(),         // 'works_for', 'cites', 'mentions', …
  weight: real("weight").default(1.0),
}, (t) => [index("kb_edge_from_idx").on(t.fromEntityId),
            index("kb_edge_to_idx").on(t.toEntityId)]);

// Citation back-reference — connects a chat message to the chunks that grounded it.
export const kbCitation = pgTable("kb_citation", {
  id: text("id").primaryKey(),
  messageId: text("message_id").notNull(),      // LangGraph message id
  chunkId: text("chunk_id").notNull().references(() => kbChunk.id, { onDelete: "cascade" }),
  score: real("score").notNull(),                // similarity / RRF score
  rank: integer("rank").notNull(),              // 0-indexed position in the merged ranking
}, (t) => [index("kb_citation_message_idx").on(t.messageId),
            index("kb_citation_chunk_idx").on(t.chunkId)]);

user_id is on kb_document (not duplicated on chunks) — per-user isolation is enforced by joining through kb_document. Cross-user reads return 404 (rule from docs/AUTH.md).

Add migrations; update docs/DB.md (rule #1 spirit).

Tool-call surface (rule #10 — lazy-register)

Add to backend/tool/:

// null when pgvector extension isn't available or KB tables are empty.
export const searchKbTool: StructuredTool | null = process.env.PG_EXTENSION_PGVECTOR
  ? tool(/* hybrid RRF query: BM25 + vector + graph expansion */, { name: "search_kb", schema:  })
  : null;

export const searchGraphTool: StructuredTool | null = process.env.PG_EXTENSION_PGVECTOR
  ? tool(/* entity-seeded traversal */, { name: "search_graph", schema:  })
  : null;

export const listDocumentsTool: StructuredTool | null = tool(/* paginated list of kb_document rows */, );

When KB is empty / unconfigured, the model gracefully degrades — search_kb returns [], the agent continues without grounding (existing behavior). Per rule #10.

When the model cites a chunk, the ToolMessage payload should include the citation row ids so the frontend (or the citation-saving side-effect on triggerBackgroundAgentNode) can persist kb_citation rows. Update docs/TOOLS.md (rule #10) and docs/INTERRUPT.md (rule #10).

Frontend — Settings → Knowledge Base tab

Add a Knowledge Base tab under /settings, alongside the existing Memory tab. Style mirrors components/settings/memory-view.tsx (same bg-muted/30 chrome, same collapsible sections, same shadcn primitives — rule #11).

What it shows

For the current user only — userId is the only filter applied at the API and at the render layer:

  • Document list (paginated): one row per kb_document, columns
    • Title (truncate, hover for full)
    • Source badge: attachment (from [Feat]: chat attachments backed by Cloudflare R2 #12) or URL
    • Status: pending / ingesting / ready / failed with icon
      • pending<Loader2 /> spinner
      • ingesting<Loader2 /> + progress bar (from the sidecar's progress callback if available)
      • ready<CheckCircle2 />
      • failed<AlertCircle /> + hover for error_message
    • createdAt (relative — formatDistanceToNow style we already use elsewhere)
    • Action menu: View, Retry (failed only), Delete
  • Row detail (lazy-loaded on click, same pattern as components/observability/):
    • First N chunks with their text + a citation count
    • Entity list (extracted names) with click-through to search_graph
    • Markdown preview pane (read-only, scrollable) — XSS-safe render of the cleaned output
  • Empty state: "No documents yet — upload a file or paste a URL to get started."
  • Stats card at the top: total documents, total chunks, total entities (matches the existing observability stat-card pattern in aggregate.ts)

Why this layout

  • Mirrors memory-view.tsx so users (and the implementation cost) don't have to learn a new pattern. rule [Feat]: Public landing page at / (replace auto-redirect to /chat) #11 says "use the existing primitives."
  • Per-user scoping is enforced server-side (where(eq(kbDocument.userId, user.id))), but the UI never suggests otherwise — there's no "share document" button, no org filter, no global search.
  • The lazy-loaded row detail matches lib/observability/transform.ts + components/observability/ — we don't ship the entire chunk list with every list response.

Routing

app/settings/page.tsx already hosts the tabs (Memory is one). The new tab uses the same basePaths={{ settings: "/settings" }} plumbing from app/auth-shell.tsx (which is currently ["memory"]-only) — extend the tabs config to include "knowledge-base", then mirror the memory-view.tsx component as components/settings/kb-view.tsx.

The AuthProvider's settings-tab system supports multiple tabs via the plugin pattern (see how memorySettingsPlugin is registered today); the KB tab is a sibling plugin / extension — keep it consistent with whatever pattern Memory uses, don't fork the layout.

Chat composer — @ mention a knowledge-base document

In addition to the settings tab, the chat composer must let users mention a KB document inline (à la Slack / Notion). Typing @ in the composer opens a popover listing the user's ready documents; selecting one inserts a chip that travels with the message and grounds the model's response.

assistant-ui's built-in mention system

Verified against @assistant-ui/react@0.14.26/dist/unstable/useMentionAdapter.d.ts (installed in this repo):

import { unstable_useMentionAdapter } from "@assistant-ui/react";

const kbMention = unstable_useMentionAdapter({
  items: docs.map((d) => ({
    id: d.id,
    type: "kb-document",
    label: d.title,
    description: `${d.chunkCount} chunks · ${d.status}`,
    icon: "kb-doc",
    metadata: { attachmentId: d.attachmentId, sourceUrl: d.sourceUrl },
  })),
  iconMap: { "kb-doc": KbDocIcon },
  onInserted: (item) => console.log("inserted", item),
});

This returns { adapter, directive, iconMap, fallbackIcon } which spreads into <ComposerPrimitive.Unstable_TriggerPopover char="@" {...kbMention} />. The popover + arrow-key navigation + filtering come for free.

References:

  • unstable_useMentionAdapterdist/unstable/useMentionAdapter.d.ts:66-71.
  • Unstable_Mention shape — id | type | label | description? | icon? | metadata?.
  • Unstable_TriggerPopover primitives — dist/primitives/composer.d.ts:23 (under ComposerPrimitive).
  • Doc: https://www.assistant-ui.com/docs/guides/triggers (canonical "Triggers / Mentions" guide).

Backend-side resolution of the mention

The mention directive rides the message as a directive string (e.g. @[kb-document:abc123]). Two viable resolutions; pick one and document it:

  1. Server-side pre-fetch (recommended): the chat runtime intercepts the directive in backend/agent.ts (likely in callModelNode or a new pre-model hook), resolves the IDs against kb_document (user-scoped 404 check), runs the hybrid search / top-k chunk retrieval, and injects the chunks into the model context as a <mentioned-documents> block. Pro: deterministic, no tool round-trip latency, works with any model. Con: chat latency grows with top_k × chunk_size.
  2. Tool-call resolution: strip the directive before sending, let the model see the bare @kb-document:abc123 token, and have it call search_kb({ documentId: "abc123" }). Pro: model picks what to read. Con: extra round-trips, and the model might skip the call.

Recommendation: option 1 — pre-fetch the first K chunks per mentioned doc, append a <mentioned-documents> system block. Keep top_k small (default 4) so latency stays bounded. The search_kb tool stays available for the model to dive deeper when the user asks follow-ups.

Edge cases

  • Multiple mentions in one message — pre-fetch each in parallel; budget the total chunks (e.g. min(top_k, 32 / mentionCount)) so the prompt doesn't explode.
  • Stale doc — if a kb_document is failed or deleted between mention and send, the directive resolves to a no-op + a soft warning appended to the system message.
  • Cross-user mention — already impossible: the popover only lists kb_document rows for the current userId; even if a crafted message arrived, the resolution query enforces the user filter (returns 404 → silently dropped).
  • Self-reference — a user mentioning their own document is the common case; no special handling needed.
  • Composer focus + accessibility — the popover is keyboard-navigable out of the box (assistant-ui's popover handles arrow keys, Enter, Esc). Don't override.

TDD

  1. tests/frontend/chat/kb-mention.test.tsx — render the composer with unstable_useMentionAdapter mock-injected with two fake docs; type @; assert the popover shows the labels; arrow-down + Enter; assert the chip is inserted.
  2. tests/backend/chat/resolve-mentions.test.ts — given a message containing @[kb-document:abc123]; assert the resolver calls searchKbTool's hybrid search and the resulting chunks land in the system message.
  3. tests/backend/chat/resolve-mentions.test.ts — given an unknown / deleted / other-user's doc id; assert the mention is silently dropped + no tool call is made.
  4. XSS: titles in the popover are rendered as text (assistant-ui handles this), but a render test confirms no <script> survives the metadata round-trip.

Acceptance (chat-mention specific)

  • Typing @ in the composer opens a popover listing only the current user's ready documents.
  • Selecting a doc inserts a chip; the chip travels with the message.
  • On send, the model receives the document's relevant chunks and cites them in its reply.
  • Failed / deleted / unknown mentions don't crash the chat; they're dropped silently.
  • Cross-user mentions are impossible end-to-end (no API path exposes another user's doc).
  • Keyboard navigation works (arrow keys, Enter, Esc).
  • No regression to the existing attachment upload flow ([Feat]: chat attachments backed by Cloudflare R2 #12) — composer can carry both attachments AND mentions simultaneously.

Acceptance (frontend-specific)

  • Settings → Knowledge Base tab renders with the same chrome as the Memory tab.
  • Lists only the current user's documents; no UI affordance to view other users' data.
  • Status icons match the four states; failed rows expose error_message on hover.
  • Row click lazy-loads the detail (chunks + entities + markdown preview); not in the initial list payload.
  • Empty state matches the existing memory-view.tsx empty-state style.
  • Delete + Retry actions call the right API routes (DELETE /api/kb/documents/[id], POST /api/kb/documents/[id]/retry).
  • XSS: title, source URL, and markdown preview are rendered as text (or sanitized markdown) — never as raw HTML. Add a render test with <script> in title.

Architecture choices to confirm before implementing

These are real decisions, not bikeshedding. Surface them so the user (or this issue's reviewer) can pick:

  1. Python sidecar vs Deno Deploy Sandbox: confirmed sidecar — but verify Deno Deploy's Python sandbox tier can run marker + PyTorch before committing. If yes, drop the sidecar.
  2. Embedding model: OpenAI text-embedding-3-small for MVP. BGE-M3 swap requires TEI container (one more service).
  3. Graph RAG flavor: LightRAG. Microsoft GraphRAG's community detection is heavy and our corpus shape is unknown.
  4. Chunking: chonkie semantic chunker, with LangChain recursive as a pure-text fallback.
  5. Where the ingestion job runs: the existing background_agent graph (extend it) vs a new ingest_kb_agent graph registered in langgraph.json. Prefer the latter — clean separation, parallel branch.

Out of scope

  • OCR on screenshots inside PDFs — marker's image pipeline handles extraction, but we do NOT OCR the extracted images for inline text in this MVP. Future issue.
  • Multi-tenant scoping — KB is per-user only; no orgs / shared workspaces yet.
  • Incremental re-ingestion on file edit — re-ingestion is whole-document for now.
  • Citation UI in the chat thread — the data model is in place; rendering is a follow-up polish issue.
  • Query rewriting / HyDE / re-ranking — start with raw hybrid + graph expansion; add re-ranking once we have evaluation data.

Acceptance criteria

  • User uploads a PDF (the kind with tables, images, formulas) → it appears in the KB with searchable markdown.
  • User uploads a PNG / scanned image → OCR runs, text is searchable.
  • User pastes a URL → markdown is extracted and stored.
  • Document appears in the Settings → Knowledge Base tab with the correct status, scoped to the current userId only.
  • search_kb returns relevant chunks via hybrid (vector + BM25), and search_graph expands with entity neighbors.
  • Tool calls produce kb_citation rows linking chat message → chunk.
  • Per-user isolation: user A cannot see user B's documents (404, matching docs/AUTH.md).
  • KB unconfigured → tools gracefully no-op, model behavior unchanged.
  • pnpm lint, pnpm typecheck, pnpm test green; new tests cover ingestion (mocked Python sidecar) + hybrid search SQL + tool-call lazy registration.
  • docs/APIS.md updated if new routes added (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).
  • docs/TOOLS.md updated with the new tools (rule [Bug]: Memory tab Socials row missing email per linked provider #10).
  • docs/DB.md updated with the new schema.
  • docs/INTERRUPT.md updated if the citation save side-effect changes the interrupt flow.

TDD plan (rule #2)

  1. Ingestion test (RED → GREEN): tests/api/kb/ingest.test.ts — mock the Python sidecar; assert that on POST /api/kb/ingest with an attachment id, the orchestrator:
    • Calls the sidecar's /clean endpoint.
    • Chunks the result.
    • Embeds via OpenAI (mocked).
    • Persists kb_document, kb_chunk, kb_entity, kb_edge rows.
    • Sets status to ready.
  2. Hybrid search test: tests/api/kb/search.test.ts — seed two documents with known chunks; query with a keyword + a paraphrased question; assert both BM25 and vector hits appear in the merged result, and that RRF rank is monotonic.
  3. Graph search test: tests/api/kb/graph.test.ts — seed an entity graph; query with an entity mention; assert the traversal returns neighbor chunks.
  4. Tool-call test: tests/backend/tools/search-kb.test.ts — assert searchKbTool is null when PG_EXTENSION_PGVECTOR is unset, and a working tool otherwise; mock the DB and assert the tool returns the expected schema.
  5. Citation test: tests/api/kb/citations.test.ts — when search_kb is invoked via the chat runtime, the resulting ToolMessage carries citation ids; triggerBackgroundAgent (or the new ingest_kb_agent) persists them.
  6. XSS coverage (carried over from [Feat]: chat attachments backed by Cloudflare R2 #12): markdown rendered from user-uploaded documents is sanitized before being inserted into a future thread UI; for now, the markdown itself is just stored, but list_documents returns titles / snippets that the UI must render as text, not HTML. Add a render test for that.
  7. Frontend test: tests/frontend/settings/kb-view.test.tsx — render with a mocked list response (2 ready docs, 1 failed); assert status icons + per-user scoping (no other user's rows appear even if API returns them) + delete action. RED → GREEN.
  8. Chat-mention test: tests/frontend/chat/kb-mention.test.tsx — typing @ opens the popover; arrow + Enter inserts a chip. RED → GREEN.
  9. Mention resolver test: tests/backend/chat/resolve-mentions.test.ts — message with @[kb-document:abc123] resolves to chunks in the system prompt; unknown ids are dropped silently. RED → GREEN.

Notes for the implementer

  • Don't write a PDF parser. If marker doesn't fit on a document, swap to docling, but never write our own.
  • Don't write a chunker. chonkie + LangChain cover the cases. The "one true chunker" debate is a tarpit.
  • Don't write a graph extractor from scratch. LightRAG / GraphRAG are mature; even the prompt template is opinionated and worth reusing.
  • The Python sidecar is a thin shim — it should do marker --output md, embed via HTTP to OpenAI, and return JSON. The TS orchestrator owns chunking + DB writes. That keeps the hot path in our stack and the ML model I/O in Python.
  • Don't break rule [Bug]: Memory tab Socials row missing email per linked provider #10: tools must be null when the KB isn't ready. The model should never see a tool that 500s.
  • Citation back-references are the bridge between this issue and [Feat]: chat attachments backed by Cloudflare R2 #12 — when kb_citation lands in the DB, the frontend can render hover-cards linking the message back to the source chunk / original file.
  • Per-user isolation is non-negotiable; cross-user reads must 404 (matches the existing thread-isolation pattern).

Related

  • [Feat]: chat attachments backed by Cloudflare R2 #12 — chat attachments backed by Cloudflare R2 (the upstream source of attachment rows this issue ingests; the chat-mention popover reads from this same storage pipeline once KB ingestion is wired).
  • backend/store.ts — existing Postgres-backed store for memory + thread summaries; KB reuses the same DB but adds the pgvector extension.
  • backend/background-agent.ts — existing turn-end side-effect graph; the new ingest_kb_agent mirrors this pattern.
  • backend/tool/ — lazy-registration site (rule [Bug]: Memory tab Socials row missing email per linked provider #10).
  • docs/AUTH.md § Data isolation — cross-user 404 rule.
  • docs/DB.md — schema conventions; new tables follow the same shape.
  • docs/TOOLS.md — tool inventory; must be updated with the new tools.
  • docs/APIS.md — new ingest / search endpoints (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).
  • docs/INTERRUPT.md — interrupt contract for the new tools.
  • @assistant-ui/react ContentPart — citation UI will eventually render here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions