Open Source · MIT · v0.1.0

agentic-portfolio-search

Drop-in agentic search for portfolios & docs sites: BM25 instant search plus grounded, cited Ask-AI answers that work with zero LLM keys and never invent facts. Generalized from the shared tattoo agentic search I built for Tattoo.co / tattoo.dev, with all domain-specific code removed.

Try it — this page runs the package

Architecture

request ─┬─ /api/search ─▶ BM25F index (title·tags·summary·facts) ─▶ ranked hits
         └─ /api/ask ────▶ intent match ─▶ retrieve + pin ─▶ deterministic synthesizer
                                                         └─▶ optional LLM rewrite
                                                             (sources only · [n] validated
                                                              · 6s timeout · else fallback)
UI: <AgenticPortfolioSearch/>  Instant Search ⇄ Ask AI · prompt chips · / ↑↓ ⏎ Esc · citation cards
  • Instant Search: in-memory BM25F with field weights and prefix expansion. A few hundred documents rank in under 1 ms.
  • Ask AI: host-written verified intent leads plus facts copied from the cited documents. Every claim carries a [n] citation.
  • Optional LLM rewrite: AI_GATEWAY_API_KEY, OPENAI_API_KEY, XAI_API_KEY or ANTHROPIC_API_KEY. Output with invalid citations is dropped.
  • Never 500s: JSON on every path, stale-index fallback, and a 60 s index cache.

Usage

// app/api/ask/route.ts
import { createAskHandler } from "agentic-portfolio-search/next";
import { corpus, intents } from "@/lib/corpus";

export const dynamic = "force-dynamic";
export const POST = createAskHandler({ documents: corpus, intents, subject: "Ada" });
export const GET = POST;
"use client";
import { AgenticPortfolioSearch } from "agentic-portfolio-search/react";
import "agentic-portfolio-search/styles.css";

export function Search({ docs }) {
  return (
    <AgenticPortfolioSearch
      documents={docs}
      askEndpoint="/api/ask"
      prompts={["What has Ada shipped with MCP?", "Why should we work with Ada?"]}
    />
  );
}

Source (15 files)

README.md raw ↗
# agentic-portfolio-search

Drop-in **agentic search for portfolios and docs sites**: BM25 instant search plus grounded, cited **Ask AI** answers. It works with **zero LLM keys**, and if a key is present it adds an optional LLM rewrite that is checked against the cited sources.

Live on [gavinbasuel.com](https://gavinbasuel.com/#ask). MIT licensed.

- **Instant Search:** in-memory BM25F (title, tags, summary and facts weighted separately) with prefix expansion for as-you-type ranking. A few hundred documents index in about 1 ms and rank in under 1 ms.
- **Ask AI:** a deterministic synthesizer combines host-written *verified intent leads* with facts copied from the cited documents. Every answer carries `[n]` citations. When nothing matches, it says so instead of guessing.
- **Optional LLM rewrite:** set `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY` or `ANTHROPIC_API_KEY`. The model only sees the cited sources. Output that cites a source that doesn't exist, times out (6 s) or errors is dropped, and the deterministic answer is returned instead.
- **Never 500s:** the route handlers return a JSON body on every path, serve a stale index if a corpus refresh fails, and cache the index for 60 s by default.
- **Accessible React UI:** `Instant Search` / `Ask AI` tabs, starter-prompt chips, `/` to focus, ↑↓ / ⏎ / Esc, citation cards, a `renderLink` slot (for hover previews or `next/link`), `?ask=` deep links, and `askAgenticSearch(question)` for CTAs.
- **Themeable:** `styles.css` reads host tokens (`--fg`, `--muted`, `--divider`, `--card`, `--bg`) and falls back to its own.

The architecture (shared search UI with a mode toggle, prompt chips, keyboard model, citation cards, and `/api/search` + `/api/ask` route seams) was generalized from the private shared tattoo search package I built for Tattoo.co / tattoo.dev. Everything domain-specific was removed.

## Install

Copy `src/` into your app (or add the package from a git URL), then:

```ts
// app/api/ask/route.ts
import { createAskHandler } from "agentic-portfolio-search/next";
import { corpus, intents } from "@/lib/corpus";

export const dynamic = "force-dynamic";
export const POST = createAskHandler({ documents: corpus, intents, subject: "Ada" });
export const GET = POST;
```

```ts
// app/api/search/route.ts
import { createSearchHandler } from "agentic-portfolio-search/next";
export const POST = createSearchHandler({ documents: corpus });
```

```tsx
// any client component
"use client";
import { AgenticPortfolioSearch } from "agentic-portfolio-search/react";
import "agentic-portfolio-search/styles.css";

<AgenticPortfolioSearch
  documents={docs}                 // optional: zero-latency client-side instant search
  askEndpoint="/api/ask"
  prompts={["What has Ada shipped with MCP?", "Why should we work with Ada?"]}
/>;
```

## The corpus

```ts
import type { PortfolioDocument, AnswerIntent } from "agentic-portfolio-search/core";

export const corpus = (): PortfolioDocument[] => [
  {
    id: "platform:acme-mcp",
    title: "Acme MCP",
    summary: "Remote MCP server with 4 tools.",
    url: "https://acme.dev",
    category: "Platform",
    tags: ["mcp", "cloudflare workers"],
    facts: ["Ships 4 MCP tools on Cloudflare Workers."],
  },
];

// Leads are written by you from facts you can prove; the engine cites the pinned docs.
export const intents: AnswerIntent[] = [
  { id: "mcp", match: /\bmcp\b|protocol/, lead: "Ada ships MCP as a product surface.", pin: ["platform:acme-mcp"] },
];
```

## API

| Export | From | Purpose |
| --- | --- | --- |
| `createIndex(docs)` / `search(index, q, opts)` | `core` | BM25F index and ranked hits |
| `synthesizeAnswer(index, q, opts)` | `core` | Deterministic grounded answer `{ answer, points, citations, mode, tookMs }` |
| `createAskHandler(opts)` / `createSearchHandler(opts)` | `next` | Web `Request → Response` handlers (App Router, Workers, Bun) |
| `refineWithLlm(answer, docs, cfg)` / `resolveProvider(cfg)` | `next` | Optional LLM rewrite, validated against sources |
| `<AgenticPortfolioSearch />`, `askAgenticSearch(q)` | `react` | UI and CTA trigger |

## Test

```sh
npx tsx test/smoke.ts
```

## License

MIT © 2026 Gavin Basuel
LICENSE raw ↗
MIT License

Copyright (c) 2026 Gavin Basuel

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
package.json raw ↗
{
  "name": "agentic-portfolio-search",
  "version": "0.1.0",
  "description": "Drop-in agentic search for portfolios & docs: BM25 instant search + grounded, cited Ask-AI answers that work with zero LLM keys.",
  "license": "MIT",
  "type": "module",
  "sideEffects": ["*.css"],
  "exports": {
    ".": "./src/index.ts",
    "./core": "./src/core.ts",
    "./react": "./src/react.tsx",
    "./next": "./src/next.ts",
    "./styles.css": "./src/styles.css"
  },
  "files": ["src", "README.md", "LICENSE"],
  "keywords": ["search", "bm25", "rag", "portfolio", "nextjs", "react", "ai", "agentic"],
  "peerDependencies": {
    "react": ">=18"
  },
  "peerDependenciesMeta": {
    "react": { "optional": true }
  },
  "scripts": {
    "typecheck": "tsc --noEmit -p .",
    "test": "npx tsx test/smoke.ts"
  },
  "author": "Gavin Basuel (https://gavinbasuel.com)",
  "homepage": "https://gavinbasuel.com/open-source/agentic-search"
}
src/answer.ts raw ↗
import { search, type SearchIndex } from "./bm25";
import { normalize, tokenize } from "./text";
import type { AnswerIntent, AskAnswer, Citation, PortfolioDocument, SearchHit } from "./types";

export interface AnswerOptions {
  intents?: AnswerIntent[];
  /** Subject name used in fallback copy, e.g. "Gavin". */
  subject?: string;
  maxCitations?: number;
  /** Minimum top BM25 score to answer without an intent (default 1.2). */
  minScore?: number;
  /** Suggestions shown when nothing matches. */
  suggestions?: string[];
}

export interface RetrievedContext {
  intent?: AnswerIntent;
  hits: SearchHit[];
}

const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());

export function detectIntent(question: string, intents: AnswerIntent[] = []): AnswerIntent | undefined {
  const q = normalize(question);
  return intents.find((i) => i.match.test(q));
}

/** Retrieve the grounded context (intent + ranked hits) for a question. */
export function retrieve(index: SearchIndex, question: string, opts: AnswerOptions = {}): RetrievedContext {
  const intent = detectIntent(question, opts.intents);
  const query = [question, ...(intent?.boostTerms || [])].join(" ");
  let hits = search(index, query, { limit: 12 });
  if (intent?.pin?.length) {
    const pinned = intent.pin
      .map((id) => hits.find((h) => h.doc.id === id) || docHit(index, id))
      .filter((h): h is SearchHit => Boolean(h));
    hits = [...pinned, ...hits.filter((h) => !intent.pin!.includes(h.doc.id))];
  }
  return { intent, hits };
}

function docHit(index: SearchIndex, id: string): SearchHit | undefined {
  const doc = index.docs.find((d) => d.id === id);
  return doc ? { doc, score: 0, matched: [] } : undefined;
}

function clip(text: string, max: number): string {
  if (text.length <= max) return text;
  const cut = text.slice(0, max);
  return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 20)).replace(/[\s,;:—–-]+$/, "")}…`;
}

function bestSentence(doc: PortfolioDocument, terms: Set<string>): string {
  const candidates = [...(doc.facts || []), doc.summary].filter(Boolean);
  let best = doc.summary;
  let bestScore = -1;
  candidates.forEach((c, i) => {
    const toks = tokenize(c);
    const overlap = toks.filter((t) => terms.has(t)).length;
    const score = overlap * 2 - i * 0.01 + (c === doc.summary ? 0.5 : 0);
    if (score > bestScore) {
      bestScore = score;
      best = c;
    }
  });
  return clip(best.replace(/\s+/g, " ").trim().replace(/[.;,]?$/, ""), 180);
}

export function toCitation(doc: PortfolioDocument): Citation {
  return { id: doc.id, title: doc.title, url: doc.url, category: doc.category, previewKey: doc.previewKey };
}

/**
 * Deterministic, grounded answer synthesizer. Every sentence is either a
 * host-authored verified intent lead or copied from a cited document's facts.
 * It never invents content and never throws.
 */
export function synthesizeAnswer(index: SearchIndex, question: string, opts: AnswerOptions = {}): AskAnswer {
  const t0 = now();
  const subject = opts.subject || "this portfolio";
  const q = question.trim().slice(0, 400);
  const { intent, hits } = retrieve(index, q, opts);
  const max = Math.min(opts.maxCitations ?? 5, intent?.maxPoints ?? 5);
  const pinned = new Set(intent?.pin || []);
  const firstFree = hits.find((h) => !pinned.has(h.doc.id))?.score || 0;
  // With pinned sources, allow at most one extra strong match; otherwise keep matches within 30% of the best.
  const floor = firstFree * (pinned.size ? 0.6 : 0.3);
  let extras = 0;
  const top = hits
    .filter((h) => {
      if (pinned.has(h.doc.id)) return true;
      if (h.score < floor) return false;
      if (pinned.size && extras >= 1) return false;
      extras += 1;
      return true;
    })
    .slice(0, max);
  const minScore = opts.minScore ?? 1.2;

  if (!intent && (!top.length || top[0].score < minScore)) {
    const tips = (opts.suggestions || []).slice(0, 3);
    return {
      question: q,
      answer: `I only answer from ${subject}'s verified portfolio, and nothing in it clearly matches “${q}”.${
        tips.length ? ` Try: ${tips.map((t) => `“${t}”`).join(", ")}.` : ""
      }`,
      points: [],
      citations: [],
      mode: "deterministic",
      tookMs: Math.round((now() - t0) * 10) / 10,
    };
  }

  const terms = new Set(tokenize([q, ...(intent?.boostTerms || [])].join(" ")));
  const citations = top.map((h) => toCitation(h.doc));
  const points = top.map((h, i) => `${h.doc.title} — ${bestSentence(h.doc, terms)} [${i + 1}]`);
  const answer = intent
    ? `${intent.lead.trim()}${citations.length ? ` [1]` : ""}`
    : `Best match: ${top[0].doc.title} — ${bestSentence(top[0].doc, terms)}. [1]`;

  return {
    question: q,
    answer,
    points: intent ? points : points.slice(1),
    citations,
    mode: "deterministic",
    intent: intent?.id,
    tookMs: Math.round((now() - t0) * 10) / 10,
  };
}
src/bm25.ts raw ↗
import { tokenize } from "./text";
import type { PortfolioDocument, SearchHit, SearchOptions } from "./types";

interface Field {
  weight: number;
  get: (d: PortfolioDocument) => string;
}

const FIELDS: Field[] = [
  { weight: 3.2, get: (d) => d.title },
  { weight: 2.2, get: (d) => (d.tags || []).join(" ") },
  { weight: 1.4, get: (d) => d.summary },
  { weight: 1.0, get: (d) => (d.facts || []).join(" ") },
  { weight: 0.6, get: (d) => d.body || "" },
  { weight: 0.8, get: (d) => d.category },
];

export interface SearchIndex {
  docs: PortfolioDocument[];
  /** term → docIndex → weighted term frequency */
  postings: Map<string, Map<number, number>>;
  lengths: number[];
  avgLength: number;
  /** prefix lookup for instant (as-you-type) search */
  terms: string[];
}

/** Build an in-memory BM25F index. ~1ms for a few hundred documents. */
export function createIndex(docs: PortfolioDocument[]): SearchIndex {
  const postings = new Map<string, Map<number, number>>();
  const lengths: number[] = [];
  docs.forEach((doc, i) => {
    let len = 0;
    for (const f of FIELDS) {
      const toks = tokenize(f.get(doc));
      len += toks.length * f.weight;
      for (const t of toks) {
        let m = postings.get(t);
        if (!m) postings.set(t, (m = new Map()));
        m.set(i, (m.get(i) || 0) + f.weight);
      }
    }
    lengths.push(len || 1);
  });
  const avgLength = lengths.reduce((a, b) => a + b, 0) / Math.max(1, lengths.length);
  return { docs, postings, lengths, avgLength, terms: [...postings.keys()].sort() };
}

function expand(index: SearchIndex, term: string, isLast: boolean): { term: string; factor: number }[] {
  const out = [{ term, factor: 1 }];
  if (isLast && term.length >= 2) {
    // prefix expansion for the token being typed
    for (const t of index.terms) {
      if (t !== term && t.startsWith(term)) out.push({ term: t, factor: 0.7 });
      if (out.length > 12) break;
    }
  }
  return out;
}

export function search(index: SearchIndex, query: string, opts: SearchOptions = {}): SearchHit[] {
  const k1 = 1.2;
  const b = 0.72;
  const N = index.docs.length;
  const qTerms = [...new Set(tokenize(query))];
  if (!qTerms.length) return [];
  const scores = new Map<number, { score: number; matched: Set<string> }>();
  qTerms.forEach((qt, qi) => {
    for (const { term, factor } of expand(index, qt, qi === qTerms.length - 1)) {
      const post = index.postings.get(term);
      if (!post) continue;
      const idf = Math.log(1 + (N - post.size + 0.5) / (post.size + 0.5));
      for (const [di, tf] of post) {
        const norm = tf + k1 * (1 - b + (b * index.lengths[di]) / index.avgLength);
        const s = idf * ((tf * (k1 + 1)) / norm) * factor;
        const cur = scores.get(di) || { score: 0, matched: new Set<string>() };
        cur.score += s;
        cur.matched.add(qt);
        scores.set(di, cur);
      }
    }
  });
  const hits: SearchHit[] = [];
  for (const [di, { score, matched }] of scores) {
    const doc = index.docs[di];
    if (opts.categories && !opts.categories.includes(doc.category)) continue;
    // coverage bonus: prefer docs matching more distinct query terms
    const coverage = matched.size / qTerms.length;
    const s = score * (0.55 + 0.45 * coverage) * (doc.weight ?? 1) * (opts.boost ? opts.boost(doc) : 1);
    hits.push({ doc, score: s, matched: [...matched] });
  }
  hits.sort((a, b2) => b2.score - a.score);
  return hits.slice(0, opts.limit ?? 10);
}
src/core.ts raw ↗
export * from "./types";
export { normalize, tokenize, stem } from "./text";
export { createIndex, search, type SearchIndex } from "./bm25";
export { synthesizeAnswer, retrieve, detectIntent, toCitation, type AnswerOptions } from "./answer";
src/index.ts raw ↗
export * from "./core";
export { AgenticPortfolioSearch, type AgenticPortfolioSearchProps, ASK_EVENT, askAgenticSearch } from "./react";
src/llm.ts raw ↗
import type { AskAnswer, PortfolioDocument } from "./types";

export interface LlmConfig {
  /** Env-like bag; defaults to process.env when available. */
  env?: Record<string, string | undefined>;
  timeoutMs?: number;
  /** Override model id (else a small fast default per provider). */
  model?: string;
  subject?: string;
}

type Provider = { name: string; model: string; call: (system: string, user: string, signal: AbortSignal) => Promise<string> };

function readEnv(cfg: LlmConfig): Record<string, string | undefined> {
  if (cfg.env) return cfg.env;
  return typeof process !== "undefined" && process.env ? (process.env as Record<string, string | undefined>) : {};
}

function openAICompatible(url: string, key: string, model: string): Provider["call"] {
  return async (system, user, signal) => {
    const res = await fetch(url, {
      method: "POST",
      signal,
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify({
        model,
        temperature: 0.1,
        max_tokens: 380,
        messages: [
          { role: "system", content: system },
          { role: "user", content: user },
        ],
      }),
    });
    if (!res.ok) throw new Error(`llm_${res.status}`);
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    return json.choices?.[0]?.message?.content?.trim() || "";
  };
}

/** Pick the first configured provider. Returns null when no key is present. */
export function resolveProvider(cfg: LlmConfig = {}): Provider | null {
  const env = readEnv(cfg);
  if (env.AI_GATEWAY_API_KEY) {
    const model = cfg.model || env.APS_MODEL || "openai/gpt-4o-mini";
    return { name: "ai-gateway", model, call: openAICompatible("https://ai-gateway.vercel.sh/v1/chat/completions", env.AI_GATEWAY_API_KEY, model) };
  }
  if (env.OPENAI_API_KEY) {
    const model = cfg.model || env.APS_MODEL || "gpt-4o-mini";
    return { name: "openai", model, call: openAICompatible("https://api.openai.com/v1/chat/completions", env.OPENAI_API_KEY, model) };
  }
  if (env.XAI_API_KEY) {
    const model = cfg.model || env.APS_MODEL || "grok-3-mini";
    return { name: "xai", model, call: openAICompatible("https://api.x.ai/v1/chat/completions", env.XAI_API_KEY, model) };
  }
  if (env.ANTHROPIC_API_KEY) {
    const key = env.ANTHROPIC_API_KEY;
    const model = cfg.model || env.APS_MODEL || "claude-3-5-haiku-latest";
    return {
      name: "anthropic",
      model,
      call: async (system, user, signal) => {
        const res = await fetch("https://api.anthropic.com/v1/messages", {
          method: "POST",
          signal,
          headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
          body: JSON.stringify({ model, max_tokens: 380, temperature: 0.1, system, messages: [{ role: "user", content: user }] }),
        });
        if (!res.ok) throw new Error(`llm_${res.status}`);
        const json = (await res.json()) as { content?: { text?: string }[] };
        return json.content?.map((c) => c.text || "").join("").trim() || "";
      },
    };
  }
  return null;
}

function contextBlock(docs: PortfolioDocument[]): string {
  return docs
    .map((d, i) => `[${i + 1}] ${d.title} (${d.category}${d.url ? ` · ${d.url}` : ""})\n${[d.summary, ...(d.facts || [])].join("\n")}`)
    .join("\n\n");
}

/**
 * Rewrite a deterministic answer with an LLM, grounded strictly in the same
 * cited documents. Returns null on any failure, timeout, or ungrounded output
 * so callers can keep the deterministic answer.
 */
export async function refineWithLlm(base: AskAnswer, docs: PortfolioDocument[], cfg: LlmConfig = {}): Promise<AskAnswer | null> {
  const provider = resolveProvider(cfg);
  if (!provider || !docs.length) return null;
  const subject = cfg.subject || "the portfolio owner";
  const system = [
    `You answer questions about ${subject} using ONLY the numbered sources provided.`,
    "Rules: never add facts, numbers, employers, dates or links that are not in the sources.",
    "Cite every sentence with [n] markers that refer to the sources. 2–4 short sentences, plain text, no markdown headings.",
    "If the sources do not answer the question, say so briefly.",
  ].join(" ");
  const user = `Question: ${base.question}\n\nSources:\n${contextBlock(docs)}`;
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 6000);
  const t0 = Date.now();
  try {
    const text = await provider.call(system, user, ctrl.signal);
    const refs = [...text.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
    if (!text || !refs.length || refs.some((n) => n < 1 || n > docs.length)) return null;
    return { ...base, answer: text, mode: "llm", model: `${provider.name}:${provider.model}`, tookMs: base.tookMs + (Date.now() - t0) };
  } catch {
    return null;
  } finally {
    clearTimeout(timer);
  }
}
src/next.ts raw ↗
import { createIndex, search, type SearchIndex } from "./bm25";
import { synthesizeAnswer, type AnswerOptions } from "./answer";
import { refineWithLlm, type LlmConfig } from "./llm";
import type { PortfolioDocument } from "./types";

export { resolveProvider, refineWithLlm, type LlmConfig } from "./llm";

type DocsSource = () => PortfolioDocument[] | Promise<PortfolioDocument[]>;

export interface HandlerOptions extends AnswerOptions {
  /** Return the corpus. Cached for `cacheMs` (default 60s). */
  documents: DocsSource;
  cacheMs?: number;
  /** Enable optional LLM refinement when a provider key is present (default true). */
  llm?: boolean | LlmConfig;
  /** Extra response headers (e.g. CORS). */
  headers?: Record<string, string>;
}

function cachedIndex(src: DocsSource, cacheMs = 60_000) {
  let cache: { at: number; index: SearchIndex } | null = null;
  let inflight: Promise<SearchIndex> | null = null;
  return async (): Promise<SearchIndex> => {
    if (cache && Date.now() - cache.at < cacheMs) return cache.index;
    if (!inflight) {
      inflight = Promise.resolve(src())
        .then((docs) => {
          cache = { at: Date.now(), index: createIndex(docs) };
          return cache.index;
        })
        .catch((err) => {
          if (cache) return cache.index; // serve stale on failure
          throw err;
        })
        .finally(() => {
          inflight = null;
        });
    }
    return inflight;
  };
}

const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers },
  });

async function readQuery(req: Request, key: string): Promise<string> {
  if (req.method === "GET") return new URL(req.url).searchParams.get(key) || new URL(req.url).searchParams.get("q") || "";
  const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
  return String(body[key] ?? body.q ?? body.query ?? "");
}

/** Next.js App Router handler for `POST|GET /api/search` → ranked hits. */
export function createSearchHandler(opts: HandlerOptions) {
  const getIndex = cachedIndex(opts.documents, opts.cacheMs);
  return async function handler(req: Request): Promise<Response> {
    const t0 = Date.now();
    try {
      const query = (await readQuery(req, "query")).trim().slice(0, 200);
      const index = await getIndex();
      const hits = query ? search(index, query, { limit: 12 }) : [];
      return json({ query, hits: hits.map((h) => ({ ...h, score: Math.round(h.score * 100) / 100 })), tookMs: Date.now() - t0 }, 200, opts.headers);
    } catch {
      return json({ query: "", hits: [], tookMs: Date.now() - t0, error: "search_unavailable" }, 200, opts.headers);
    }
  };
}

/** Next.js App Router handler for `POST|GET /api/ask` → grounded, cited answer. */
export function createAskHandler(opts: HandlerOptions) {
  const getIndex = cachedIndex(opts.documents, opts.cacheMs);
  return async function handler(req: Request): Promise<Response> {
    const question = (await readQuery(req, "question").catch(() => "")).trim();
    if (!question) return json({ error: "missing_question", hint: 'POST {"question": "..."}' }, 400, opts.headers);
    try {
      const index = await getIndex();
      const base = synthesizeAnswer(index, question, opts);
      if (opts.llm !== false && base.citations.length) {
        const docs = base.citations.map((c) => index.docs.find((d) => d.id === c.id)!).filter(Boolean);
        const refined = await refineWithLlm(base, docs, typeof opts.llm === "object" ? opts.llm : { subject: opts.subject });
        if (refined) return json(refined, 200, opts.headers);
      }
      return json(base, 200, opts.headers);
    } catch {
      return json(
        { question, answer: "Search is warming up — please try again in a moment.", points: [], citations: [], mode: "deterministic", tookMs: 0 },
        200,
        opts.headers,
      );
    }
  };
}
src/react.tsx raw ↗
"use client";

import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createIndex, search } from "./bm25";
import type { AskAnswer, Citation, PortfolioDocument, SearchHit } from "./types";

export const ASK_EVENT = "agentic-search:ask";

/** Programmatically open the search in Ask mode with a question (e.g. from a CTA). */
export function askAgenticSearch(question: string) {
  if (typeof window === "undefined") return;
  window.dispatchEvent(new CustomEvent(ASK_EVENT, { detail: { question } }));
}

type Mode = "search" | "ask";

export interface AgenticPortfolioSearchProps {
  /** Corpus for zero-latency client-side instant search. */
  documents?: PortfolioDocument[];
  /** Used for instant search when `documents` is not provided. */
  searchEndpoint?: string;
  askEndpoint?: string;
  prompts?: string[];
  kicker?: string;
  title?: ReactNode;
  placeholder?: string;
  askPlaceholder?: string;
  defaultMode?: Mode;
  /** Render a link (e.g. wrap in a hover-preview component). */
  renderLink?: (target: Citation, children: ReactNode, className: string) => ReactNode;
  /** Slot under the panel (e.g. an open-source badge). */
  footer?: ReactNode;
  /** Global hotkey that focuses the input (default "/"; pass null to disable). */
  hotkey?: string | null;
  /** Read `?ask=` from the URL on mount and answer it (default true). */
  readAskParam?: boolean;
  id?: string;
  className?: string;
}

const host = (url?: string) => (url ? url.replace(/^https?:\/\//, "").replace(/\/$/, "") : "");

function defaultLink(target: Citation, children: ReactNode, className: string) {
  if (!target.url) return <span className={className}>{children}</span>;
  const external = /^https?:\/\//.test(target.url);
  return (
    <a href={target.url} className={className} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined}>
      {children}
    </a>
  );
}

function withCitations(text: string, citations: Citation[], link: NonNullable<AgenticPortfolioSearchProps["renderLink"]>) {
  const parts = text.split(/(\[\d+\])/g);
  return parts.map((part, i) => {
    const m = part.match(/^\[(\d+)\]$/);
    const c = m ? citations[Number(m[1]) - 1] : undefined;
    if (!m || !c) return <Fragment key={i}>{part}</Fragment>;
    return <sup key={i}>{link(c, m[1], "aps-cite-ref")}</sup>;
  });
}

export function AgenticPortfolioSearch({
  documents,
  searchEndpoint = "/api/search",
  askEndpoint = "/api/ask",
  prompts = [],
  kicker = "Agentic Search",
  title,
  placeholder = "Search projects, roles, skills, writing…",
  askPlaceholder = "Ask a question — answers cite their sources",
  defaultMode = "search",
  renderLink = defaultLink,
  footer,
  hotkey = "/",
  readAskParam = true,
  id = "ask",
  className = "",
}: AgenticPortfolioSearchProps) {
  const [mode, setMode] = useState<Mode>(defaultMode);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const [remoteHits, setRemoteHits] = useState<SearchHit[]>([]);
  const [ask, setAsk] = useState<{ status: "idle" | "loading" | "done" | "error"; data?: AskAnswer }>({ status: "idle" });
  const inputRef = useRef<HTMLInputElement>(null);
  const rootRef = useRef<HTMLElement>(null);
  const askSeq = useRef(0);

  const index = useMemo(() => (documents?.length ? createIndex(documents) : null), [documents]);
  const localHits = useMemo(() => (index && query.trim() ? search(index, query, { limit: 8 }) : []), [index, query]);
  const hits = index ? localHits : remoteHits;

  useEffect(() => {
    if (index || mode !== "search" || !query.trim()) return setRemoteHits([]);
    const ctrl = new AbortController();
    const t = setTimeout(() => {
      fetch(searchEndpoint, { method: "POST", signal: ctrl.signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ query }) })
        .then((r) => r.json())
        .then((j: { hits?: SearchHit[] }) => setRemoteHits(j.hits || []))
        .catch(() => {});
    }, 90);
    return () => {
      clearTimeout(t);
      ctrl.abort();
    };
  }, [index, mode, query, searchEndpoint]);

  useEffect(() => setActive(0), [query, mode]);

  const runAsk = useCallback(
    async (question: string) => {
      const q = question.trim();
      if (!q) return;
      const seq = ++askSeq.current;
      setAsk({ status: "loading" });
      try {
        const res = await fetch(askEndpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ question: q }) });
        const data = (await res.json()) as AskAnswer;
        if (seq === askSeq.current) setAsk({ status: res.ok ? "done" : "error", data });
      } catch {
        if (seq === askSeq.current) setAsk({ status: "error" });
      }
    },
    [askEndpoint],
  );

  const askNow = useCallback(
    (q: string) => {
      setMode("ask");
      setQuery(q);
      void runAsk(q);
    },
    [runAsk],
  );

  // External triggers: CTA events + ?ask= deep links.
  useEffect(() => {
    const onAsk = (e: Event) => {
      const q = (e as CustomEvent<{ question?: string }>).detail?.question || "";
      rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
      askNow(q);
      window.setTimeout(() => inputRef.current?.focus({ preventScroll: true }), 350);
    };
    window.addEventListener(ASK_EVENT, onAsk);
    if (readAskParam) {
      const q = new URLSearchParams(window.location.search).get("ask");
      if (q) {
        askNow(q);
        window.setTimeout(() => rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 120);
      }
    }
    return () => window.removeEventListener(ASK_EVENT, onAsk);
  }, [askNow, readAskParam]);

  // Global hotkey focuses the input.
  useEffect(() => {
    if (!hotkey) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== hotkey || e.metaKey || e.ctrlKey || e.altKey) return;
      const el = e.target as HTMLElement | null;
      if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
      e.preventDefault();
      inputRef.current?.focus();
      rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [hotkey]);

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Escape") {
      if (query) setQuery("");
      else inputRef.current?.blur();
      setAsk({ status: "idle" });
      return;
    }
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey || mode === "ask")) {
      e.preventDefault();
      askNow(query);
      return;
    }
    if (mode !== "search" || !hits.length) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActive((a) => Math.min(hits.length - 1, a + 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActive((a) => Math.max(0, a - 1));
    } else if (e.key === "Enter") {
      e.preventDefault();
      const url = hits[active]?.doc.url;
      if (url) window.open(url, /^https?:/.test(url) ? "_blank" : "_self", "noopener");
    }
  };

  const answer = ask.data;

  return (
    <section ref={rootRef} id={id} className={`aps ${className}`} data-mode={mode} aria-label={kicker}>
      <div className="aps-head">
        <span className="aps-kicker">{kicker}</span>
        <div className="aps-tabs" role="tablist" aria-label="Search mode">
          {(["search", "ask"] as const).map((m) => (
            <button
              key={m}
              type="button"
              role="tab"
              aria-selected={mode === m}
              data-active={mode === m}
              className="aps-tab"
              onClick={() => {
                setMode(m);
                inputRef.current?.focus();
              }}
            >
              {m === "search" ? "Instant Search" : "Ask AI"}
            </button>
          ))}
        </div>
      </div>
      {title ? <h2 className="aps-title">{title}</h2> : null}

      <form
        className="aps-bar"
        role="search"
        onSubmit={(e) => {
          e.preventDefault();
          if (mode === "ask") askNow(query);
        }}
      >
        <svg className="aps-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
          {mode === "ask" ? <path d="M12 3l1.9 5.6L19.5 10l-5.6 1.9L12 17.5l-1.9-5.6L4.5 10l5.6-1.4z" /> : <><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>}
        </svg>
        <input
          ref={inputRef}
          className="aps-input"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={mode === "ask" ? askPlaceholder : placeholder}
          aria-label={mode === "ask" ? "Ask a question" : "Search"}
          aria-controls={`${id}-results`}
          autoComplete="off"
          spellCheck={false}
          enterKeyHint={mode === "ask" ? "send" : "search"}
        />
        {mode === "ask" ? (
          <button type="submit" className="aps-submit" disabled={!query.trim() || ask.status === "loading"}>
            {ask.status === "loading" ? "Thinking…" : "Ask"}
          </button>
        ) : (
          <kbd className="aps-kbd" aria-hidden>
            {hotkey || "⏎"}
          </kbd>
        )}
      </form>

      {prompts.length ? (
        <div className="aps-chips" aria-label="Suggested questions">
          {prompts.map((p) => (
            <button key={p} type="button" className="aps-chip" onClick={() => askNow(p)}>
              {p}
            </button>
          ))}
        </div>
      ) : null}

      <div id={`${id}-results`} className="aps-results" aria-live="polite">
        {mode === "search" && query.trim() ? (
          hits.length ? (
            <ul className="aps-hits" role="listbox" aria-label="Results">
              {hits.map((h, i) => (
                <li key={h.doc.id} role="option" aria-selected={i === active} data-active={i === active} onMouseEnter={() => setActive(i)}>
                  {renderLink(
                    { id: h.doc.id, title: h.doc.title, url: h.doc.url, category: h.doc.category, previewKey: h.doc.previewKey },
                    <>
                      <span className="aps-hit-title">{h.doc.title}</span>
                      <span className="aps-hit-summary">{h.doc.summary}</span>
                      <span className="aps-hit-meta">
                        <span className="aps-badge">{h.doc.category}</span>
                        {host(h.doc.url) ? <span className="aps-host">{host(h.doc.url)}</span> : null}
                      </span>
                    </>,
                    "aps-hit",
                  )}
                </li>
              ))}
              <li className="aps-hint">
                ↑↓ to move · ⏎ to open ·{" "}
                <button type="button" className="aps-linkish" onClick={() => askNow(query)}>
                  Ask AI about “{query.trim()}” →
                </button>
              </li>
            </ul>
          ) : (
            <p className="aps-empty">
              No direct matches.{" "}
              <button type="button" className="aps-linkish" onClick={() => askNow(query)}>
                Ask AI instead →
              </button>
            </p>
          )
        ) : null}

        {mode === "ask" && ask.status === "loading" ? (
          <div className="aps-answer aps-loading" aria-busy="true">
            <span />
            <span />
            <span />
          </div>
        ) : null}

        {mode === "ask" && ask.status === "error" ? <p className="aps-empty">Couldn’t reach the answer service — try again.</p> : null}

        {mode === "ask" && ask.status === "done" && answer ? (
          <div className="aps-answer">
            <p className="aps-answer-text">{withCitations(answer.answer, answer.citations, renderLink)}</p>
            {answer.points.length ? (
              <ul className="aps-points">
                {answer.points.map((p) => (
                  <li key={p}>{withCitations(p, answer.citations, renderLink)}</li>
                ))}
              </ul>
            ) : null}
            {answer.citations.length ? (
              <ol className="aps-citations" aria-label="Sources">
                {answer.citations.map((c, i) => (
                  <li key={c.id}>
                    {renderLink(
                      c,
                      <>
                        <span className="aps-cite-n">{i + 1}</span>
                        <span className="aps-cite-body">
                          <span className="aps-cite-title">{c.title}</span>
                          <span className="aps-hit-meta">
                            <span className="aps-badge">{c.category}</span>
                            {host(c.url) ? <span className="aps-host">{host(c.url)}</span> : null}
                          </span>
                        </span>
                      </>,
                      "aps-cite",
                    )}
                  </li>
                ))}
              </ol>
            ) : null}
            <p className="aps-provenance">
              {answer.mode === "llm" ? `Grounded LLM answer (${answer.model})` : "Deterministic grounded answer · BM25 + verified facts"} ·{" "}
              {answer.tookMs}ms · cites only indexed sources
            </p>
          </div>
        ) : null}
      </div>
      {footer ? <div className="aps-footer">{footer}</div> : null}
    </section>
  );
}
src/styles.css raw ↗
/* agentic-portfolio-search — themeable via CSS variables; inherits host tokens when present. */
.aps {
  --aps-fg: var(--fg, #141416);
  --aps-muted: var(--muted, #68686f);
  --aps-border: var(--divider, rgba(20, 20, 22, 0.1));
  --aps-surface: var(--card, rgba(127, 127, 127, 0.06));
  --aps-accent: var(--fg, #141416);
  --aps-radius: 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  color: var(--aps-fg);
}
.aps-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.aps-kicker { font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--aps-muted); }
.aps-title { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; margin: 0; }
.aps-tabs { display: inline-flex; padding: 2px; border: 1px solid var(--aps-border); border-radius: 999px; }
.aps-tab { font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; border: 0; background: none; color: var(--aps-muted); cursor: pointer; }
.aps-tab[data-active="true"] { background: var(--aps-fg); color: var(--bg, #fff); }
.aps-bar {
  display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px;
  border: 1px solid var(--aps-border); border-radius: var(--aps-radius); background: var(--aps-surface);
  transition: border-color 0.15s, box-shadow 0.15s;
}
.aps-bar:focus-within { border-color: color-mix(in srgb, var(--aps-fg) 35%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--aps-fg) 8%, transparent); }
.aps-icon { color: var(--aps-muted); flex: none; }
.aps-input { flex: 1; min-width: 0; font: inherit; font-size: 15px; background: none; border: 0; outline: 0; color: var(--aps-fg); padding: 8px 0; }
.aps-input::placeholder { color: var(--aps-muted); }
.aps-kbd { font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; color: var(--aps-muted); border: 1px solid var(--aps-border); border-radius: 6px; padding: 2px 7px; margin-right: 6px; }
.aps-submit { font: inherit; font-size: 13px; font-weight: 500; padding: 7px 14px; border-radius: 10px; border: 0; background: var(--aps-fg); color: var(--bg, #fff); cursor: pointer; }
.aps-submit:disabled { opacity: 0.45; cursor: default; }
.aps-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.aps-chip { font: inherit; font-size: 12px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--aps-border); background: none; color: var(--aps-muted); cursor: pointer; text-align: left; }
.aps-chip:hover { color: var(--aps-fg); border-color: color-mix(in srgb, var(--aps-fg) 30%, transparent); }
.aps-results:empty { display: none; }
.aps-hits { list-style: none; margin: 0; padding: 4px; border: 1px solid var(--aps-border); border-radius: var(--aps-radius); display: flex; flex-direction: column; gap: 2px; }
.aps-hits > li[data-active="true"] .aps-hit { background: color-mix(in srgb, var(--aps-fg) 6%, transparent); }
.aps-hit { display: grid; gap: 2px; padding: 9px 10px; border-radius: 10px; color: inherit; text-decoration: none; }
.aps-hit-title { font-size: 14px; font-weight: 500; }
.aps-hit-summary { font-size: 12.5px; color: var(--aps-muted); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.aps-hit-meta { display: inline-flex; gap: 8px; align-items: center; margin-top: 2px; }
.aps-badge { font-family: var(--font-mono, ui-monospace, monospace); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--aps-muted); border: 1px solid var(--aps-border); border-radius: 999px; padding: 1px 7px; }
.aps-host { font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; color: var(--aps-muted); }
.aps-hint, .aps-empty { font-size: 12px; color: var(--aps-muted); padding: 8px 10px; margin: 0; }
.aps-linkish { font: inherit; background: none; border: 0; padding: 0; color: var(--aps-fg); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.aps-answer { border: 1px solid var(--aps-border); border-radius: var(--aps-radius); padding: 14px 16px; background: var(--aps-surface); display: flex; flex-direction: column; gap: 10px; }
.aps-answer-text { margin: 0; font-size: 14.5px; line-height: 1.6; }
.aps-points { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; line-height: 1.5; color: var(--aps-muted); list-style: disc; }
.aps-cite-ref { font-family: var(--font-mono, ui-monospace, monospace); font-size: 10px; padding: 0 3px; margin-left: 1px; border-radius: 4px; border: 1px solid var(--aps-border); color: var(--aps-fg); text-decoration: none; }
.aps-citations { list-style: none; margin: 2px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 6px; }
.aps-cite { display: flex; gap: 9px; align-items: flex-start; padding: 8px 10px; border: 1px solid var(--aps-border); border-radius: 10px; color: inherit; text-decoration: none; height: 100%; }
.aps-cite:hover { border-color: color-mix(in srgb, var(--aps-fg) 30%, transparent); }
.aps-cite-n { font-family: var(--font-mono, ui-monospace, monospace); font-size: 10px; color: var(--aps-muted); border: 1px solid var(--aps-border); border-radius: 5px; padding: 0 5px; line-height: 16px; }
.aps-cite-body { display: grid; gap: 3px; min-width: 0; }
.aps-cite-title { font-size: 12.5px; font-weight: 500; line-height: 1.35; }
.aps-provenance { margin: 0; font-family: var(--font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--aps-muted); }
.aps-footer { font-size: 11px; color: var(--aps-muted); }
.aps-loading { flex-direction: row; gap: 6px; padding: 18px 16px; }
.aps-loading span { width: 6px; height: 6px; border-radius: 99px; background: var(--aps-muted); animation: aps-pulse 1s infinite ease-in-out; }
.aps-loading span:nth-child(2) { animation-delay: 0.15s; }
.aps-loading span:nth-child(3) { animation-delay: 0.3s; }
@keyframes aps-pulse { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .aps-loading span { animation: none; } }
src/text.ts raw ↗
const STOP = new Set(
  "a an and are as at be but by can did do does for from has have he her his how i in into is it its me my of on or our she so that the their them then there these they this to us was we what when where which who why will with would you your about tell show give".split(
    " ",
  ),
);

/** Lowercase, strip accents/punctuation, keep dots inside domains (quote.dev). */
export function normalize(input: string): string {
  return input
    .normalize("NFKD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9.+#/\-\s]/g, " ")
    .replace(/(^|\s)[./\-]+|[./\-]+(?=\s|$)/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

/** Very small suffix stemmer — enough for "agents"→"agent", "shipping"→"ship". */
export function stem(token: string): string {
  if (token.length <= 3 || /[.\d]/.test(token)) return token;
  for (const [suf, rep] of [
    ["ies", "y"],
    ["ing", ""],
    ["ed", ""],
    ["es", ""],
    ["s", ""],
  ] as const) {
    if (token.endsWith(suf) && token.length - suf.length >= 3) {
      const base = token.slice(0, -suf.length) + rep;
      return base.replace(/(.)\1$/, "$1");
    }
  }
  return token;
}

export function tokenize(input: string, keepStop = false): string[] {
  const out: string[] = [];
  for (const raw of normalize(input).split(" ")) {
    if (!raw) continue;
    if (!keepStop && STOP.has(raw)) continue;
    out.push(stem(raw));
    // also index the pieces of compound tokens (quote.dev → quote, dev; geo/aeo → geo, aeo)
    if (/[./\-]/.test(raw)) for (const part of raw.split(/[./\-]+/)) if (part && part.length > 1 && !STOP.has(part)) out.push(stem(part));
  }
  return out;
}
src/types.ts raw ↗
/** A single searchable, citable unit of portfolio or docs content. */
export interface PortfolioDocument {
  /** Stable unique id (used as the citation key). */
  id: string;
  title: string;
  /** Short, human-readable summary used in result rows and answers. */
  summary: string;
  /** Optional longer text that is indexed but not shown verbatim. */
  body?: string;
  /** Destination URL (absolute or site-relative). */
  url?: string;
  /** Category badge, e.g. "Platform", "Role", "Writing", "Credential". */
  category: string;
  /** Free-form tags that boost matching (skills, disciplines, stack). */
  tags?: string[];
  /** Verified, quotable facts. Answers are built only from these + summary. */
  facts?: string[];
  /** Optional preview key the host can map to a hover preview. */
  previewKey?: string;
  /** Static ranking prior (default 1). */
  weight?: number;
  /** Optional ISO date for sorting/recency hints. */
  date?: string;
}

export interface SearchHit {
  doc: PortfolioDocument;
  score: number;
  /** Matched query terms (normalized). */
  matched: string[];
}

export interface SearchOptions {
  limit?: number;
  /** Restrict to these categories. */
  categories?: string[];
  /** Extra per-document score multiplier. */
  boost?: (doc: PortfolioDocument) => number;
}

/**
 * A host-authored intent: when a question matches, the synthesizer answers with
 * the verified `lead` and cites the best documents matching `boostTerms`.
 */
export interface AnswerIntent {
  id: string;
  /** Matches the normalized question. */
  match: RegExp;
  /** Verified lead sentence(s) — write these from facts you can prove. */
  lead: string;
  /** Terms appended to the retrieval query for this intent. */
  boostTerms?: string[];
  /** Document ids that must be cited first when present. */
  pin?: string[];
  /** Max bullets (default 4). */
  maxPoints?: number;
}

export interface Citation {
  id: string;
  title: string;
  url?: string;
  category: string;
  previewKey?: string;
}

export interface AskAnswer {
  question: string;
  /** Plain-text answer with [n] citation markers. */
  answer: string;
  /** Supporting bullets, each ending with a [n] marker. */
  points: string[];
  citations: Citation[];
  mode: "deterministic" | "llm";
  intent?: string;
  /** Model id when mode === "llm". */
  model?: string;
  tookMs: number;
}

export interface SearchResponse {
  query: string;
  hits: Array<Omit<SearchHit, "doc"> & { doc: PortfolioDocument }>;
  tookMs: number;
}
test/smoke.ts raw ↗
import { createIndex, search, synthesizeAnswer, type PortfolioDocument } from "../src/core";

const docs: PortfolioDocument[] = [
  { id: "a", title: "Acme MCP Server", summary: "Remote MCP server with 4 tools.", category: "Project", tags: ["mcp", "cloudflare workers"], facts: ["Ships 4 MCP tools on Cloudflare Workers."] },
  { id: "b", title: "Shop Redesign", summary: "Shopify checkout redesign.", category: "Client", tags: ["shopify", "cro"], facts: ["Checkout path cut from 5 to 3 clicks."] },
];
const index = createIndex(docs);
const hits = search(index, "mcp workers");
if (hits[0]?.doc.id !== "a") throw new Error("search failed");
const ans = synthesizeAnswer(index, "What MCP work is there?", { subject: "Acme" });
if (!ans.citations.length || !ans.answer.includes("[1]")) throw new Error("answer failed");
const none = synthesizeAnswer(index, "quantum knitting", { subject: "Acme" });
if (none.citations.length) throw new Error("should not answer");
console.log("ok", hits.length, ans.answer, "|", none.answer);
tsconfig.json raw ↗
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "isolatedModules": true
  },
  "include": ["src", "test"]
}