Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8ce1269
feat(assistant): t171 search foundation — folded fts5 index + retriev…
duongdev Jul 27, 2026
0e597a8
feat(assistant): t172 llm provider seam — ai@7 resolveModel + smoke s…
duongdev Jul 27, 2026
831959d
feat(assistant): t173 backend — sessions, agent loop, citation valida…
duongdev Jul 27, 2026
e23898a
feat(assistant): t174 panel ui — third column, useChat stream, citati…
duongdev Jul 27, 2026
a14810e
feat(chat): t175 jump-to-message — db-served msg window, jump mode, r…
duongdev Jul 27, 2026
78045ba
refactor(assistant): steering pass — 2-page sessions, keep-alive pane…
duongdev Jul 27, 2026
889e0fd
feat(assistant): t176 quick actions — summarize, catch-up digest, dra…
duongdev Jul 27, 2026
9518eab
feat(assistant): t177 model selector — curated LLM_MODELS, per-sessio…
duongdev Jul 27, 2026
3b42d59
fix(assistant): strip stray reasoning tags from streamed text (glm qu…
duongdev Jul 27, 2026
ed47878
docs(assistant): record live 9router smoke results
duongdev Jul 27, 2026
d16d5ea
feat(assistant): steering 2 — composer card, ⌘⌥B, context ring, tool …
duongdev Jul 27, 2026
41b44bd
fix(assistant): ci lint, seam dividers, composer parity, panel width …
duongdev Jul 27, 2026
b479c52
fix(assistant): shared composer shell, reasoning-phase state, portall…
duongdev Jul 27, 2026
d3d4b52
fix(assistant): unique turn ids, assistant markdown class, self-bubbl…
duongdev Jul 27, 2026
5e2cf24
feat(assistant): attach tray — explicit refs, add/remove/jump, no hid…
duongdev Jul 27, 2026
1991527
feat(assistant): mentionsMe search filter — tagged messages, not name…
duongdev Jul 27, 2026
d6a9991
fix(assistant): hover message actions, collapsible sources, real mode…
duongdev Jul 27, 2026
01567b4
feat(chat): pr-chip links, ai answer actions, panel resize reset
duongdev Jul 27, 2026
6b3f379
fix(chat): pr link renders as plain inline link, not a code-like chip
duongdev Jul 27, 2026
f40b6a8
fix(assistant): smaller answer headings, sender-level citations, link…
duongdev Jul 27, 2026
35b919e
docs(chat): assistant glossary terms, psn-104 renderer facts, adr reg…
duongdev Jul 27, 2026
d252e16
feat(assistant): resolve folders and labels as retrieval scopes
duongdev Jul 27, 2026
f8a1548
feat(assistant): attach a folder or label as a live context scope
duongdev Jul 27, 2026
f7f3eec
feat(assistant): collapsible attach tray with capped scrollable chips
duongdev Jul 27, 2026
ff327b0
feat(assistant): attach menu rows toggle and stay open
duongdev Jul 27, 2026
cf23ae7
feat(assistant): surface reply chains to retrieval tools
duongdev Jul 27, 2026
47abbbb
feat(assistant): read images in messages via transcription and vision
duongdev Jul 27, 2026
d630f5d
fix(assistant): tolerate router non-stream body, label image transcri…
duongdev Jul 27, 2026
63e94fe
feat(assistant): answer in the operator's terse house style
duongdev Jul 27, 2026
f521876
fix(assistant): rescue reasoning-only answers, anchor time to the use…
duongdev Jul 27, 2026
23d5cad
feat(assistant): skeleton the model and context controls while they load
duongdev Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ cdp-browser/
│ └── server.mjs # Web build backend: serves dist/ (+ dist-chat/ at /chat) + SSE/POST/WS proxy + streaming input (no Electron) + the chat BFF reverse proxy (/api/chat/* + /api/chat/ws) + the internal-only /internal/teams/* provider seam. See ADR-0006, ADR-0007, ADR-0020
├── apps/
│ └── chat-server/ # Chat BFF (PSN-93, ADR-0020, web only): Hono + better-sqlite3 + ws; own chat.db; sweeps Teams via /internal/teams/*, pushes WS deltas, owns Teams web push. Boots on :7810 (pnpm --filter @cdp/chat-server start)
│ └── src/ # contract.ts (/api/chat types), store.ts (service-agnostic schema + upsert/read, raw-payload column), routes.ts (Hono /api/chat), ws-hub.ts (snapshot+delta hub), sweep.ts + backfill.ts (engines), push.ts (VAPID send), providers/ (ChatProvider: teams-provider HTTP client of /internal/teams/*, mock-provider, stub-provider)
│ └── src/ # contract.ts (/api/chat types), store.ts (service-agnostic schema + upsert/read, raw-payload column), routes.ts (Hono /api/chat), ws-hub.ts (snapshot+delta hub), sweep.ts + backfill.ts (engines), push.ts (VAPID send), assistant/ (t173/t176/t177, ADR-0021: session-store — ai_sessions/ai_messages UIMessage rows, sdk_major-stamped, tolerant load; citations — validated-not-trusted [msg:convId:msgId] markers vs the tool-surfaced id set; compact — 40K-token summarize policy, sent shrinks/stored never; loop — `timeContext` states "today" in the USER's zone (the browser sends its IANA zone per turn; the server clock is UTC, so a UTC "today" called this morning "yesterday" east of Greenwich) with the day bounds as epoch ms the model passes straight to search_messages, plus a RESPONSE_STYLE block distilled from the operator's own agent rules (global CLAUDE.md + the caveman and i-have-adhd skills: answer first, no preamble/recap/closer, bounded five-item lists, bad news in the opening line, no emoji, one next action) so the panel doesn't read like a chattier second assistant — with carve-outs that keep quoted message text verbatim and anything drafted FOR a colleague in normal prose (the draft-reply seed opts out explicitly), plus read-only zod tools over search.ts + get_unread_overview (unread-overview.ts, effectiveReadTs-derived, mute-filtered, capped excerpts) + list_scopes/resolve_scope (the user's folders + labels → convIds, PSN-104) + stepCountIs(8) streamText; routes — /api/chat/assistant session CRUD + context refs (the attach tray: POST/DELETE `/sessions/:id/context`, PURE refs — nothing is injected into the transcript, so a ref stays live and detaching truly removes it. Four kinds, two shapes: `chat`/`message` key on (convId,msgId), `folder`/`label` key on (kind,name) and resolve to conversations per question, so a chat filed into the folder later is in scope without re-attaching — `refKey` is the one identity function, mirrored in the FE client. The tray UI is a COLLAPSIBLE header — "N attached · 2 chats · 1 folder" (`context-tray-view.ts`, pure) over a `max-h-24` scrollable chip list, auto-collapsed past 2 chips and re-expanded whenever a new one lands, so a long attach list can never push the composer off the panel) + models (enriched with each curated model's REAL `contextWindow`/`maxOutput` via one cached provider `/v1/models` lookup — `enrichModelLimits`, not discovery: the offered set stays env-curated. Only a SUCCESSFUL lookup is cached and a failed one logs `[llm] model-limit lookup failed` (a transient blip used to pin the 40K compaction fallback until restart, PSN-104), and the window is read from any of the shapes a router reports it under (`capabilities.contextWindow` on 9router, `context_length`, `top_provider.context_length`); curated `LLM_MODELS` env — comma-separated `id[:label]`, e.g. `glm/glm-5.1:GLM 5.2,glm/glm-4.7:GLM 4.7`; falls back to the single `LLM_MODEL` when unset, and the picker only hides when NOTHING is configured — there is deliberately no router `/v1/models` discovery, ADR-0021; parsed by parseModelList; the chat route validates a session's stored model against it, stale → env default) + voice prefs blob + the useChat stream route, consumeStream un-awaited so a dropped tab still persists, post-turn title-gen + compaction fire-and-forget), media-images.ts + media-store.ts + caption.ts (PSN-104 image vision: a message's inline AMS images are extracted at upsert into `message_media` (one row per occurrence, caption keyed by the immutable AMS object id so a forwarded screenshot is transcribed once), and a table-as-queue worker transcribes each with `LLM_CAPTION_MODEL` — full verbatim transcription, indexed WITH the message so a phrase that exists only inside a screenshot is findable. `stripHtml` leaves an `[image#N]` marker (without it the model answers "there's nothing there" about a screenshot-only message) and tool rows carry `images[].caption`. Raw pixels are opt-in via the `view_image` tool, registered ONLY for a probed vision model (`enrichModelLimits` reads input modality; `LLM_VISION_MODELS` overrides): they CANNOT ride back as the tool result — @ai-sdk/openai-compatible JSON.stringifies a multi-modal tool output — so they buffer and are appended as a `role:"user"` message by `prepareStep` on every later step. `sharp` downscales to 1024px first, degrading to full-size bytes if it is missing. Old images caption lazily on first view via `GET /api/chat/media/caption`, which the lightbox awaits for its pending state), llm.ts (t172, ADR-0021: readLlmConfig env → resolveModel via ai@7 + @ai-sdk/openai-compatible — LanguageModel IS the provider seam; typed llm-unconfigured; llm-smoke.ts = live streamed-tool-call check. Two router-quirk guards live here, both live-reproduced on 9router: `tolerantFetch` strips the stray `data: [DONE]` 9router glues onto a NON-streaming JSON body — without it every `generateText` (image transcription, title-gen, compaction) died with "Invalid JSON response" while the streamed chat turn worked, which is why it hid; and `reasoningFallbackMiddleware` promotes a step's reasoning to text when a thinking model finished its whole answer inside `reasoning_content` and emitted no content at all (~1 turn in 3 on GLM 4.7 — an empty answer bubble and nothing for the citation validator to check). A tool-call step, which legitimately has no text, is never touched), search.ts (t171, ADR-0021: Vietnamese fold + html-strip + FTS5 shadow index over messages.body, synced inside upsertMessages + boot backfill; searchMessages/getContextWindow/listConversationsByQuery/resolvePerson — the assistant's retrieval tools. `listScopes`/`resolveScope` expose the user's OWN folders + labels (`conversation_prefs`, local organisation the provider never sees) so "look in my FWD folder" resolves to real conversation ids — fold-matched (diacritics/casing don't matter), folder beats label on a tie, no match returns the real list instead of a guess; the ids ride back in as `convIds` on search/list, where an EMPTY array means "nothing qualifies", never "unfiltered". `splitReplyQuotes` separates a reply's OWN words from the parent it quotes (Teams inlines the parent as a `<blockquote itemtype=…/Reply itemid={parentMsgId}>`) — flattened, the quoted sentence read as the replier's and the parent id was lost, so tool rows carry `repliesTo:[{msgId,sender,excerpt}]` and the model walks the chain with `get_context({aroundMsgId})`; the quote text stays in the FTS index so a phrase still finds the message that quoted it. `stripHtml` keeps an anchor's **href**, not just its visible text: Teams renders long URLs pre-truncated with its own "…", so dropping the href handed the model an unusable half-URL it then "quoted" back, PSN-104), providers/ (ChatProvider: teams-provider HTTP client of /internal/teams/*, mock-provider, stub-provider)
├── chat/ # Standalone Teams chat app (t128, ADR-0019) — a second Vite entry served at /chat
│ ├── index.html # Chat app Vite entry HTML (title "Teams Chat")
│ ├── public/ # Chat PWA assets: manifest.webmanifest + sw.js scoped to /chat/ (icons shared from /icons via absolute refs)
│ └── src/ # Chat renderer: main.tsx, chat-app.tsx, lib/ (chat-client — the /api/chat client; chat-ws — the WS delta client; teams-client — pure type home; conversation-view + test), components/ (conversation-list, conversation-row). index.css re-imports the shared src/index.css theme
│ └── src/ # Chat renderer: main.tsx, chat-app.tsx, lib/ (chat-client — the /api/chat client; chat-ws — the WS delta client; teams-client — pure type home; conversation-view + test; assistant-client — /api/chat/assistant session CRUD + models + voice prefs + typed error copy; assistant-citations — pure [msg:convId:msgId] marker → chip parsing (t174) plus collectCitationMeta, which harvests sender/ts/excerpt out of the SAME turn's tool rows so a chip reads "Sender: what they said" instead of the conversation name repeated N times (PSN-104; shape-driven, so a new retrieval tool is covered with no extra fetch); link-label — the ONE place a URL's display label is decided (Azure DevOps PR → `repo#id`, else PSN-99 middle-elide), applied to sanitized message HTML via elideLinkText and to AI markdown via labelMarkdownLinks (the SOURCE is rewritten, so Streamdown still owns the anchor); assistant-actions — pure quick-action prompt seeds (summarize/catch-up/draft-reply/action-items, t176); jump-mode — pure t175 jump-window state), components/composer-shell.tsx (the ONE composer card both surfaces render inside — frame, focus ring, outer padding, footer-row metrics; paired with the `.composer-editor, .ai-composer-input` font rule in index.css so the thread and AI inputs are pixel-identical at rest), components/ (conversation-list, conversation-row, link-hover-copy — the shared hover-a-link-get-a-copy-button hook (PSN-99 hover-bridge timing, one copy of it) used by BOTH message bubbles and AI answers; it positions off the link's LAST client rect, so a wrapped link's button lands at the end of its final line instead of the far right of the paragraph, ai/assistant-panel — the assistant third column/phone view (t174 + steering): two pages (session list + kept-alive session panes, MRU cap 4), @ai-sdk/react useChat + streamdown markdown with `linkSafety` OFF (its interstitial renders a href-less <button>, which made an answer's link behave differently from the same link in a bubble and hid the href from hover-copy), collapsed sources summarised as "N messages in M chats", hover-revealed insert/copy actions under each answer, quick-action auto-send + "Insert into composer" (never auto-sent), context-usage meter, ai/model-selector — per-session curated-model picker in the prompt footer (t177); open/width/session + listWidth persist per device via chatAi*/chatListWidth settings slots; both the list and AI columns drag-resize, and double-clicking either seam resets that column to its DEFAULT_CHAT_SETTINGS width). index.css re-imports the shared src/index.css theme, sizes assistant headings down to near body scale, and carries the `@source` for streamdown/dist so Tailwind emits the classes its prebuilt JS uses
├── public/ # Web static assets copied into dist/ by Vite: manifest.webmanifest, sw.js, icon.svg, icons/
├── index.html # Vite entry HTML
├── vite.config.ts # Vite + React + Tailwind config (the / build → dist/)
Expand Down
25 changes: 25 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,30 @@ _Avoid_: poll, refresh loop, sync (ambiguous).
A manual, resumable BFF job that cursor-chains a conversation's older history pages until it reaches `now − X days` (X selectable in Settings → Data: 30/60/90/120, default 30), streaming `backfill-progress` over WS. Serial per conversation + rate-limit-aware; changing X affects the next Run. Fills the durable store beyond what the live **Sweep** carries.
_Avoid_: import, history fetch, sync.

**Assistant Session**:
One persistent conversation with the AI assistant (`ai_sessions` / `ai_messages` in the BFF, ADR-0021), holding its own transcript, model pick, compaction summary, and **Attach Tray**. Sessions survive restarts and are switched from the panel's list/dropdown; opened ones stay mounted (MRU-capped) so a switch keeps stream/scroll/draft state.
_Avoid_: chat (that's a Teams conversation), thread, AI conversation.

**Citation**:
A `[msg:{convId}:{msgId}]` marker the model emits inline and the server keeps ONLY when that id was actually surfaced by a tool call in this session — validated, never trusted (ADR-0021). The FE strips the markers from the rendered answer and lists what survived under it, each entry labelled with the sender + excerpt harvested from the same turn's tool rows, opening `/chat/c/{convId}?msg={msgId}` on click.
_Avoid_: source, reference, link (a **Citation** is not a URL).

**Scope** (assistant):
A folder or label the user assigned by hand (`conversation_prefs`) used as a retrieval filter: the assistant resolves the name it was given ("my FWD folder", "the urgent ones") to that group's conversation ids and passes them as `convIds`. Fold-matched, folder before label, and an unknown name comes back with the real folder/label list rather than a guess. Attachable as an **Attach Tray** chip in its own right (kind `folder`/`label`), and resolvable ad-hoc when a question just names one.
_Avoid_: filter, category, tag (a label IS the tag).

**Attach Tray**:
The explicit, visible context of an **Assistant Session** — chips for whole conversations, single messages, or a whole **Scope** (folder/label), added from the "+" menu / a message's ⋯ menu and removable. Refs are PURE pointers: nothing is injected into the transcript, so a ref stays live and detaching truly removes it. A scope chip holds the NAME, so conversations filed into that folder later are covered without re-attaching. An empty tray means "search everything", never a silent scope.
_Avoid_: context window (that's the token budget), attachments, pinned context.

**Image Transcription**:
The text an inline image's pixels were reduced to (`message_media.caption`, PSN-104) — a verbatim reading of everything visible, made once per AMS object by the caption model. It is indexed alongside the message, so a phrase that exists ONLY inside a screenshot still finds it, and it is what a text-only model sees of that image. Shown under the picture in the lightbox.
_Avoid_: caption in the "photo caption" sense, alt text, OCR (it is a transcription plus a describing line, not either alone).

**Vision Model**:
An assistant model the router reports as taking image input (or one force-listed in `LLM_VISION_MODELS`). Only such a model gets the `view_image` tool; every other model answers from **Image Transcription**s. Fetched pixels never ride in a tool result — they are appended as a user message on the next step, because the OpenAI-compatible mapping stringifies a multi-modal tool output.
_Avoid_: multimodal model (ambiguous — audio/video are not supported), image model (that generates images).

## Relationships

- A **Remote Browser** hosts many **Tabs**; exactly one is the **Active Tab**.
Expand All @@ -139,6 +163,7 @@ _Avoid_: import, history fetch, sync.
- For Slack, the **Slack Content Sweep** is the authoritative **Notification Capture** writer; the in-page hijack provides only an instant foreground toast. The sweep reads creds from a live **Tab**, persists workspaces in the **Workspace Registry**, uses the **Parked Tab** keeper (pin-deferred since t098 — a pinned workspace is owned by its Pin), and respects the **Channel Exclude** list.
- A **Local Tab** renders a real local web page (in-DOM `<webview>`) alongside CDP Tabs — it does not use **Screencast Frames** or **Input Forwarding**; it gets direct device access instead.
- The **Sync Backend** is the shared store for **Pin**s and **Visit**s; the New Tab omnibox ranks **Visit**s (`rankHistory`/`suggest`) alongside currently-open Tabs to offer a "switch to tab" result ahead of opening a duplicate.
- An **Assistant Session** answers over the **Chat BFF**'s store: its retrieval tools surface message ids, only those ids may become a **Citation**, and its **Attach Tray** names what the user pointed at — never a hidden filter.

## Example dialogue

Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ RUN pnpm install --prod --frozen-lockfile --ignore-scripts && pnpm store prune
# Copy the module the builder already compiled (same node:24-alpine base → abi-compatible), instead of
# shipping a toolchain into the runtime image.
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
# `sharp` (downscales a screenshot before it goes to the vision model, PSN-104) needs no build step —
# its binaries are prebuilt @img/sharp-linuxmusl-* packages that the --prod install above pulls in.
# If it is ever absent, captioning still works: caption.ts falls back to the full-size bytes and logs
# "[caption] sharp unavailable".

# Copy the TS source so `node --experimental-transform-types` can run it at runtime (no compile step).
COPY --from=builder /app/apps/chat-server ./apps/chat-server
Expand Down
6 changes: 5 additions & 1 deletion apps/chat-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.14",
"@hono/node-server": "^1.13.7",
"ai": "^7.0.37",
"better-sqlite3": "^12.11.1",
"hono": "^4.6.14",
"sharp": "^0.35.3",
"web-push": "^3.6.7",
"ws": "*"
"ws": "*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
Expand Down
Loading