nexus/.planning/milestones/v1.3-phases/24-search-history-branching/24-01-PLAN.md
Nexus Dev ffc7b130e4 chore: archive v1.3 phase directories to milestones/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:48 +00:00

12 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
24-search-history-branching 01 execute 2
24-00
server/src/services/chat.ts
server/src/routes/chat.ts
true
CHAT-07
CHAT-13
CHAT-14
HIST-04
HIST-09
HIST-10
HIST-11
PERF-04
truths artifacts key_links
searchMessages returns ranked results using tsvector FTS in under 500ms
toggleBookmark creates or removes a bookmark for a message
getBookmarks returns bookmarked messages with conversation titles
branchConversation creates child conversation with copied messages up to branch point
exportConversation returns Markdown or JSON file content with agent names resolved
listBranches returns child conversations for a parent
path provides contains
server/src/services/chat.ts searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation searchMessages
path provides contains
server/src/routes/chat.ts Search, bookmark, branch, export HTTP routes messages/search
from to via pattern
server/src/routes/chat.ts server/src/services/chat.ts svc.searchMessages, svc.toggleBookmark, svc.branchConversation, svc.exportConversation svc.searchMessages
from to via pattern
server/src/services/chat.ts packages/db/src/schema/chat_message_bookmarks.ts import chatMessageBookmarks chatMessageBookmarks
Implement all server-side service methods and Express routes for search, bookmarks, branching, and export.

Purpose: Complete backend API so the UI plans can wire against real endpoints. Output: Six new service methods and five new route handlers.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-search-history-branching/24-RESEARCH.md @.planning/phases/24-search-history-branching/24-00-SUMMARY.md

@server/src/services/chat.ts @server/src/routes/chat.ts @packages/db/src/schema/chat_conversations.ts @packages/db/src/schema/chat_messages.ts @packages/db/src/schema/chat_message_bookmarks.ts @packages/shared/src/types/chat.ts @packages/shared/src/validators/chat.ts

ChatMessageSearchResult { messageId, conversationId, conversationTitle, content, role, agentId, createdAt, rank } ChatMessageSearchResponse { items: ChatMessageSearchResult[] } ChatBookmark { id, companyId, messageId, conversationId, createdAt } ChatBookmarkWithMessage extends ChatBookmark { message: ChatMessage, conversationTitle } ChatBookmarkListResponse { items: ChatBookmarkWithMessage[] } ChatBookmarkToggleResponse { bookmarked: boolean }

searchMessagesSchema: z.object({ q: string.min(2).max(200), limit: coerce.number.optional() }) branchConversationSchema: z.object({ branchFromMessageId: string.uuid() })

assertBoard(req) + assertCompanyAccess(req, companyId) guard on all company-scoped routes chatService(db) factory pattern returning plain object

Task 1: Service methods — searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation server/src/services/chat.ts server/src/services/chat.ts, packages/db/src/schema/chat_messages.ts, packages/db/src/schema/chat_conversations.ts, packages/db/src/schema/chat_message_bookmarks.ts, packages/db/src/schema/index.ts, packages/shared/src/types/chat.ts Add these methods to the `chatService(db)` return object. Import `sql, asc, lte` from drizzle-orm (add to existing import). Import `chatMessageBookmarks` from `@paperclipai/db`. Import `agents` from `@paperclipai/db` for export agent name resolution.
**searchMessages(companyId, query, opts: { limit?: number }):**
- Use `sql` template for `plainto_tsquery('english', ${query})` and `ts_rank`
- Join chatMessages with chatConversations on conversationId + companyId + isNull(deletedAt)
- WHERE: `sql\`"chat_messages"."content_search" @@ plainto_tsquery('english', ${query})\``
- ORDER BY: `desc(sql\`ts_rank(...)\`)`
- Limit: `Math.min(opts.limit ?? 20, 50)`
- Return `{ items }` matching ChatMessageSearchResponse shape
- If query.trim() is empty, return `{ items: [] }` early

**toggleBookmark(companyId, messageId, conversationId):**
- Check if bookmark exists: `SELECT id FROM chat_message_bookmarks WHERE company_id = ? AND message_id = ?`
- If exists: DELETE and return `{ bookmarked: false }`
- If not: INSERT and return `{ bookmarked: true }`
- Use a single transaction for atomicity

**getBookmarks(companyId, opts: { conversationId?: string }):**
- Select from chatMessageBookmarks JOIN chatMessages JOIN chatConversations
- Filter by companyId, optionally by conversationId
- Order by chatMessageBookmarks.createdAt desc
- Return `{ items }` matching ChatBookmarkListResponse shape
- Each item includes the full message object and conversationTitle

**branchConversation(parentConversationId, branchFromMessageId, companyId):**
- Follow Pattern 2 from RESEARCH.md exactly
- Get branchMsg createdAt, then get all messages up to that point ordered by asc(createdAt)
- Create new conversation with parentConversationId and branchFromMessageId set
- Copy messages into new conversation (spread rest, exclude id and conversationId)
- Return the new conversation
- Throw notFound if branchFromMessageId does not exist

**listBranches(conversationId):**
- SELECT from chatConversations WHERE parentConversationId = conversationId AND deletedAt IS NULL
- Order by createdAt desc
- Return array of conversations

**exportConversation(conversationId, format: "markdown" | "json"):**
- Get conversation metadata (title, createdAt)
- Get ALL messages ordered by asc(createdAt) — no pagination
- Join with agents table to resolve agentId -> agent name (LEFT JOIN agents ON chatMessages.agentId = agents.id)
- **Markdown format:** Build string: `# {title}\nExported: {date}\n\n---\n\n` then for each message: `**{agentName || role}** ({timestamp})\n{content}\n\n---\n\n`
- For user messages, use "You" as the speaker name
- **JSON format:** Return `JSON.stringify({ conversation: { id, title, createdAt }, messages }, null, 2)`
- Return `{ content: string, filename: string }` — filename is `{title-slug}-{date}.md` or `.json`
- Sanitize title for filename: lowercase, replace spaces with hyphens, remove special chars, truncate to 50 chars
- Add code comment: `// Note: loads all messages in memory. For very large conversations, consider streaming in future.`
cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10 - grep -q "searchMessages" server/src/services/chat.ts - grep -q "toggleBookmark" server/src/services/chat.ts - grep -q "getBookmarks" server/src/services/chat.ts - grep -q "branchConversation" server/src/services/chat.ts - grep -q "listBranches" server/src/services/chat.ts - grep -q "exportConversation" server/src/services/chat.ts - grep -q "plainto_tsquery" server/src/services/chat.ts - grep -q "ts_rank" server/src/services/chat.ts - grep -q "chatMessageBookmarks" server/src/services/chat.ts Six service methods implemented: searchMessages uses tsvector FTS with ts_rank ordering, toggleBookmark does insert-or-delete, getBookmarks joins with messages and conversations, branchConversation copies messages up to branch point, listBranches queries child conversations, exportConversation resolves agent names and produces Markdown or JSON. Task 2: Express routes — search, bookmarks, branch, export server/src/routes/chat.ts server/src/routes/chat.ts, packages/shared/src/validators/chat.ts Add imports for `searchMessagesSchema` and `branchConversationSchema` from `@paperclipai/shared`.
**GET /companies/:companyId/messages/search:**
- `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
- Parse query params with `searchMessagesSchema.parse({ q: req.query.q, limit: req.query.limit })`
- Call `svc.searchMessages(companyId, parsed.q, { limit: parsed.limit })`
- Wrap parse in try/catch — on ZodError return 400 with `{ error: "Query must be at least 2 characters" }`
- Return 200 with results

**POST /conversations/:id/bookmarks:**
- `assertBoard(req)`
- Get conversationId from `req.params.id`
- Get messageId from `req.body.messageId` (validate with `z.string().uuid()`)
- Get companyId by first calling `svc.getConversation(conversationId)` to obtain `conversation.companyId`
- Call `svc.toggleBookmark(companyId, messageId, conversationId)`
- Return 200 with `{ bookmarked }` response

**GET /companies/:companyId/bookmarks:**
- `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
- Optional query param `conversationId`
- Call `svc.getBookmarks(companyId, { conversationId })`
- Return 200 with results

**POST /conversations/:id/branch:**
- `assertBoard(req)`
- Parse body with `branchConversationSchema.parse(req.body)`
- Get conversation to extract companyId: `const conv = await svc.getConversation(req.params.id!)`
- Call `svc.branchConversation(req.params.id!, parsed.branchFromMessageId, conv.companyId)`
- Return 201 with new conversation

**GET /conversations/:id/branches:**
- `assertBoard(req)`
- Call `svc.listBranches(req.params.id!)`
- Return 200 with `{ items }` array

**GET /conversations/:id/export:**
- `assertBoard(req)`
- Parse format: `const format = req.query.format === "json" ? "json" : "markdown"`
- Call `svc.exportConversation(req.params.id!, format)`
- Set headers: `Content-Disposition: attachment; filename="{filename}"`, `Content-Type: {mime}`
- `res.send(content)`
cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10 - grep -q "messages/search" server/src/routes/chat.ts - grep -q "bookmarks" server/src/routes/chat.ts - grep -q "branch" server/src/routes/chat.ts - grep -q "export" server/src/routes/chat.ts - grep -q "searchMessagesSchema" server/src/routes/chat.ts - grep -q "branchConversationSchema" server/src/routes/chat.ts - grep -q "Content-Disposition" server/src/routes/chat.ts Six route handlers implemented: message search (GET), bookmark toggle (POST), bookmark list (GET), branch create (POST), branch list (GET), export download (GET). All routes use assertBoard guard. Search validates query length. Export sets file download headers. - `pnpm --filter @paperclipai/server build` passes - Search route uses searchMessagesSchema validation - Branch route uses branchConversationSchema validation - Export route sets Content-Disposition header - All routes use assertBoard(req) guard pattern

<success_criteria> Server builds cleanly. Six service methods and six route handlers exist. Search uses tsvector FTS. Export resolves agent names. Branch copies messages. Bookmark toggles insert/delete. </success_criteria>

After completion, create `.planning/phases/24-search-history-branching/24-01-SUMMARY.md`