Skip to content

feat: LangChain integration & AI autocomplete (#9)#27

Merged
multiandrewlab merged 47 commits into
feat/database-schema-migrationsfrom
feat/langchain-ai-autocomplete
Apr 13, 2026
Merged

feat: LangChain integration & AI autocomplete (#9)#27
multiandrewlab merged 47 commits into
feat/database-schema-migrationsfrom
feat/langchain-ai-autocomplete

Conversation

@multiandrewlab
Copy link
Copy Markdown
Owner

Summary

Implements issue #9 — LangChain.js provider-factory abstraction, SSE-streaming autocomplete endpoint, and CodeMirror 6 ghost text overlay.

Closes #9

What's in this PR

  • Server
    • Provider factory (ollama default gemma4, openai, vertex) driven by LLM_PROVIDER/LLM_MODEL env vars
    • Autocomplete chain with few-shot prompt template + StringOutputParser
    • AiRateLimiter class — 1 in-flight request per user, 60s timeout, injectable now() for deterministic tests
    • langchainPlugin Fastify plugin exposing app.aiProvider, app.aiRateLimit, app.aiGate decorators; onResponse/onError release hooks
    • POST /api/ai/complete SSE streaming route with exported createAbortHandlers helper
  • Client
    • CodeMirror 6 ghost-text extension (StateField + WidgetType)
    • parseSseStream async-iterable SSE parser
    • useAiComplete composable: 300ms debounce + AbortController cancellation
    • AiSuggestion.vue wires composable to the editor's EditorView
    • CodeEditor.vue exposes its EditorView ref and includes ghostTextExtension
    • PostEditor.vue mounts <AiSuggestion> and triggers requestCompletion on content/language changes
  • Tests: 1761 passing / 1 skipped; 100% coverage (lines/branches/functions/statements)
  • Bruno: bruno/ai/complete.bru with SSE assertions
  • Knowledge base: 8 learnings captured in .beads/knowledge/ (patterns + gotchas)

Key design decision — preHandler instead of onRequest

The issue's example puts the rate limiter in a Fastify onRequest hook, but onRequest fires before app.authenticate (which runs in preHandler), so request.user would be undefined there. This PR composes app.aiGate = [app.authenticate, app.aiRateLimit] as a preHandler, matching the existing pattern at packages/server/src/routes/posts.ts:35. The adversarial reviewers accepted this deviation as a necessary correctness fix.

Verification

Gate Status
Unit tests (1761 pass, 1 skip)
Coverage 100% lines/branches/functions/statements per .coverage-thresholds.json
Typecheck + ESLint
Bruno ai/complete.bru against live server (OpenAI provider gpt-5.4) — 9 token events + done event + suggestion return a + b;
Bruno ai/ collection tests (2/2 assertions pass)
Error-event path verified against a real Ollama failure on Apple M5 Metal4
Plan Review Gate (Feasibility / Completeness / Scope) ✅ 3/3 PASS after 3 iterations
Per-WU adversarial reviews ✅ all 13 code-WUs PASS
Manual browser E2E (type → ghost text → Tab accepts) ⚠️ pending local verification by maintainer

Notes for reviewers

  • gemma4 stays as the declared default in .env.example per the issue spec. Live verification on this PR used OpenAI provider because the Apple M5 + macOS Tahoe + ollama 0.20.5/0.20.6 stack has a Metal4 shader compile bug (upstream ollama#15487, fix merged as commit ec29ce4, awaiting a release). Once that release ships, swapping LLM_PROVIDER=openaiLLM_PROVIDER=ollama in local .env is enough to verify the happy path with gemma4.
  • Plan at docs/superpowers/plans/2026-04-13-langchain-ai-autocomplete.md (uncommitted — a previously-established convention for this repo).
  • The Bruno full -r regression has pre-existing failures unrelated to this branch (500s on bookmarks/comments/revisions, and ai/complete gets 401 because auth/logout.bru runs before it alphabetically). None of those endpoints were touched.

Test plan

  • npm test — 1761/1762
  • npm run test:coverage — 100% all metrics
  • npm run typecheck — clean
  • npx eslint — clean (2 pre-existing warnings on untouched lines)
  • Live server + Bruno bruno/ai/complete.bru — PASS
  • Reviewer: open the editor in a browser, type function add(a, b) {, wait 300ms, verify translucent ghost text, press Tab to accept, press any other key to dismiss

🤖 Generated with Claude Code

multiandrewlab and others added 30 commits April 12, 2026 21:17
Add VoteValue, VoteResponse, BookmarkToggleResponse, Tag, TagSubscriptionResponse
types and voteSchema Zod validator. Update FeedSort to include 'personalized'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POST /api/posts/:id/vote — idempotent toggle (same value removes)
DELETE /api/posts/:id/vote — explicit vote removal
Adds getUserVote query. DB trigger handles vote_count updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POST /api/posts/:id/bookmark — toggles bookmark on/off
GET /api/bookmarks — paginated list of bookmarked posts
Adds getUserBookmark query. Reuses findFeedPosts with filter=bookmarked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GET /api/tags — list/search tags with autocomplete
GET /api/tags/popular — top tags by post count
POST/DELETE /api/tags/:id/subscribe — tag subscriptions
GET /api/tags/subscriptions — user's subscribed tags
POST /api/posts now processes tags array (find-or-create + link)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sort=personalized checks user tag subscriptions, filters posts by
subscribed tags via EXISTS clause, and ranks by hotness score.
Falls back to trending when user has no subscriptions.
Also adds shared type/validator tests for full coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extend feed store with userVotes/userBookmarks reactive state
- useVotes composable: vote toggle + explicit removal via API
- useBookmarks composable: bookmark toggle via API
- PostActions rewritten: upvote, downvote, bookmark (functional),
  fork and history (disabled placeholders)
- PostListItem vote count reactivity verified via store mutation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Tags Pinia store: subscribedTags, popularTags, subscribe/unsubscribe
- useTags composable: loadSubscriptions, searchTags, subscribe/unsubscribe
- TheSidebar: dynamic followed tags from API, empty state,
  tag click filters feed via useFeed().setTag()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Debounced search-as-you-type via useTags().searchTags(), dropdown
suggestions with post counts, click/Enter to add, inline tag creation,
max 10 tags enforced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New request collections:
- votes/: upvote, downvote, remove-vote
- bookmarks/: toggle-bookmark, list-bookmarks
- tags/: list-tags, popular-tags, subscribe, unsubscribe, subscriptions
Update get-feed.bru docs for sort=personalized.
Add tagId to local environment variables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- V8 finally block coverage artifact workaround
- Vue SFC browser globals ESLint pattern
- ON CONFLICT DO NOTHING toggle pattern for bookmarks
- Parallel subagent route registration safety

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…etection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds threaded comment system with general comments below posts
and inline comments anchored to specific code lines and revisions.

- 5 REST endpoints (GET with revision filter, POST, PATCH, DELETE)
- CommentThread, CommentInput, InlineComment, CommentSection components
- Pinia store with tree building, inline grouping, stale detection
- Edit/delete with ownership checks
- Bruno API collection for all comment endpoints
- 1159 tests passing across all packages

Closes #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nd test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add v8 ignore for unreachable DOM guard in CodeViewer
- Add timeAgo branch tests (minutes, hours, days) for CommentThread
- Add inline comment indicator tests for PostDetail
- Add authStore.user null path test for PostDetail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bruno requests are now a blocking gate for any feature that adds
or modifies API endpoints — same enforcement level as unit tests
and coverage thresholds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(shared): add WebSocket message type schemas (WU-001)

Add discriminated-union TypeScript types and zod schemas for every
client<->server WebSocket message, plus per-variant schemas for
granular server-side validation.

DoD items verified:
- [x] ClientMessage union (auth/subscribe/unsubscribe/presence)
- [x] ServerMessage union (auth:*/comment:*/vote:*/revision:*/post:*/presence:update)
- [x] clientMessageSchema + serverMessageSchema via z.discriminatedUnion
- [x] All 17 types + schemas re-exported from packages/shared/src/types/index.ts
- [x] 69 tests, 100% coverage on websocket.ts

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add ConnectionManager for WebSocket multi-tab support (WU-002)

Tracks WebSocket connections per user via Map<userId, Set<socket>> and
indexes by clientId UUID for later sender-exclusion broadcasts (WU-006).

DoD items verified:
- [x] Class with add/remove/get/getAll/findByClientId
- [x] Last connection removed → user key pruned
- [x] clientId removed from index on disconnect
- [x] 11 tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add ChannelManager for WebSocket pub/sub (WU-003)

In-memory channel subscriber registry. `broadcast` serializes once,
skips non-OPEN sockets, and supports per-call sender exclusion.
`removeFromAll` is called on socket close to clean up subscriptions.

DoD items verified:
- [x] subscribe/unsubscribe/broadcast/getSubscribers/removeFromAll
- [x] broadcast stringifies once, excludes sender, skips closed sockets
- [x] Empty channel entries pruned on unsubscribe/removeFromAll
- [x] 14 tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add PresenceTracker with eviction interval (WU-004)

Tracks per-channel per-user last-seen timestamps. Entries older than
60s are evicted. A factory creates a 15s interval that evicts and
broadcasts presence:update for affected channels.

DoD items verified:
- [x] Class exports update/remove/evict/getViewers
- [x] PRESENCE_EVICTION_THRESHOLD_MS constant (60_000)
- [x] createPresenceEvictionInterval factory
- [x] Eviction boundary: strict >, exact 60s entries retained
- [x] 23 tests with fake timers, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add WebSocket plugin with auth handshake (WU-005)

Register @fastify/websocket@^11 at /ws, implement an auth-handshake
state machine, and expose ConnectionManager/ChannelManager/PresenceTracker
via app.websocket so REST routes can broadcast in WU-006.

State machine:
- awaiting-auth: non-auth frame or malformed JSON → close(4001, 'auth-required')
- auth + valid JWT → auth:ok, register with UUID clientId
- auth + invalid JWT → auth:error + close(4002, 'auth-failed')
- authenticated + subscribe/unsubscribe → ChannelManager
- authenticated + presence (viewing) → PresenceTracker
- authenticated + unknown/malformed → log and ignore
- JWT expiry mid-session → auth:expired, revert to awaiting-auth (socket stays open)
- close → removeFromAll + removeConnection (if authenticated)

Includes app.test.ts boot-resilience smoke test and @types/ws devDep.

DoD items verified:
- [x] @fastify/websocket pinned to ^11 (Fastify-5 compatible line)
- [x] handler.ts state machine covers all 12 branches
- [x] index.ts fastify-plugin with app.websocket decoration, onClose cleanup
- [x] app.ts registers plugin after auth, before routes
- [x] 100% coverage, 29 new tests (24 handler + 4 index + 1 app smoke)

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): broadcast REST mutations over WebSocket (WU-006)

Wire every mutation route to the WebSocket ChannelManager so connected
clients receive comment/vote/revision/post events in real time.

Broadcasts added:
- comment:new / comment:updated / comment:deleted on post:<id>
- vote:updated on post:<id>
- revision:new on post:<id>
- post:new / post:updated on feed
  - post:new on POST /
  - post:updated on PATCH /:id, POST /:id/publish, POST /:id/revisions

Sender exclusion via new x-ws-client-id request header, read through
getExcludeWs helper that looks up the socket by clientId. Feed-channel
broadcasts fetch a PostWithAuthor via new findFeedPostById query helper.

Soft-delete (DELETE /:id) intentionally does not broadcast; clients
invalidate via cache expiration or a separate refresh path (documented).

DoD items verified:
- [x] getExcludeWs helper (5 tests)
- [x] All 3 comment mutations broadcast
- [x] Both vote mutations broadcast
- [x] post:new, post:updated x3, revision:new all broadcast
- [x] findFeedPostById helper with 3 tests
- [x] Existing REST tests updated to assert broadcasts
- [x] 100% coverage maintained

Reviewed-by: adversarial-review (PASS, attempt 2)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useWebSocket composable and realtime store (WU-007)

Singleton Vue composable owning one WebSocket per app load with:
- First-message auth handshake, token refresh on auth:expired
- Auto-reconnect exponential backoff 1s→30s cap, reset on auth:ok
- Channel re-subscription after reconnect
- Handler cleanup via returned unsubscribe function
- Send queue buffers messages while disconnected

Pinia realtime store exposes connection status + per-channel presence
(populated by WU-008).

clientId (UUID generated once at module load) is the token threaded
through REST calls in WU-009 to drive server sender-exclusion.

DoD items verified:
- [x] useRealtimeStore with status, presenceByChannel, actions
- [x] useWebSocket singleton with subscribe/send/connect/disconnect/clientId
- [x] Exponential backoff with 30s cap (6+ retry test)
- [x] Channel re-subscription on reconnect
- [x] auth:expired → re-auth (handlers kept)
- [x] auth:error → status disconnected, no reconnect
- [x] 56 new tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add usePresence composable and PresenceIndicator (WU-008)

usePresence(postId) sends a presence heartbeat to the server immediately
on mount (and when postId changes to a truthy value), then every 30s.
Subscribes to presence:update events and writes the latest viewers list
into the realtime store. On postId change or unmount, cleans up interval,
unsubscribes, and clears the old channel's presence.

PresenceIndicator.vue renders up to 5 avatars stacked with -ml-2 overlap
and shows +N overflow for additional viewers. Hidden when no viewers.

Reactivity: activeChannel is a ref so the viewers computed re-evaluates
when postId switches to a channel with pre-seeded store presence — a
regression test guards this.

DoD items verified:
- [x] Immediate heartbeat + 30s interval
- [x] Subscribe to presence:update, write to realtime store
- [x] Cleanup on postId change and unmount
- [x] Null/undefined postId handled
- [x] 5 avatar max + overflow badge, v-if empty state
- [x] aria-label on root
- [x] 32 new tests, 100% coverage

Reviewed-by: adversarial-review (PASS, attempt 2)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): wire real-time events into comments, votes, feed, presence (WU-009)

Integrate WebSocket subscriptions with existing composables and app shell
so real-time events mutate the stores as they arrive, and so REST calls
carry the x-ws-client-id header for server-side sender exclusion.

Changes (all strictly additive — no existing exports changed):
- apiFetch injects x-ws-client-id on POST/PATCH/PUT/DELETE only
- useComments.subscribeRealtime: comment:new/updated/deleted → store
- useVotes.subscribeRealtime: vote:updated → new store.updateVoteCount
  (aggregate voteCount only; preserves userVote for current user)
- useFeed.subscribeRealtime: post:new prepends, post:updated replaces
  in place
- PostViewPage mounts PresenceIndicator and subscribes comments/votes
  on mount; cleans up on unmount
- AppLayout connects WS + feed subscription on auth, disconnects
  on logout; cleans up on unmount

DoD items verified:
- [x] x-ws-client-id header on mutating methods only
- [x] Comment/vote/feed subscribeRealtime functions
- [x] PresenceIndicator mounted on post detail
- [x] WS connect/disconnect lifecycle in AppLayout
- [x] Composable API strictly additive (existing tests still pass)
- [x] 42 new tests, 100% coverage maintained

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(knowledge): capture learnings from WebSocket feature

Extract 6 high-value learnings from the WebSocket infrastructure
implementation (issue #1):

Gotchas:
- Vue computed reading a plain `let` returns stale data — use ref
- @fastify/* plugin majors must match Fastify major (v10=4, v11=5)
- Importing from 'ws' needs @types/ws; prefer @fastify/websocket's types
- Bruno `run -r` is alphabetical, not dependency-ordered

Decisions:
- Sender exclusion via x-ws-client-id header (REST/WS are separate TCP
  connections, so attaching socket to request doesn't work)
- /ws endpoint is exempt from the Bruno gate (HTTP-only CLI limitation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): add typecheck script and resolve HomePage TS2532

CI was failing on `npm run typecheck` because no such script existed in
the root package.json. Add a per-workspace `typecheck` script that runs
`tsc --noEmit` (vue-tsc for client) and aggregate them via the root
script.

While verifying the new typecheck step locally, fix the only standing
TS2532 in `packages/client/src/pages/HomePage.vue:57`:
`posts.value[0]` is `T | undefined` under `noUncheckedIndexedAccess`,
and the `length > 0` guard does not narrow array indexing. Pull the
first element into a const and gate on its truthiness.

Verified locally:
- npm run typecheck → exit 0 across shared/server/client
- npm run lint → 0 errors (130 pre-existing warnings unchanged)
- 1458 tests / 89 files pass
- Coverage 100% (lines / branches / functions / statements)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): build shared before typecheck

CI failed because the client typecheck (vue-tsc) cannot resolve
'@forge/shared' until that workspace's dist/index.d.ts exists.
Locally we got lucky — prior test runs had already produced the dist.

Add a `pretypecheck` hook on the root package.json that builds the
shared workspace first, mirroring the existing `pretest` pattern.

Verified locally from a fresh state (rm -rf packages/shared/dist):
- npm run typecheck → exit 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(search): add implementation plan for issue #3

Plan approved after 3 iterations of adversarial review gate
(Feasibility / Completeness / Scope & Alignment).

Decomposes the PostgreSQL full-text + Cmd+K modal feature
into 11 work units. Notes that migration 001 already ships
the search_vector column, forge_search config, weighted
A/B/C trigger, and GIN indexes — so WU-1 verifies rather
than re-implements that infrastructure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(search): add skipped integration test for search_vector trigger (WU-1)

Migration 001 already ships the forge_search config, search_vector
column, weighted trigger (A/B/C), and refresh triggers. This suite
will verify them end-to-end, but no integration-test harness exists
yet in this repo so the suite is intentionally `.skip`-ed with a TODO.

A follow-up will add Testcontainers-Postgres (or similar) and remove
the `.skip`.

DoD items verified:
- [x] Integration file lives at the planned path
- [x] Test body captures the weighted A/B/C token assertions + tsquery match
- [x] `.skip` gate with a clear TODO
- [x] No regressions: 434 pass, 1 skip, typecheck clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(shared): add search types + Zod schema (WU-2)

Adds SearchQuery / SearchSnippet / AiAction / UserSummary /
SearchResponse plus searchQuerySchema for validating
GET /api/search query strings on both server and client.

Replaces the plan's z.coerce.boolean() for `fuzzy` with a
z.preprocess() guard — z.coerce.boolean() converts the string
"false" to true (any non-empty string is truthy), which would
silently mis-handle the intended query-string semantics.

DoD items verified:
- [x] searchQuerySchema matches spec shape
- [x] 5 interfaces with exact plan shapes (including matchedBy and excerpt on SearchSnippet)
- [x] Re-exports via types/index.ts
- [x] 22 tests covering round-trip, rejections, boundaries, coercion
- [x] typecheck clean
- [x] 100% coverage on search.ts
- [x] No `any`, no non-null assertions

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add search query layer (WU-3)

Three raw-SQL query functions in db/queries/search.ts:
  - searchPostsByTsvector — primary full-text path using
    plainto_tsquery('forge_search', $1) + ts_rank
  - searchPostsByTrigram  — fuzzy fallback using
    similarity(p.title, $1) > 0.3 AND title % $1
  - searchUsers — display_name match (trigram + ILIKE) with
    post_count via COUNT FILTER on public non-draft posts

Both post queries LATERAL-join the latest revision for an
excerpt and left-truncate to 200 chars. Filters (contentType,
tag) are parameterised via EXISTS for tag to avoid duplicates.
Similarity score is aliased as `rank` in both post queries so
WU-4 can treat them as a uniform row shape.

DoD verified:
- [x] All 3 functions export SearchPostRow / SearchUserRow
- [x] Soft-delete + visibility filters present on both post queries
- [x] 0.3 similarity threshold literal
- [x] 15 tests: every filter branch + single-quote parameterisation + users
- [x] typecheck clean, 100% coverage on search.ts

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add /api/search route + service (WU-4)

- services/search.ts: pure transforms (toSearchSnippet,
  toUserSummary) + buildAiActions stub that returns 2
  hardcoded actions ("Generate a {q} tutorial" / "Explain
  {q}") when q.length >= 2, else [].
- routes/search.ts: GET /search (public, no auth). Validates
  with searchQuerySchema; empty q short-circuits to empty
  response; tsvector primary; trigram fallback when <5 hits
  OR fuzzy=true; tsvector wins on id collision; users + ai
  actions in Promise.all; bare-catch DB errors -> 500
  { error: 'internal_error' } (no leak).
- app.ts: register searchRoutes with prefix /api so the path
  is GET /api/search.

DoD verified:
- [x] Public endpoint, no preHandler
- [x] Empty q -> 200 with empty shape, no DB calls
- [x] tsvector then trigram fallback at threshold 5
- [x] fuzzy=true skips tsvector
- [x] Filters (type, tag) pass through
- [x] 400 on bad type, 500 on DB throw with sanitized body
- [x] 23 tests (11 service + 12 route)
- [x] 100% coverage on services/search.ts and routes/search.ts
- [x] typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(bruno): add /api/search collection (WU-5)

Six requests under bruno/search/ covering basic, type filter,
tag filter, fuzzy, empty query (asserts totalResults=0), and
people match. All public — no bearer auth.

Gate verification (server on :3002 to avoid the dev :3001):
  bruno search collection: 6/6 requests, 31/31 asserts PASS
  full bruno regression:   41/41 requests, 31/31 asserts PASS

DoD verified:
- [x] All 6 .bru files use {{baseUrl}}, no hardcoded URLs
- [x] auth: none on every request (public endpoint)
- [x] empty-query asserts totalResults: eq 0
- [x] basic-query captures firstSnippetId for follow-ups
- [x] BLOCKING gate (live server) passed
- [x] No regression in full collection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add debounce util + Pinia search store (WU-6)

- lib/debounce.ts: 15-line generic debounce with cancel(),
  no @vueuse dependency.
- stores/search.ts: composition-API Pinia store. State =
  query, results, isLoading, isOpen, recentQueries (cap 10,
  case-insensitive dedupe, latest-first), activeIndex.
- localStorage persistence is best-effort: getItem/setItem
  failures are swallowed so the store works in SSR / test
  environments where localStorage is undefined.

Coder typed the debounce return as `((...args) => void) &
{ cancel }` rather than the plan's `T & { cancel }` because
debounced calls always return void — the original signature
in the plan was technically unsound TypeScript.

Adversarial review iteration 1 caught a missing SSR-guard
test path; added two tests that stub globalThis.localStorage
to undefined and verify init + pushRecent behave correctly.
Re-review PASSED.

DoD verified:
- [x] debounce + cancel + re-invoke
- [x] All 6 refs + 8 mutations
- [x] localStorage init: missing / invalid JSON / non-array → []
- [x] setItem throw swallowed; in-memory still updates
- [x] SSR (localStorage undefined) path covered by test
- [x] 30 tests, 100% coverage on both files
- [x] typecheck clean

Reviewed-by: adversarial-review (PASS, iter 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useSearch composable with debounced search

Implements the useSearch composable (WU-7) that wraps the search Pinia
store with a 300ms debounced apiFetch call. Returns stable query,
results, isLoading refs via storeToRefs, plus search() and clearResults()
functions. Empty/whitespace queries short-circuit without fetching.
Non-2xx and network errors are handled gracefully with console.warn.

17 tests, 100% coverage on all metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useKeyboard composable with cross-platform mod+k (WU-8)

useKeyboard().register('mod+k', handler) attaches a window
keydown listener once and dispatches by parsed shortcut.
On macOS `mod` resolves to event.metaKey; elsewhere ctrlKey.
SSR-safe (typeof navigator guard, falls back to ctrlKey).

When a registered shortcut matches, preventDefault() fires
(so browser Cmd+K is intercepted). The window listener is
attached lazily on first register and detached when the
last handler is unregistered, then re-attached on the next
register — verified via vi.spyOn(addEventListener) tests.

Also supports single-key tokens (escape, arrowup, arrowdown,
enter) for the modal's keyboard navigation.

Auto-cleanup via onScopeDispose when called inside a Vue
component setup (uses getCurrentScope to detect context;
silently no-ops when called outside a scope).

A `_resetForTesting()` helper resets the module singleton
between test cases — prefixed with `_` to signal internal,
not exposed via useKeyboard()'s return value.

DoD verified:
- [x] mod+k → metaKey on Mac, ctrlKey elsewhere
- [x] Single-key shortcuts (escape, arrows, enter)
- [x] preventDefault only on match
- [x] Listener lifecycle (attach/detach/re-attach)
- [x] Auto-cleanup via onScopeDispose
- [x] Shortcut name case-normalised
- [x] 22 tests, 100% coverage
- [x] No `any`, no `!`; typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add SearchResultItem + SearchResultGroup (WU-9)

SearchResultItem renders one of three variants — snippet, person,
or aiAction — bound to the same SearchSnippet | UserSummary |
AiAction discriminated-union prop. role="option", aria-selected
mirrors active. Click emits select (no payload — parent decides
navigation per variant). Active state uses bg-primary/10
(consistent with existing components; the plan's bg-primary-50
isn't defined in this project's Tailwind v4 config).

SearchResultGroup renders a heading + v-for of items. Empty
items produce no DOM. Per-item active = activeGlobalIndex ===
startIndex + i. Bubbles select with the global index payload,
giving the modal a single integer to address any item across
groups.

Initials fallback: split on spaces, take first char of each
word, cap at 2, uppercase. Singular/plural agreement on the
"X post(s)" line.

DoD verified:
- [x] 3 variants render the right fields
- [x] role="option" + aria-selected on root
- [x] Active class only when active=true
- [x] Empty group renders nothing
- [x] Group bubbles correct global index
- [x] 29 tests, 100% coverage on both .vue files
- [x] vue-tsc clean

Reviewed-by: adversarial-review (PASS); pre-commit eslint
caught test-file `!` non-null assertions, replaced with `?.`
optional chaining (project rule per memory note).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add TheSearchModal — first modal in the codebase (WU-10)

The Cmd+K modal: dialog with role="dialog" + aria-modal,
auto-focus on open, debounced typeahead via useSearch,
Esc to close, ArrowUp/Down with wrap-around through the
combined snippets+aiActions+people list, Enter on the
active item, Tab focus trap between input and close button,
backdrop click closes (dialog click .stop'd), focus
restored on close, "See all results" footer that deep-links
to /search?q=…

Per-variant select handlers (all push the *search query*,
not the item label, into recents BEFORE close):
  snippet  → pushRecent(q) → /posts/<id> → close
  person   → pushRecent(q) → close → /search?q=<displayName>
  aiAction → pushRecent(q) → close → console.info (Phase 3
             will replace this with real action dispatch)

Empty input shows the recent-search list (clicking a recent
populates input + triggers search). Loading shows a
"Searching..." line while in flight.

DoD verified:
- [x] Dialog ARIA, autofocus, Esc/backdrop close
- [x] Arrow nav with wrap-around
- [x] Tab focus trap forward + backward
- [x] Per-variant select (pushRecent before close, exact navigation)
- [x] "See all results" hidden when empty input
- [x] Recent searches in empty state
- [x] Loading state
- [x] 31 tests, 100% coverage, vue-tsc clean

Reviewed-by: adversarial-review (PASS); pre-commit eslint
caught missing browser-globals declaration in <script> and
an unused test import — added /* global ... */ comment per
the existing pattern in CodeViewer.vue, and dropped the
flushPromises import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add /search page + shell wiring (WU-11, final)

Wires the search feature into the app:

- pages/SearchPage.vue — /search?q&type&tag&fuzzy page with
  route-query-driven search on mount + on change; filter
  chips (type, tag) with X-to-remove; "Try fuzzy search"
  link when fuzzy=false and no results; empty-state CTA
  opens the Cmd+K modal; Snippets / AI Actions / People
  sections via SearchResultGroup.
- plugins/router.ts — registers /search as a child of the
  AppLayout parent with meta.requiresAuth=false so
  unauthenticated users can deep-link. Vue Router 4 merges
  meta child-wins, overriding the parent's requiresAuth=true
  for this specific path.
- layouts/AppLayout.vue — mounts <TheSearchModal /> once at
  layout root so Cmd+K works from any authenticated route.
- components/shell/TheTopBar.vue — replaces the readonly
  placeholder input with a click-to-open button (visual
  affordance: "Search…" placeholder + Cmd+K kbd badge);
  registers mod+k via useKeyboard at mount. Auto-cleanup
  handled by useKeyboard's onScopeDispose hook.

Tests (91 across 4 files, all passing):
- SearchPage.test.ts (31 new): mount search, route-query
  change re-search, filter-chip removal w/ cross-filter
  preservation, fuzzy-link visibility rules, loading and
  empty and no-results states.
- TheTopBar.test.ts (new file, 11 tests): preserves the 7
  previously-unreferenced behaviors + 4 new — search-button
  click opens modal; Cmd+K on Mac; Ctrl+K on non-Mac;
  handler cleaned up on unmount.
- AppLayout.test.ts: +1 smoke that TheSearchModal is
  rendered at layout root.
- router.test.ts: +4 for the /search route — existence,
  meta.requiresAuth=false, unauthenticated access without
  redirect, authenticated access.

Pre-commit hook caught:
- File named Search.vue violated vue/multi-word-component-
  names — renamed to SearchPage.vue to match the project's
  *Page.vue convention (HomePage, LoginPage, etc.).
- Unused `nextTick` and `mountPage` in the test — removed.

DoD verified:
- [x] /search reads q/type/tag/fuzzy and re-searches on change
- [x] Filter chips remove via router.push preserving others
- [x] Fuzzy link hidden when already fuzzy
- [x] Empty/loading/no-results states
- [x] Cmd+K globally registered on mount, cleaned on unmount
- [x] TheSearchModal mounted once in AppLayout
- [x] /search public (meta override verified)
- [x] 100% coverage on SearchPage.vue, TheTopBar.vue, AppLayout.vue, router.ts
- [x] vue-tsc clean, no any, no `!`

Reviewed-by: adversarial-review (PASS, pre-rename)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(knowledge): capture 6 learnings from search implementation

From the 11-WU orchestrated execution of /api/search + Cmd+K
modal (Issue #3), extracted the highest-value insights that
will save future agents a debugging cycle:

gotchas.jsonl:
  - gotcha-zod-coerce-boolean-querystring: z.coerce.boolean()
    decodes "false" as true; use z.preprocess instead.
  - gotcha-vue-multi-word-page-name: vue/multi-word-component-
    names rejects Search.vue; follow the *Page.vue convention.
  - gotcha-non-null-assertion-in-tests: ESLint enforces the
    no-non-null-assertion rule in test files too; use ?.[0]
    optional chaining, not ![0].

codebase-facts.jsonl:
  - fact-migration-001-search-infrastructure: migration 001
    pre-ships pg_trgm, unaccent, forge_search config,
    search_vector column, weighted A/B/C trigger, GIN indexes,
    and refresh triggers; future search work should verify,
    not re-create.

decisions.jsonl:
  - decision-tailwind-v4-primary-opacity-palette: the project
    uses a single --color-primary with opacity modifiers
    (bg-primary/10, bg-primary/20), not numbered palette
    variants.

patterns.jsonl:
  - pattern-worktree-setup-forge-monorepo: npm install + cp
    .env + unique PORT are required to use a new worktree for
    running tests and the server.

Deduplicated against existing /* global ... */ and Bruno
alphabetical-order facts (already captured on prior issues).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
multiandrewlab and others added 16 commits April 13, 2026 20:31
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… to direct

DoD items verified:
- [x] @langchain/community ^0.3.14 (installed ^0.3.59)
- [x] @langchain/core ^0.3.18 (installed ^0.3.80)
- [x] @langchain/google-vertexai ^0.1.3 (installed ^0.1.8)
- [x] @langchain/openai ^0.3.14 (installed ^0.3.17)
- [x] @codemirror/view ^6.27.0 (installed ^6.41.0) — direct client dep
- [x] .env.example already contains LLM_PROVIDER, LLM_MODEL, OLLAMA_BASE_URL
- [x] docker-compose.yml already has ollama service
- [x] npm install + typecheck clean; 1694 tests passing (no regression)

Reviewed-by: adversarial-review (PASS, iteration 2 — iter 1 misinterpreted phase ordering)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] AI_CONTEXT_MAX = 8000 exported
- [x] aiCompleteRequestSchema: before/after (max 8000), language (1-32)
- [x] AiCompleteRequest = z.infer<...> type exported
- [x] 5 tests (exact names), all green via TDD
- [x] Barrel export appended
- [x] typecheck clean; 1699 tests pass

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] createChatModel() reads LLM_PROVIDER (default ollama), LLM_MODEL (default gemma4)
- [x] Ollama arm: OLLAMA_BASE_URL default http://ollama:11434
- [x] OpenAI arm: modelName + OPENAI_API_KEY + streaming:true
- [x] Vertex arm: ChatVertexAI({ model })
- [x] Unknown provider throws Error
- [x] 5 tests (exact names), all green via TDD; process.env restored per test
- [x] vi.mock on all 3 LangChain modules; lint clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] AiRateLimiter: acquire(userId) -> AiSlot | null; release() idempotent
- [x] Default timeoutMs = 60_000; injectable now() for deterministic tests
- [x] Strict > boundary: 59_999ms still-live, 60_001ms reclaimed+aborted
- [x] Users tracked independently via Map
- [x] 7 tests (exact names), all green via TDD; lint clean (no !)

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] autocompletePrompt: ChatPromptTemplate with system (few-shot) + human (language/before/after)
- [x] createAutocompleteChain(model) pipes prompt -> model -> StringOutputParser
- [x] streamAutocomplete(chain, input, {signal}) yields chunks; aborts cleanly
- [x] 3 tests green via TDD; lint + typecheck clean

Deviation: tests use FakeListChatModel + hand-rolled chain mock (the plan's
original fake-pipe helper couldn't work with LangChain RunnableSequence).
Adversarial reviewer accepted the deviation.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] langchainPlugin via fp(...) with name 'langchain-plugin' and auth-plugin dep
- [x] app.aiProvider: lazy createChatModel(), cached
- [x] app.aiRateLimit: preHandler (401 if unauth, 429 + Retry-After:5 on contention, attaches request.aiSlot)
- [x] app.aiGate = [authenticate, aiRateLimit]
- [x] Module augmentation: FastifyInstance + FastifyRequest.aiSlot
- [x] onResponse + onError hooks release slot
- [x] app.ts: registered after authPlugin, before websocketPlugin
- [x] 2 tests green; 1716 full-suite tests pass (no regressions); lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] POST /api/ai/complete with preHandler: app.aiGate
- [x] createAbortHandlers(request, slot, timeoutMs) helper exported for unit tests
- [x] SSE headers: text/event-stream + no-cache + keep-alive + X-Accel-Buffering:no
- [x] Token events: event: token\ndata: {"text":"..."}\n\n
- [x] done event on success; error event on model failure
- [x] finally: cleanupAborts + slot.release + reply.raw.end
- [x] 8 tests green (3 createAbortHandlers + 5 route); 1724 full-suite pass; lint + typecheck clean
- [x] app.ts registers aiRoutes with prefix '/api/ai'

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] bruno/ai/complete.bru with meta/post/auth:bearer/body:json
- [x] post-response script checks for 'event: done' in SSE body
- [x] tests assert status 200 + content-type text/event-stream
- [x] Follows collection convention (matches bruno/comments/create-comment.bru format)

End-to-end execution against a running server happens in WU-014.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] setGhostText StateEffect + ghostTextField StateField + ghostTextExtension bundle
- [x] acceptGhostText(view): inserts at cursor, clears, returns true; false when empty
- [x] currentGhostText(state): read current text
- [x] Widget: class cm-ghost-text, opacity 0.4, color var(--text-muted, #999)
- [x] docChanged clears ghost inside StateField.update
- [x] 6 tests green via TDD; lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] parseSseStream(ReadableStream<Uint8Array>) async generator yields {event, data}
- [x] TextDecoder streaming mode; splits on \n\n; frames across chunks handled
- [x] Default event='message' when no event: line; malformed JSON frames skipped
- [x] Reader released in finally
- [x] 5 tests green via TDD; lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] suggestion, isLoading refs; requestCompletion/acceptSuggestion/dismissSuggestion/cancel
- [x] 300ms debounce; new call cancels previous debounce + aborts in-flight
- [x] Fetches POST /api/ai/complete with AbortController signal
- [x] parseSseStream accumulates token.text; breaks on done; errors clear suggestion
- [x] acceptSuggestion returns text + clears; dismissSuggestion clears + cancels
- [x] 8 tests green via TDD; lint + typecheck clean

Test adjustments: hanging ReadableStream for abort tests (empty-closed stream
completes synchronously and clears controller before the abort assertion).

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…orView

DoD items verified:
- [x] props.editorView: EditorView (required)
- [x] watch(suggestion) -> dispatch setGhostText.of(val)
- [x] keydown listener on contentDOM: Tab accepts + preventDefault (when ghost present); other keys dismiss; no-op when empty
- [x] onMounted adds, onBeforeUnmount removes + calls cancel()
- [x] defineExpose({ requestCompletion })
- [x] Template: hidden span wrapper
- [x] 5 tests green (real EditorView + ghostTextExtension); lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DoD items verified:
- [x] CodeEditor.vue: @ready handler captures EditorView; ghostTextExtension added to base + language extensions
- [x] CodeEditor.vue: defineExpose({ view }) (shallowRef auto-unwraps via template ref)
- [x] PostEditor.vue: editorRef + editorView computed + aiRef; watcher on props.modelValue/language/editorView
- [x] PostEditor.vue: <AiSuggestion v-if="editorView" ref="aiRef"/> adjacent to CodeEditor
- [x] CodeEditor.test.ts: 21 -> 22 tests (added "exposes EditorView via defineExpose after ready event"); extension counts updated for ghostTextExtension
- [x] PostEditor.test.ts: 21 -> 23 tests (mounts AiSuggestion / forwards requestCompletion on modelValue change)
- [x] 1751 full-suite tests pass; lint + typecheck clean (only 2 pre-existing warnings on untouched lines)

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added tests for defensive / fallback branches that weren't reachable via the
main tests:
- plugin.test.ts: aiRateLimit 401 when request.user missing; onError hook
  releases the slot so subsequent requests succeed
- ai.test.ts: 500 defensive guard when aiSlot missing (minimal app without
  aiRateLimit preHandler); non-Error throw falls back to 'stream_error'
- ghost-text.test.ts: GhostWidget.eq() and ignoreEvent() unit-tested directly
  (GhostWidget now exported)
- sse-stream.test.ts: frame with only event: line (no data:) is skipped
- useAiComplete.test.ts: cancel() clears a pending debounce before it fires;
  cancel() no-op path; non-ok fetch response clears suggestion
- PostEditor.vue: removed dead `?? 'plaintext'` fallback (language is required
  by the prop type)

Coverage: 100% lines/branches/functions/statements (was 99.79/99.54/99.54/99.79
after WU-013). Full suite: 1761 passing, 1 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gotchas (6):
- Fastify onRequest fires before preHandler; request.user undefined there
- Vue 3 defineExpose auto-unwraps shallowRef one level (editorRef.value.view)
- LangChain ChatPromptTemplate requires {{ }} to escape literal braces
- Fake-pipe pattern doesn't work for LangChain chains; use FakeListChatModel
- Vitest v8 counts ?? branches; remove dead fallbacks on required props
- Husky post-failure staging carryover can combine logical commits

Patterns (2):
- Fastify plugins that reference app.authenticate must declare
  dependencies: ["auth-plugin"] via fp() config
- Extract testable helpers (createAbortHandlers) for SSE route branches
  that light-my-request cannot reach (client-disconnect + setTimeout)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

Important

Review skipped

Too many files!

This PR contains 195 files, which is 45 over the limit of 150.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ca656514-1375-42e5-8dfe-73e9f61ca9fb

📥 Commits

Reviewing files that changed from the base of the PR and between b1591f0 and 4a83849.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (195)
  • .beads/knowledge/codebase-facts.jsonl
  • .beads/knowledge/decisions.jsonl
  • .beads/knowledge/gotchas.jsonl
  • .beads/knowledge/patterns.jsonl
  • .gitignore
  • CLAUDE.md
  • bruno/ai/complete.bru
  • bruno/bookmarks/list-bookmarks.bru
  • bruno/bookmarks/toggle-bookmark.bru
  • bruno/comments/create-comment.bru
  • bruno/comments/create-inline-comment.bru
  • bruno/comments/create-reply.bru
  • bruno/comments/delete-comment.bru
  • bruno/comments/edit-comment.bru
  • bruno/comments/list-comments-by-revision.bru
  • bruno/comments/list-comments.bru
  • bruno/environments/local.bru
  • bruno/posts/create-post.bru
  • bruno/posts/get-feed.bru
  • bruno/search/basic-query.bru
  • bruno/search/empty-query.bru
  • bruno/search/filter-by-tag.bru
  • bruno/search/filter-by-type.bru
  • bruno/search/fuzzy-search.bru
  • bruno/search/people-match.bru
  • bruno/tags/list-tags.bru
  • bruno/tags/popular-tags.bru
  • bruno/tags/subscribe.bru
  • bruno/tags/subscriptions.bru
  • bruno/tags/unsubscribe.bru
  • bruno/votes/downvote.bru
  • bruno/votes/remove-vote.bru
  • bruno/votes/upvote.bru
  • docs/superpowers/plans/2026-04-13-search-postgres-fts-cmdk.md
  • package.json
  • packages/client/package.json
  • packages/client/src/__tests__/components/editor/AiSuggestion.test.ts
  • packages/client/src/__tests__/components/editor/CodeEditor.test.ts
  • packages/client/src/__tests__/components/editor/EditorToolbar.test.ts
  • packages/client/src/__tests__/components/editor/PostEditor.test.ts
  • packages/client/src/__tests__/components/post/CodeViewer.test.ts
  • packages/client/src/__tests__/components/post/CommentInput.test.ts
  • packages/client/src/__tests__/components/post/CommentSection.test.ts
  • packages/client/src/__tests__/components/post/CommentThread.test.ts
  • packages/client/src/__tests__/components/post/InlineComment.test.ts
  • packages/client/src/__tests__/components/post/PostActions.test.ts
  • packages/client/src/__tests__/components/post/PostDetail.test.ts
  • packages/client/src/__tests__/components/post/PostListItem.test.ts
  • packages/client/src/__tests__/components/post/PresenceIndicator.test.ts
  • packages/client/src/__tests__/components/search/SearchResultGroup.test.ts
  • packages/client/src/__tests__/components/search/SearchResultItem.test.ts
  • packages/client/src/__tests__/components/shell/TheSearchModal.test.ts
  • packages/client/src/__tests__/components/shell/TheSidebar.test.ts
  • packages/client/src/__tests__/components/shell/TheTopBar.test.ts
  • packages/client/src/__tests__/composables/useAiComplete.test.ts
  • packages/client/src/__tests__/composables/useBookmarks.test.ts
  • packages/client/src/__tests__/composables/useComments.test.ts
  • packages/client/src/__tests__/composables/useFeed.test.ts
  • packages/client/src/__tests__/composables/useKeyboard.test.ts
  • packages/client/src/__tests__/composables/usePresence.test.ts
  • packages/client/src/__tests__/composables/useSearch.test.ts
  • packages/client/src/__tests__/composables/useTags.test.ts
  • packages/client/src/__tests__/composables/useVotes.test.ts
  • packages/client/src/__tests__/composables/useWebSocket.test.ts
  • packages/client/src/__tests__/layouts/AppLayout.test.ts
  • packages/client/src/__tests__/lib/ai/ghost-text.test.ts
  • packages/client/src/__tests__/lib/ai/sse-stream.test.ts
  • packages/client/src/__tests__/lib/api.test.ts
  • packages/client/src/__tests__/lib/debounce.test.ts
  • packages/client/src/__tests__/pages/HomePage.test.ts
  • packages/client/src/__tests__/pages/PostViewPage.test.ts
  • packages/client/src/__tests__/pages/SearchPage.test.ts
  • packages/client/src/__tests__/plugins/router.test.ts
  • packages/client/src/__tests__/stores/comments.test.ts
  • packages/client/src/__tests__/stores/feed.test.ts
  • packages/client/src/__tests__/stores/realtime.test.ts
  • packages/client/src/__tests__/stores/search.test.ts
  • packages/client/src/__tests__/stores/tags.test.ts
  • packages/client/src/components/editor/AiSuggestion.vue
  • packages/client/src/components/editor/CodeEditor.vue
  • packages/client/src/components/editor/EditorToolbar.vue
  • packages/client/src/components/editor/PostEditor.vue
  • packages/client/src/components/post/CodeViewer.vue
  • packages/client/src/components/post/CommentInput.vue
  • packages/client/src/components/post/CommentSection.vue
  • packages/client/src/components/post/CommentThread.vue
  • packages/client/src/components/post/InlineComment.vue
  • packages/client/src/components/post/PostActions.vue
  • packages/client/src/components/post/PostDetail.vue
  • packages/client/src/components/post/PresenceIndicator.vue
  • packages/client/src/components/search/SearchResultGroup.vue
  • packages/client/src/components/search/SearchResultItem.vue
  • packages/client/src/components/shell/TheSearchModal.vue
  • packages/client/src/components/shell/TheSidebar.vue
  • packages/client/src/components/shell/TheTopBar.vue
  • packages/client/src/composables/useAiComplete.ts
  • packages/client/src/composables/useBookmarks.ts
  • packages/client/src/composables/useComments.ts
  • packages/client/src/composables/useFeed.ts
  • packages/client/src/composables/useKeyboard.ts
  • packages/client/src/composables/usePresence.ts
  • packages/client/src/composables/useSearch.ts
  • packages/client/src/composables/useTags.ts
  • packages/client/src/composables/useVotes.ts
  • packages/client/src/composables/useWebSocket.ts
  • packages/client/src/layouts/AppLayout.vue
  • packages/client/src/lib/ai/ghost-text.ts
  • packages/client/src/lib/ai/sse-stream.ts
  • packages/client/src/lib/api.ts
  • packages/client/src/lib/debounce.ts
  • packages/client/src/pages/HomePage.vue
  • packages/client/src/pages/PostViewPage.vue
  • packages/client/src/pages/SearchPage.vue
  • packages/client/src/plugins/router.ts
  • packages/client/src/stores/comments.ts
  • packages/client/src/stores/feed.ts
  • packages/client/src/stores/realtime.ts
  • packages/client/src/stores/search.ts
  • packages/client/src/stores/tags.ts
  • packages/server/package.json
  • packages/server/src/__tests__/app.test.ts
  • packages/server/src/__tests__/db/queries/bookmarks.test.ts
  • packages/server/src/__tests__/db/queries/comments.test.ts
  • packages/server/src/__tests__/db/queries/feed.test.ts
  • packages/server/src/__tests__/db/queries/search.test.ts
  • packages/server/src/__tests__/db/queries/tags.test.ts
  • packages/server/src/__tests__/db/queries/votes.test.ts
  • packages/server/src/__tests__/integration/search-trigger.test.ts
  • packages/server/src/__tests__/plugins/langchain/autocomplete-chain.test.ts
  • packages/server/src/__tests__/plugins/langchain/plugin.test.ts
  • packages/server/src/__tests__/plugins/langchain/provider.test.ts
  • packages/server/src/__tests__/plugins/langchain/rate-limiter.test.ts
  • packages/server/src/__tests__/plugins/websocket/broadcast.test.ts
  • packages/server/src/__tests__/plugins/websocket/channels.test.ts
  • packages/server/src/__tests__/plugins/websocket/connections.test.ts
  • packages/server/src/__tests__/plugins/websocket/handler.test.ts
  • packages/server/src/__tests__/plugins/websocket/index.test.ts
  • packages/server/src/__tests__/plugins/websocket/presence.test.ts
  • packages/server/src/__tests__/routes/ai.test.ts
  • packages/server/src/__tests__/routes/bookmarks.test.ts
  • packages/server/src/__tests__/routes/comments.test.ts
  • packages/server/src/__tests__/routes/posts.test.ts
  • packages/server/src/__tests__/routes/search.test.ts
  • packages/server/src/__tests__/routes/tags.test.ts
  • packages/server/src/__tests__/routes/votes.test.ts
  • packages/server/src/__tests__/services/comments.test.ts
  • packages/server/src/__tests__/services/search.test.ts
  • packages/server/src/app.ts
  • packages/server/src/db/queries/bookmarks.ts
  • packages/server/src/db/queries/comments.ts
  • packages/server/src/db/queries/feed.ts
  • packages/server/src/db/queries/search.ts
  • packages/server/src/db/queries/tags.ts
  • packages/server/src/db/queries/types.ts
  • packages/server/src/db/queries/votes.ts
  • packages/server/src/plugins/langchain/chains/autocomplete.ts
  • packages/server/src/plugins/langchain/index.ts
  • packages/server/src/plugins/langchain/prompts/autocomplete.ts
  • packages/server/src/plugins/langchain/provider.ts
  • packages/server/src/plugins/langchain/rate-limiter.ts
  • packages/server/src/plugins/websocket/broadcast.ts
  • packages/server/src/plugins/websocket/channels.ts
  • packages/server/src/plugins/websocket/connections.ts
  • packages/server/src/plugins/websocket/handler.ts
  • packages/server/src/plugins/websocket/index.ts
  • packages/server/src/plugins/websocket/presence.ts
  • packages/server/src/routes/ai.ts
  • packages/server/src/routes/bookmarks.ts
  • packages/server/src/routes/comments.ts
  • packages/server/src/routes/posts.ts
  • packages/server/src/routes/search.ts
  • packages/server/src/routes/tags.ts
  • packages/server/src/routes/votes.ts
  • packages/server/src/services/comments.ts
  • packages/server/src/services/search.ts
  • packages/shared/package.json
  • packages/shared/src/__tests__/types/search.test.ts
  • packages/shared/src/__tests__/types/types.test.ts
  • packages/shared/src/__tests__/types/websocket.test.ts
  • packages/shared/src/__tests__/validators/ai.test.ts
  • packages/shared/src/__tests__/validators/comment.test.ts
  • packages/shared/src/__tests__/validators/vote.test.ts
  • packages/shared/src/types/bookmark.ts
  • packages/shared/src/types/comment.ts
  • packages/shared/src/types/feed.ts
  • packages/shared/src/types/index.ts
  • packages/shared/src/types/search.ts
  • packages/shared/src/types/tag.ts
  • packages/shared/src/types/vote.ts
  • packages/shared/src/types/websocket.ts
  • packages/shared/src/validators/ai.ts
  • packages/shared/src/validators/comment.ts
  • packages/shared/src/validators/index.ts
  • packages/shared/src/validators/vote.ts
  • vitest.config.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/langchain-ai-autocomplete

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

In `bruno -r` recursive runs, directories execute alphabetically so
`ai/` fires before `auth/` — the `accessToken` collection variable is
empty and `ai/complete` returns 401. The pre-request script logs in if
the token is not yet set, making the request self-contained.

Verified: full `-r` regression now reports 42/42 passed, 31/31
assertions, 2/2 tests; ai/complete returns 200 with streamed tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@multiandrewlab multiandrewlab merged commit dc79726 into feat/database-schema-migrations Apr 13, 2026
1 check passed
@multiandrewlab multiandrewlab deleted the feat/langchain-ai-autocomplete branch April 13, 2026 21:43
multiandrewlab added a commit that referenced this pull request Apr 13, 2026
* feat(shared): add vote, bookmark, tag types and vote validator

Add VoteValue, VoteResponse, BookmarkToggleResponse, Tag, TagSubscriptionResponse
types and voteSchema Zod validator. Update FeedSort to include 'personalized'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add vote toggle and removal routes

POST /api/posts/:id/vote — idempotent toggle (same value removes)
DELETE /api/posts/:id/vote — explicit vote removal
Adds getUserVote query. DB trigger handles vote_count updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add bookmark toggle and bookmark list routes

POST /api/posts/:id/bookmark — toggles bookmark on/off
GET /api/bookmarks — paginated list of bookmarked posts
Adds getUserBookmark query. Reuses findFeedPosts with filter=bookmarked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add tag routes, subscriptions, and post-tag assignment

GET /api/tags — list/search tags with autocomplete
GET /api/tags/popular — top tags by post count
POST/DELETE /api/tags/:id/subscribe — tag subscriptions
GET /api/tags/subscriptions — user's subscribed tags
POST /api/posts now processes tags array (find-or-create + link)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add personalized feed sort with subscription filter

sort=personalized checks user tag subscriptions, filters posts by
subscribed tags via EXISTS clause, and ranks by hotness score.
Falls back to trending when user has no subscriptions.
Also adds shared type/validator tests for full coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add vote/bookmark composables and wire PostActions

- Extend feed store with userVotes/userBookmarks reactive state
- useVotes composable: vote toggle + explicit removal via API
- useBookmarks composable: bookmark toggle via API
- PostActions rewritten: upvote, downvote, bookmark (functional),
  fork and history (disabled placeholders)
- PostListItem vote count reactivity verified via store mutation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add tags store/composable and wire sidebar followed tags

- Tags Pinia store: subscribedTags, popularTags, subscribe/unsubscribe
- useTags composable: loadSubscriptions, searchTags, subscribe/unsubscribe
- TheSidebar: dynamic followed tags from API, empty state,
  tag click filters feed via useFeed().setTag()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add tag autocomplete to editor toolbar

Debounced search-as-you-type via useTags().searchTags(), dropdown
suggestions with post counts, click/Enter to add, inline tag creation,
max 10 tags enforced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(bruno): add API requests for votes, bookmarks, and tags

New request collections:
- votes/: upvote, downvote, remove-vote
- bookmarks/: toggle-bookmark, list-bookmarks
- tags/: list-tags, popular-tags, subscribe, unsubscribe, subscriptions
Update get-feed.bru docs for sort=personalized.
Add tagId to local environment variables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: capture learnings from issue #18 implementation

- V8 finally block coverage artifact workaround
- Vue SFC browser globals ESLint pattern
- ON CONFLICT DO NOTHING toggle pattern for bookmarks
- Parallel subagent route registration safety

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(shared): add Comment types and validator schemas for issue #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add comment query functions with author joins for issue #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add toComment service transformer for issue #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add comments Pinia store with tree building and stale detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add CommentInput component for issue #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add comment CRUD routes for issue #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useComments composable for comment CRUD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add CommentThread component with reply/edit/delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add CommentSection, InlineComment and wire into PostDetail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: improve coverage for comment routes and PostDetail integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(bruno): add API requests for comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bruno): capture commentId and revisionId in post-response scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude type-only files from coverage and add PATCH post-not-found test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: achieve 100% coverage for comment components

- Add v8 ignore for unreachable DOM guard in CodeViewer
- Add timeAgo branch tests (minutes, hours, days) for CommentThread
- Add inline comment indicator tests for PostDetail
- Add authStore.user null path test for PostDetail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: achieve 100% coverage across all metrics for comment components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add Bruno API test requirements to project instructions

Bruno requests are now a blocking gate for any feature that adds
or modifies API endpoints — same enforcement level as unit tests
and coverage thresholds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: WebSocket infrastructure & real-time events (#1) (#24)

* feat(shared): add WebSocket message type schemas (WU-001)

Add discriminated-union TypeScript types and zod schemas for every
client<->server WebSocket message, plus per-variant schemas for
granular server-side validation.

DoD items verified:
- [x] ClientMessage union (auth/subscribe/unsubscribe/presence)
- [x] ServerMessage union (auth:*/comment:*/vote:*/revision:*/post:*/presence:update)
- [x] clientMessageSchema + serverMessageSchema via z.discriminatedUnion
- [x] All 17 types + schemas re-exported from packages/shared/src/types/index.ts
- [x] 69 tests, 100% coverage on websocket.ts

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add ConnectionManager for WebSocket multi-tab support (WU-002)

Tracks WebSocket connections per user via Map<userId, Set<socket>> and
indexes by clientId UUID for later sender-exclusion broadcasts (WU-006).

DoD items verified:
- [x] Class with add/remove/get/getAll/findByClientId
- [x] Last connection removed → user key pruned
- [x] clientId removed from index on disconnect
- [x] 11 tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add ChannelManager for WebSocket pub/sub (WU-003)

In-memory channel subscriber registry. `broadcast` serializes once,
skips non-OPEN sockets, and supports per-call sender exclusion.
`removeFromAll` is called on socket close to clean up subscriptions.

DoD items verified:
- [x] subscribe/unsubscribe/broadcast/getSubscribers/removeFromAll
- [x] broadcast stringifies once, excludes sender, skips closed sockets
- [x] Empty channel entries pruned on unsubscribe/removeFromAll
- [x] 14 tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add PresenceTracker with eviction interval (WU-004)

Tracks per-channel per-user last-seen timestamps. Entries older than
60s are evicted. A factory creates a 15s interval that evicts and
broadcasts presence:update for affected channels.

DoD items verified:
- [x] Class exports update/remove/evict/getViewers
- [x] PRESENCE_EVICTION_THRESHOLD_MS constant (60_000)
- [x] createPresenceEvictionInterval factory
- [x] Eviction boundary: strict >, exact 60s entries retained
- [x] 23 tests with fake timers, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add WebSocket plugin with auth handshake (WU-005)

Register @fastify/websocket@^11 at /ws, implement an auth-handshake
state machine, and expose ConnectionManager/ChannelManager/PresenceTracker
via app.websocket so REST routes can broadcast in WU-006.

State machine:
- awaiting-auth: non-auth frame or malformed JSON → close(4001, 'auth-required')
- auth + valid JWT → auth:ok, register with UUID clientId
- auth + invalid JWT → auth:error + close(4002, 'auth-failed')
- authenticated + subscribe/unsubscribe → ChannelManager
- authenticated + presence (viewing) → PresenceTracker
- authenticated + unknown/malformed → log and ignore
- JWT expiry mid-session → auth:expired, revert to awaiting-auth (socket stays open)
- close → removeFromAll + removeConnection (if authenticated)

Includes app.test.ts boot-resilience smoke test and @types/ws devDep.

DoD items verified:
- [x] @fastify/websocket pinned to ^11 (Fastify-5 compatible line)
- [x] handler.ts state machine covers all 12 branches
- [x] index.ts fastify-plugin with app.websocket decoration, onClose cleanup
- [x] app.ts registers plugin after auth, before routes
- [x] 100% coverage, 29 new tests (24 handler + 4 index + 1 app smoke)

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): broadcast REST mutations over WebSocket (WU-006)

Wire every mutation route to the WebSocket ChannelManager so connected
clients receive comment/vote/revision/post events in real time.

Broadcasts added:
- comment:new / comment:updated / comment:deleted on post:<id>
- vote:updated on post:<id>
- revision:new on post:<id>
- post:new / post:updated on feed
  - post:new on POST /
  - post:updated on PATCH /:id, POST /:id/publish, POST /:id/revisions

Sender exclusion via new x-ws-client-id request header, read through
getExcludeWs helper that looks up the socket by clientId. Feed-channel
broadcasts fetch a PostWithAuthor via new findFeedPostById query helper.

Soft-delete (DELETE /:id) intentionally does not broadcast; clients
invalidate via cache expiration or a separate refresh path (documented).

DoD items verified:
- [x] getExcludeWs helper (5 tests)
- [x] All 3 comment mutations broadcast
- [x] Both vote mutations broadcast
- [x] post:new, post:updated x3, revision:new all broadcast
- [x] findFeedPostById helper with 3 tests
- [x] Existing REST tests updated to assert broadcasts
- [x] 100% coverage maintained

Reviewed-by: adversarial-review (PASS, attempt 2)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useWebSocket composable and realtime store (WU-007)

Singleton Vue composable owning one WebSocket per app load with:
- First-message auth handshake, token refresh on auth:expired
- Auto-reconnect exponential backoff 1s→30s cap, reset on auth:ok
- Channel re-subscription after reconnect
- Handler cleanup via returned unsubscribe function
- Send queue buffers messages while disconnected

Pinia realtime store exposes connection status + per-channel presence
(populated by WU-008).

clientId (UUID generated once at module load) is the token threaded
through REST calls in WU-009 to drive server sender-exclusion.

DoD items verified:
- [x] useRealtimeStore with status, presenceByChannel, actions
- [x] useWebSocket singleton with subscribe/send/connect/disconnect/clientId
- [x] Exponential backoff with 30s cap (6+ retry test)
- [x] Channel re-subscription on reconnect
- [x] auth:expired → re-auth (handlers kept)
- [x] auth:error → status disconnected, no reconnect
- [x] 56 new tests, 100% coverage

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add usePresence composable and PresenceIndicator (WU-008)

usePresence(postId) sends a presence heartbeat to the server immediately
on mount (and when postId changes to a truthy value), then every 30s.
Subscribes to presence:update events and writes the latest viewers list
into the realtime store. On postId change or unmount, cleans up interval,
unsubscribes, and clears the old channel's presence.

PresenceIndicator.vue renders up to 5 avatars stacked with -ml-2 overlap
and shows +N overflow for additional viewers. Hidden when no viewers.

Reactivity: activeChannel is a ref so the viewers computed re-evaluates
when postId switches to a channel with pre-seeded store presence — a
regression test guards this.

DoD items verified:
- [x] Immediate heartbeat + 30s interval
- [x] Subscribe to presence:update, write to realtime store
- [x] Cleanup on postId change and unmount
- [x] Null/undefined postId handled
- [x] 5 avatar max + overflow badge, v-if empty state
- [x] aria-label on root
- [x] 32 new tests, 100% coverage

Reviewed-by: adversarial-review (PASS, attempt 2)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): wire real-time events into comments, votes, feed, presence (WU-009)

Integrate WebSocket subscriptions with existing composables and app shell
so real-time events mutate the stores as they arrive, and so REST calls
carry the x-ws-client-id header for server-side sender exclusion.

Changes (all strictly additive — no existing exports changed):
- apiFetch injects x-ws-client-id on POST/PATCH/PUT/DELETE only
- useComments.subscribeRealtime: comment:new/updated/deleted → store
- useVotes.subscribeRealtime: vote:updated → new store.updateVoteCount
  (aggregate voteCount only; preserves userVote for current user)
- useFeed.subscribeRealtime: post:new prepends, post:updated replaces
  in place
- PostViewPage mounts PresenceIndicator and subscribes comments/votes
  on mount; cleans up on unmount
- AppLayout connects WS + feed subscription on auth, disconnects
  on logout; cleans up on unmount

DoD items verified:
- [x] x-ws-client-id header on mutating methods only
- [x] Comment/vote/feed subscribeRealtime functions
- [x] PresenceIndicator mounted on post detail
- [x] WS connect/disconnect lifecycle in AppLayout
- [x] Composable API strictly additive (existing tests still pass)
- [x] 42 new tests, 100% coverage maintained

Reviewed-by: adversarial-review (PASS)
Issue: #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(knowledge): capture learnings from WebSocket feature

Extract 6 high-value learnings from the WebSocket infrastructure
implementation (issue #1):

Gotchas:
- Vue computed reading a plain `let` returns stale data — use ref
- @fastify/* plugin majors must match Fastify major (v10=4, v11=5)
- Importing from 'ws' needs @types/ws; prefer @fastify/websocket's types
- Bruno `run -r` is alphabetical, not dependency-ordered

Decisions:
- Sender exclusion via x-ws-client-id header (REST/WS are separate TCP
  connections, so attaching socket to request doesn't work)
- /ws endpoint is exempt from the Bruno gate (HTTP-only CLI limitation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): add typecheck script and resolve HomePage TS2532 (#25)

* fix(ci): add typecheck script and resolve HomePage TS2532

CI was failing on `npm run typecheck` because no such script existed in
the root package.json. Add a per-workspace `typecheck` script that runs
`tsc --noEmit` (vue-tsc for client) and aggregate them via the root
script.

While verifying the new typecheck step locally, fix the only standing
TS2532 in `packages/client/src/pages/HomePage.vue:57`:
`posts.value[0]` is `T | undefined` under `noUncheckedIndexedAccess`,
and the `length > 0` guard does not narrow array indexing. Pull the
first element into a const and gate on its truthiness.

Verified locally:
- npm run typecheck → exit 0 across shared/server/client
- npm run lint → 0 errors (130 pre-existing warnings unchanged)
- 1458 tests / 89 files pass
- Coverage 100% (lines / branches / functions / statements)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): build shared before typecheck

CI failed because the client typecheck (vue-tsc) cannot resolve
'@forge/shared' until that workspace's dist/index.d.ts exists.
Locally we got lucky — prior test runs had already produced the dist.

Add a `pretypecheck` hook on the root package.json that builds the
shared workspace first, mirroring the existing `pretest` pattern.

Verified locally from a fresh state (rm -rf packages/shared/dist):
- npm run typecheck → exit 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: search (PostgreSQL full-text + Cmd+K modal) (#3) (#26)

* docs(search): add implementation plan for issue #3

Plan approved after 3 iterations of adversarial review gate
(Feasibility / Completeness / Scope & Alignment).

Decomposes the PostgreSQL full-text + Cmd+K modal feature
into 11 work units. Notes that migration 001 already ships
the search_vector column, forge_search config, weighted
A/B/C trigger, and GIN indexes — so WU-1 verifies rather
than re-implements that infrastructure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(search): add skipped integration test for search_vector trigger (WU-1)

Migration 001 already ships the forge_search config, search_vector
column, weighted trigger (A/B/C), and refresh triggers. This suite
will verify them end-to-end, but no integration-test harness exists
yet in this repo so the suite is intentionally `.skip`-ed with a TODO.

A follow-up will add Testcontainers-Postgres (or similar) and remove
the `.skip`.

DoD items verified:
- [x] Integration file lives at the planned path
- [x] Test body captures the weighted A/B/C token assertions + tsquery match
- [x] `.skip` gate with a clear TODO
- [x] No regressions: 434 pass, 1 skip, typecheck clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(shared): add search types + Zod schema (WU-2)

Adds SearchQuery / SearchSnippet / AiAction / UserSummary /
SearchResponse plus searchQuerySchema for validating
GET /api/search query strings on both server and client.

Replaces the plan's z.coerce.boolean() for `fuzzy` with a
z.preprocess() guard — z.coerce.boolean() converts the string
"false" to true (any non-empty string is truthy), which would
silently mis-handle the intended query-string semantics.

DoD items verified:
- [x] searchQuerySchema matches spec shape
- [x] 5 interfaces with exact plan shapes (including matchedBy and excerpt on SearchSnippet)
- [x] Re-exports via types/index.ts
- [x] 22 tests covering round-trip, rejections, boundaries, coercion
- [x] typecheck clean
- [x] 100% coverage on search.ts
- [x] No `any`, no non-null assertions

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add search query layer (WU-3)

Three raw-SQL query functions in db/queries/search.ts:
  - searchPostsByTsvector — primary full-text path using
    plainto_tsquery('forge_search', $1) + ts_rank
  - searchPostsByTrigram  — fuzzy fallback using
    similarity(p.title, $1) > 0.3 AND title % $1
  - searchUsers — display_name match (trigram + ILIKE) with
    post_count via COUNT FILTER on public non-draft posts

Both post queries LATERAL-join the latest revision for an
excerpt and left-truncate to 200 chars. Filters (contentType,
tag) are parameterised via EXISTS for tag to avoid duplicates.
Similarity score is aliased as `rank` in both post queries so
WU-4 can treat them as a uniform row shape.

DoD verified:
- [x] All 3 functions export SearchPostRow / SearchUserRow
- [x] Soft-delete + visibility filters present on both post queries
- [x] 0.3 similarity threshold literal
- [x] 15 tests: every filter branch + single-quote parameterisation + users
- [x] typecheck clean, 100% coverage on search.ts

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(server): add /api/search route + service (WU-4)

- services/search.ts: pure transforms (toSearchSnippet,
  toUserSummary) + buildAiActions stub that returns 2
  hardcoded actions ("Generate a {q} tutorial" / "Explain
  {q}") when q.length >= 2, else [].
- routes/search.ts: GET /search (public, no auth). Validates
  with searchQuerySchema; empty q short-circuits to empty
  response; tsvector primary; trigram fallback when <5 hits
  OR fuzzy=true; tsvector wins on id collision; users + ai
  actions in Promise.all; bare-catch DB errors -> 500
  { error: 'internal_error' } (no leak).
- app.ts: register searchRoutes with prefix /api so the path
  is GET /api/search.

DoD verified:
- [x] Public endpoint, no preHandler
- [x] Empty q -> 200 with empty shape, no DB calls
- [x] tsvector then trigram fallback at threshold 5
- [x] fuzzy=true skips tsvector
- [x] Filters (type, tag) pass through
- [x] 400 on bad type, 500 on DB throw with sanitized body
- [x] 23 tests (11 service + 12 route)
- [x] 100% coverage on services/search.ts and routes/search.ts
- [x] typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(bruno): add /api/search collection (WU-5)

Six requests under bruno/search/ covering basic, type filter,
tag filter, fuzzy, empty query (asserts totalResults=0), and
people match. All public — no bearer auth.

Gate verification (server on :3002 to avoid the dev :3001):
  bruno search collection: 6/6 requests, 31/31 asserts PASS
  full bruno regression:   41/41 requests, 31/31 asserts PASS

DoD verified:
- [x] All 6 .bru files use {{baseUrl}}, no hardcoded URLs
- [x] auth: none on every request (public endpoint)
- [x] empty-query asserts totalResults: eq 0
- [x] basic-query captures firstSnippetId for follow-ups
- [x] BLOCKING gate (live server) passed
- [x] No regression in full collection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add debounce util + Pinia search store (WU-6)

- lib/debounce.ts: 15-line generic debounce with cancel(),
  no @vueuse dependency.
- stores/search.ts: composition-API Pinia store. State =
  query, results, isLoading, isOpen, recentQueries (cap 10,
  case-insensitive dedupe, latest-first), activeIndex.
- localStorage persistence is best-effort: getItem/setItem
  failures are swallowed so the store works in SSR / test
  environments where localStorage is undefined.

Coder typed the debounce return as `((...args) => void) &
{ cancel }` rather than the plan's `T & { cancel }` because
debounced calls always return void — the original signature
in the plan was technically unsound TypeScript.

Adversarial review iteration 1 caught a missing SSR-guard
test path; added two tests that stub globalThis.localStorage
to undefined and verify init + pushRecent behave correctly.
Re-review PASSED.

DoD verified:
- [x] debounce + cancel + re-invoke
- [x] All 6 refs + 8 mutations
- [x] localStorage init: missing / invalid JSON / non-array → []
- [x] setItem throw swallowed; in-memory still updates
- [x] SSR (localStorage undefined) path covered by test
- [x] 30 tests, 100% coverage on both files
- [x] typecheck clean

Reviewed-by: adversarial-review (PASS, iter 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useSearch composable with debounced search

Implements the useSearch composable (WU-7) that wraps the search Pinia
store with a 300ms debounced apiFetch call. Returns stable query,
results, isLoading refs via storeToRefs, plus search() and clearResults()
functions. Empty/whitespace queries short-circuit without fetching.
Non-2xx and network errors are handled gracefully with console.warn.

17 tests, 100% coverage on all metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add useKeyboard composable with cross-platform mod+k (WU-8)

useKeyboard().register('mod+k', handler) attaches a window
keydown listener once and dispatches by parsed shortcut.
On macOS `mod` resolves to event.metaKey; elsewhere ctrlKey.
SSR-safe (typeof navigator guard, falls back to ctrlKey).

When a registered shortcut matches, preventDefault() fires
(so browser Cmd+K is intercepted). The window listener is
attached lazily on first register and detached when the
last handler is unregistered, then re-attached on the next
register — verified via vi.spyOn(addEventListener) tests.

Also supports single-key tokens (escape, arrowup, arrowdown,
enter) for the modal's keyboard navigation.

Auto-cleanup via onScopeDispose when called inside a Vue
component setup (uses getCurrentScope to detect context;
silently no-ops when called outside a scope).

A `_resetForTesting()` helper resets the module singleton
between test cases — prefixed with `_` to signal internal,
not exposed via useKeyboard()'s return value.

DoD verified:
- [x] mod+k → metaKey on Mac, ctrlKey elsewhere
- [x] Single-key shortcuts (escape, arrows, enter)
- [x] preventDefault only on match
- [x] Listener lifecycle (attach/detach/re-attach)
- [x] Auto-cleanup via onScopeDispose
- [x] Shortcut name case-normalised
- [x] 22 tests, 100% coverage
- [x] No `any`, no `!`; typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add SearchResultItem + SearchResultGroup (WU-9)

SearchResultItem renders one of three variants — snippet, person,
or aiAction — bound to the same SearchSnippet | UserSummary |
AiAction discriminated-union prop. role="option", aria-selected
mirrors active. Click emits select (no payload — parent decides
navigation per variant). Active state uses bg-primary/10
(consistent with existing components; the plan's bg-primary-50
isn't defined in this project's Tailwind v4 config).

SearchResultGroup renders a heading + v-for of items. Empty
items produce no DOM. Per-item active = activeGlobalIndex ===
startIndex + i. Bubbles select with the global index payload,
giving the modal a single integer to address any item across
groups.

Initials fallback: split on spaces, take first char of each
word, cap at 2, uppercase. Singular/plural agreement on the
"X post(s)" line.

DoD verified:
- [x] 3 variants render the right fields
- [x] role="option" + aria-selected on root
- [x] Active class only when active=true
- [x] Empty group renders nothing
- [x] Group bubbles correct global index
- [x] 29 tests, 100% coverage on both .vue files
- [x] vue-tsc clean

Reviewed-by: adversarial-review (PASS); pre-commit eslint
caught test-file `!` non-null assertions, replaced with `?.`
optional chaining (project rule per memory note).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add TheSearchModal — first modal in the codebase (WU-10)

The Cmd+K modal: dialog with role="dialog" + aria-modal,
auto-focus on open, debounced typeahead via useSearch,
Esc to close, ArrowUp/Down with wrap-around through the
combined snippets+aiActions+people list, Enter on the
active item, Tab focus trap between input and close button,
backdrop click closes (dialog click .stop'd), focus
restored on close, "See all results" footer that deep-links
to /search?q=…

Per-variant select handlers (all push the *search query*,
not the item label, into recents BEFORE close):
  snippet  → pushRecent(q) → /posts/<id> → close
  person   → pushRecent(q) → close → /search?q=<displayName>
  aiAction → pushRecent(q) → close → console.info (Phase 3
             will replace this with real action dispatch)

Empty input shows the recent-search list (clicking a recent
populates input + triggers search). Loading shows a
"Searching..." line while in flight.

DoD verified:
- [x] Dialog ARIA, autofocus, Esc/backdrop close
- [x] Arrow nav with wrap-around
- [x] Tab focus trap forward + backward
- [x] Per-variant select (pushRecent before close, exact navigation)
- [x] "See all results" hidden when empty input
- [x] Recent searches in empty state
- [x] Loading state
- [x] 31 tests, 100% coverage, vue-tsc clean

Reviewed-by: adversarial-review (PASS); pre-commit eslint
caught missing browser-globals declaration in <script> and
an unused test import — added /* global ... */ comment per
the existing pattern in CodeViewer.vue, and dropped the
flushPromises import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(client): add /search page + shell wiring (WU-11, final)

Wires the search feature into the app:

- pages/SearchPage.vue — /search?q&type&tag&fuzzy page with
  route-query-driven search on mount + on change; filter
  chips (type, tag) with X-to-remove; "Try fuzzy search"
  link when fuzzy=false and no results; empty-state CTA
  opens the Cmd+K modal; Snippets / AI Actions / People
  sections via SearchResultGroup.
- plugins/router.ts — registers /search as a child of the
  AppLayout parent with meta.requiresAuth=false so
  unauthenticated users can deep-link. Vue Router 4 merges
  meta child-wins, overriding the parent's requiresAuth=true
  for this specific path.
- layouts/AppLayout.vue — mounts <TheSearchModal /> once at
  layout root so Cmd+K works from any authenticated route.
- components/shell/TheTopBar.vue — replaces the readonly
  placeholder input with a click-to-open button (visual
  affordance: "Search…" placeholder + Cmd+K kbd badge);
  registers mod+k via useKeyboard at mount. Auto-cleanup
  handled by useKeyboard's onScopeDispose hook.

Tests (91 across 4 files, all passing):
- SearchPage.test.ts (31 new): mount search, route-query
  change re-search, filter-chip removal w/ cross-filter
  preservation, fuzzy-link visibility rules, loading and
  empty and no-results states.
- TheTopBar.test.ts (new file, 11 tests): preserves the 7
  previously-unreferenced behaviors + 4 new — search-button
  click opens modal; Cmd+K on Mac; Ctrl+K on non-Mac;
  handler cleaned up on unmount.
- AppLayout.test.ts: +1 smoke that TheSearchModal is
  rendered at layout root.
- router.test.ts: +4 for the /search route — existence,
  meta.requiresAuth=false, unauthenticated access without
  redirect, authenticated access.

Pre-commit hook caught:
- File named Search.vue violated vue/multi-word-component-
  names — renamed to SearchPage.vue to match the project's
  *Page.vue convention (HomePage, LoginPage, etc.).
- Unused `nextTick` and `mountPage` in the test — removed.

DoD verified:
- [x] /search reads q/type/tag/fuzzy and re-searches on change
- [x] Filter chips remove via router.push preserving others
- [x] Fuzzy link hidden when already fuzzy
- [x] Empty/loading/no-results states
- [x] Cmd+K globally registered on mount, cleaned on unmount
- [x] TheSearchModal mounted once in AppLayout
- [x] /search public (meta override verified)
- [x] 100% coverage on SearchPage.vue, TheTopBar.vue, AppLayout.vue, router.ts
- [x] vue-tsc clean, no any, no `!`

Reviewed-by: adversarial-review (PASS, pre-rename)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(knowledge): capture 6 learnings from search implementation

From the 11-WU orchestrated execution of /api/search + Cmd+K
modal (Issue #3), extracted the highest-value insights that
will save future agents a debugging cycle:

gotchas.jsonl:
  - gotcha-zod-coerce-boolean-querystring: z.coerce.boolean()
    decodes "false" as true; use z.preprocess instead.
  - gotcha-vue-multi-word-page-name: vue/multi-word-component-
    names rejects Search.vue; follow the *Page.vue convention.
  - gotcha-non-null-assertion-in-tests: ESLint enforces the
    no-non-null-assertion rule in test files too; use ?.[0]
    optional chaining, not ![0].

codebase-facts.jsonl:
  - fact-migration-001-search-infrastructure: migration 001
    pre-ships pg_trgm, unaccent, forge_search config,
    search_vector column, weighted A/B/C trigger, GIN indexes,
    and refresh triggers; future search work should verify,
    not re-create.

decisions.jsonl:
  - decision-tailwind-v4-primary-opacity-palette: the project
    uses a single --color-primary with opacity modifiers
    (bg-primary/10, bg-primary/20), not numbered palette
    variants.

patterns.jsonl:
  - pattern-worktree-setup-forge-monorepo: npm install + cp
    .env + unique PORT are required to use a new worktree for
    running tests and the server.

Deduplicated against existing /* global ... */ and Bruno
alphabetical-order facts (already captured on prior issues).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: ignore .worktrees/ for isolated worktree development

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(wu-001): add LangChain server deps and promote @codemirror/view to direct

DoD items verified:
- [x] @langchain/community ^0.3.14 (installed ^0.3.59)
- [x] @langchain/core ^0.3.18 (installed ^0.3.80)
- [x] @langchain/google-vertexai ^0.1.3 (installed ^0.1.8)
- [x] @langchain/openai ^0.3.14 (installed ^0.3.17)
- [x] @codemirror/view ^6.27.0 (installed ^6.41.0) — direct client dep
- [x] .env.example already contains LLM_PROVIDER, LLM_MODEL, OLLAMA_BASE_URL
- [x] docker-compose.yml already has ollama service
- [x] npm install + typecheck clean; 1694 tests passing (no regression)

Reviewed-by: adversarial-review (PASS, iteration 2 — iter 1 misinterpreted phase ordering)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-002): add aiCompleteRequestSchema to @forge/shared

DoD items verified:
- [x] AI_CONTEXT_MAX = 8000 exported
- [x] aiCompleteRequestSchema: before/after (max 8000), language (1-32)
- [x] AiCompleteRequest = z.infer<...> type exported
- [x] 5 tests (exact names), all green via TDD
- [x] Barrel export appended
- [x] typecheck clean; 1699 tests pass

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-003): LangChain provider factory (ollama/openai/vertex)

DoD items verified:
- [x] createChatModel() reads LLM_PROVIDER (default ollama), LLM_MODEL (default gemma4)
- [x] Ollama arm: OLLAMA_BASE_URL default http://ollama:11434
- [x] OpenAI arm: modelName + OPENAI_API_KEY + streaming:true
- [x] Vertex arm: ChatVertexAI({ model })
- [x] Unknown provider throws Error
- [x] 5 tests (exact names), all green via TDD; process.env restored per test
- [x] vi.mock on all 3 LangChain modules; lint clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-005): in-memory per-user AI rate limiter

DoD items verified:
- [x] AiRateLimiter: acquire(userId) -> AiSlot | null; release() idempotent
- [x] Default timeoutMs = 60_000; injectable now() for deterministic tests
- [x] Strict > boundary: 59_999ms still-live, 60_001ms reclaimed+aborted
- [x] Users tracked independently via Map
- [x] 7 tests (exact names), all green via TDD; lint clean (no !)

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-004): autocomplete prompt template and chain

DoD items verified:
- [x] autocompletePrompt: ChatPromptTemplate with system (few-shot) + human (language/before/after)
- [x] createAutocompleteChain(model) pipes prompt -> model -> StringOutputParser
- [x] streamAutocomplete(chain, input, {signal}) yields chunks; aborts cleanly
- [x] 3 tests green via TDD; lint + typecheck clean

Deviation: tests use FakeListChatModel + hand-rolled chain mock (the plan's
original fake-pipe helper couldn't work with LangChain RunnableSequence).
Adversarial reviewer accepted the deviation.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-006): LangChain Fastify plugin (aiProvider, aiRateLimit, aiGate)

DoD items verified:
- [x] langchainPlugin via fp(...) with name 'langchain-plugin' and auth-plugin dep
- [x] app.aiProvider: lazy createChatModel(), cached
- [x] app.aiRateLimit: preHandler (401 if unauth, 429 + Retry-After:5 on contention, attaches request.aiSlot)
- [x] app.aiGate = [authenticate, aiRateLimit]
- [x] Module augmentation: FastifyInstance + FastifyRequest.aiSlot
- [x] onResponse + onError hooks release slot
- [x] app.ts: registered after authPlugin, before websocketPlugin
- [x] 2 tests green; 1716 full-suite tests pass (no regressions); lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-007): POST /api/ai/complete with SSE streaming

DoD items verified:
- [x] POST /api/ai/complete with preHandler: app.aiGate
- [x] createAbortHandlers(request, slot, timeoutMs) helper exported for unit tests
- [x] SSE headers: text/event-stream + no-cache + keep-alive + X-Accel-Buffering:no
- [x] Token events: event: token\ndata: {"text":"..."}\n\n
- [x] done event on success; error event on model failure
- [x] finally: cleanupAborts + slot.release + reply.raw.end
- [x] 8 tests green (3 createAbortHandlers + 5 route); 1724 full-suite pass; lint + typecheck clean
- [x] app.ts registers aiRoutes with prefix '/api/ai'

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(wu-008): Bruno request for POST /api/ai/complete (SSE)

DoD items verified:
- [x] bruno/ai/complete.bru with meta/post/auth:bearer/body:json
- [x] post-response script checks for 'event: done' in SSE body
- [x] tests assert status 200 + content-type text/event-stream
- [x] Follows collection convention (matches bruno/comments/create-comment.bru format)

End-to-end execution against a running server happens in WU-014.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-009): CodeMirror ghost-text extension

DoD items verified:
- [x] setGhostText StateEffect + ghostTextField StateField + ghostTextExtension bundle
- [x] acceptGhostText(view): inserts at cursor, clears, returns true; false when empty
- [x] currentGhostText(state): read current text
- [x] Widget: class cm-ghost-text, opacity 0.4, color var(--text-muted, #999)
- [x] docChanged clears ghost inside StateField.update
- [x] 6 tests green via TDD; lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-010): client-side SSE stream parser

DoD items verified:
- [x] parseSseStream(ReadableStream<Uint8Array>) async generator yields {event, data}
- [x] TextDecoder streaming mode; splits on \n\n; frames across chunks handled
- [x] Default event='message' when no event: line; malformed JSON frames skipped
- [x] Reader released in finally
- [x] 5 tests green via TDD; lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-011): useAiComplete composable

DoD items verified:
- [x] suggestion, isLoading refs; requestCompletion/acceptSuggestion/dismissSuggestion/cancel
- [x] 300ms debounce; new call cancels previous debounce + aborts in-flight
- [x] Fetches POST /api/ai/complete with AbortController signal
- [x] parseSseStream accumulates token.text; breaks on done; errors clear suggestion
- [x] acceptSuggestion returns text + clears; dismissSuggestion clears + cancels
- [x] 8 tests green via TDD; lint + typecheck clean

Test adjustments: hanging ReadableStream for abort tests (empty-closed stream
completes synchronously and clears controller before the abort assertion).

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-012): AiSuggestion.vue wires useAiComplete to CodeMirror EditorView

DoD items verified:
- [x] props.editorView: EditorView (required)
- [x] watch(suggestion) -> dispatch setGhostText.of(val)
- [x] keydown listener on contentDOM: Tab accepts + preventDefault (when ghost present); other keys dismiss; no-op when empty
- [x] onMounted adds, onBeforeUnmount removes + calls cancel()
- [x] defineExpose({ requestCompletion })
- [x] Template: hidden span wrapper
- [x] 5 tests green (real EditorView + ghostTextExtension); lint + typecheck clean

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(wu-013): wire AI autocomplete into CodeEditor + PostEditor

DoD items verified:
- [x] CodeEditor.vue: @ready handler captures EditorView; ghostTextExtension added to base + language extensions
- [x] CodeEditor.vue: defineExpose({ view }) (shallowRef auto-unwraps via template ref)
- [x] PostEditor.vue: editorRef + editorView computed + aiRef; watcher on props.modelValue/language/editorView
- [x] PostEditor.vue: <AiSuggestion v-if="editorView" ref="aiRef"/> adjacent to CodeEditor
- [x] CodeEditor.test.ts: 21 -> 22 tests (added "exposes EditorView via defineExpose after ready event"); extension counts updated for ghostTextExtension
- [x] PostEditor.test.ts: 21 -> 23 tests (mounts AiSuggestion / forwards requestCompletion on modelValue change)
- [x] 1751 full-suite tests pass; lint + typecheck clean (only 2 pre-existing warnings on untouched lines)

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(wu-014): close remaining branch-coverage gaps to hit 100%

Added tests for defensive / fallback branches that weren't reachable via the
main tests:
- plugin.test.ts: aiRateLimit 401 when request.user missing; onError hook
  releases the slot so subsequent requests succeed
- ai.test.ts: 500 defensive guard when aiSlot missing (minimal app without
  aiRateLimit preHandler); non-Error throw falls back to 'stream_error'
- ghost-text.test.ts: GhostWidget.eq() and ignoreEvent() unit-tested directly
  (GhostWidget now exported)
- sse-stream.test.ts: frame with only event: line (no data:) is skipped
- useAiComplete.test.ts: cancel() clears a pending debounce before it fires;
  cancel() no-op path; non-ok fetch response clears suggestion
- PostEditor.vue: removed dead `?? 'plaintext'` fallback (language is required
  by the prop type)

Coverage: 100% lines/branches/functions/statements (was 99.79/99.54/99.54/99.79
after WU-013). Full suite: 1761 passing, 1 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(kb): capture 8 learnings from issue #9 LangChain AI autocomplete

Gotchas (6):
- Fastify onRequest fires before preHandler; request.user undefined there
- Vue 3 defineExpose auto-unwraps shallowRef one level (editorRef.value.view)
- LangChain ChatPromptTemplate requires {{ }} to escape literal braces
- Fake-pipe pattern doesn't work for LangChain chains; use FakeListChatModel
- Vitest v8 counts ?? branches; remove dead fallbacks on required props
- Husky post-failure staging carryover can combine logical commits

Patterns (2):
- Fastify plugins that reference app.authenticate must declare
  dependencies: ["auth-plugin"] via fp() config
- Extract testable helpers (createAbortHandlers) for SSE route branches
  that light-my-request cannot reach (client-disconnect + setTimeout)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(bruno): add pre-request login to ai/complete for -r regression

In `bruno -r` recursive runs, directories execute alphabetically so
`ai/` fires before `auth/` — the `accessToken` collection variable is
empty and `ai/complete` returns 401. The pre-request script logs in if
the token is not yet set, making the request self-contained.

Verified: full `-r` regression now reports 42/42 passed, 31/31
assertions, 2/2 tests; ai/complete returns 200 with streamed tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
multiandrewlab added a commit that referenced this pull request Apr 13, 2026
The cherry-pick of PR #27 with -X ours preserved main's versions of
packages/server/src/app.ts and packages/shared/src/validators/index.ts,
which lacked the LangChain plugin registration, AI route registration,
and the ai.ts validator re-export. Adds them back so AI autocomplete
works end-to-end on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
multiandrewlab added a commit that referenced this pull request Apr 28, 2026
#57)

Hotfix for the broken Bruno API Regression workflow on main. The
`fork-post.bru` and `refresh-link-preview.bru` requests reference
{{forkablePostId}} and {{createdLinkPostId}} variables that were added
to bruno/environments/local.bru by recent PRs (#27 forking, #?? link
sharing) but never added to bruno/environments/ci.bru.

CI runs use --env ci, so the substitution failed and Bruno sent the
literal string "{{forkablePostId}}" in the URL. The server returned
500 from `invalid input syntax for type uuid: "{{forkablePostId}}"`.

Fix: add both variables to ci.bru so the two envs are identical.

`forkablePostId` points to the seeded public post owned by a
different user (so the testuser can fork it). `createdLinkPostId`
is empty by default — set at runtime by create-link-post.bru's
post-response and consumed by refresh-link-preview.bru.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[10/19] LangChain integration & AI autocomplete

1 participant