Skip to content

v0.6.0.0 feat(assistant): ChatGPT-style chat at #/assistant — phase 1 (#135)#139

Merged
jayzalowitz merged 1 commit into
mainfrom
jayzalowitz/assistant-phase-1
May 5, 2026
Merged

v0.6.0.0 feat(assistant): ChatGPT-style chat at #/assistant — phase 1 (#135)#139
jayzalowitz merged 1 commit into
mainfrom
jayzalowitz/assistant-phase-1

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

Summary

Phase 1 of issue #135 — adds a ChatGPT-style conversational assistant at #/assistant with the dark glass aesthetic, persisted threads, and four new API endpoints. Out of scope for this phase (deferred to phase 2+): SSE streaming, twin/memory context enrichment, action-intent routing through the decision engine, tool use, mobile UI.

What landed

@skytwin/assistant (new package)

Stateless AssistantService wrapping LlmClient. Pure — persistence and HTTP concerns live in @skytwin/db and apps/api so the LLM interaction unit-tests against a stubbed client. MAX_HISTORY_TURNS = 20 cap protects cost / latency / provider context-window. Older turns stay persisted (the user sees them) but aren't fed back to the model.

DB (026-assistant-threads.sql)

Two tables, parent assistant_threads (id, user_id, title, created_at, updated_at) + child assistant_messages (id, thread_id, role, content, created_at, metadata) with ON DELETE CASCADE. Two indexes for the read paths. metadata JSONB reserved for phase 2's provider/model/latency stamps.

assistantRepository.appendMessage runs in a transaction so the message INSERT and the parent's updated_at UPDATE are atomic — without it a race could leave a thread with messages but a stale updated_at, demoting it unfairly in the list ordering.

API routes

Method Path Purpose
POST /api/assistant/messages Submit message, get reply
GET /api/assistant/threads?userId=… List user's threads
GET /api/assistant/threads/:id?userId=… Fetch thread + messages
DELETE /api/assistant/threads/:id?userId=… Cascade-delete thread

All four mounted under sessionAuth + requireOwnership. Don't-leak-existence semantics: a thread the user doesn't own returns the same 404 as a thread that doesn't exist.

POST /messages notes:

  • User message persisted FIRST so it survives an LLM provider outage.
  • Returns 409 when the user has no AI provider configured (dashboard surfaces "set one up in Settings").
  • Returns 502 + attempted: [...] when every provider in the chain fails.
  • 16K byte cap on content (well under CRDB's row-size limit).

Web (#/assistant)

Two-column layout: threads rail on the left (with "New" + per-thread delete), message log + composer on the right. Reuses the existing warm-glass dark variant (#0c0a14 ≈ the spec's #0b0d10) instead of forking a parallel theme system — saves ~500 lines of CSS variables and means the assistant tracks whichever theme the user has selected.

  • Composer: Enter sends, Shift+Enter inserts a newline.
  • Optimistic user bubble + animated typing dots during the round-trip.
  • Singleton click + submit + keydown delegators wired once on document, gated by window.location.hash per CLAUDE.md "Frontend Event Handling" rules.
  • All Gmail / user strings flow through escapeHtml; no inline onclick.
  • Respects prefers-reduced-motion.

Safety / invariants

  • Safety Invariant feat: bootstrap SkyTwin monorepo (v0.1.0.0) #1 (no auto-execute without policy check) — phase 1 is text-only. The default system prompt explicitly tells the assistant it can only converse and to redirect users to dashboard surfaces for actions. Phase 2 adds the action-intent routing through @skytwin/decision-engine.
  • Safety Invariant Make SkyTwin usable by a non-technical person end-to-end #2 (always log explanations) — N/A in phase 1 because no actions are taken. Phase 2 will emit ExplanationRecords when the assistant routes through the decision pipeline.
  • Information leak hygiene — repo + route both return identical 404 for not-found and not-owned threads.
  • Frontend XSS — all dynamic content escaped; singleton listeners hash-gated; no listener leaks across re-renders.

Test plan

  • pnpm build — 20 packages green
  • pnpm test — 40 packages, all green; 24 new tests (8 service + 7 thread title + 9 validator)
  • pnpm lint — clean
  • Manual: create user, configure an AI provider in Settings, navigate to #/assistant, send a message, verify reply lands; create second thread; switch between threads; delete a thread; reload page and verify threads persist
  • Manual: with no AI provider configured, verify 409 surfaces a meaningful message
  • Migration apply: pnpm db:migrate against fresh local CRDB

Phase 2+ follow-ups (filing as separate issues, not this PR)

  • SSE streaming on POST /messages — route is structured to layer on without redesign
  • Twin profile + Memory Palace context enrichment in the system prompt
  • Action-intent routing through @skytwin/decision-engine — assistant gains the ability to send mail / modify calendar / spend money behind policy gates
  • Multi-turn LLM API (refactor LlmClient.generate to take a messages array, drop the User: / Assistant: prompt-flattening workaround)
  • Feedback (thumbs up/down on a reply) wired to twin model per Safety Invariant feat: SkyTwin M2/M3/M4 — safe delegation, real workflows, learning & evals #6
  • Mobile chat UI under apps/mobile

🤖 Generated with Claude Code

…ase 1 (#135)

Phase 1 of issue #135: text-only chat surface at #/assistant with persisted
threads and four new API endpoints. Out of scope (deferred to phase 2+):
SSE streaming, twin/memory context enrichment, action-intent routing
through the decision engine, tool use, mobile UI.

New package @skytwin/assistant
- AssistantService stateless wrapper around LlmClient
- formatHistoryAsPrompt flattens ChatTurn[] to single-prompt for the
  LlmClient API today; phase 2 refactors LlmClient to take messages array
- MAX_HISTORY_TURNS = 20 cap protects cost/latency/context-window

DB: 026-assistant-threads.sql
- assistant_threads + assistant_messages with ON DELETE CASCADE
- (user_id, updated_at DESC) and (thread_id, created_at ASC) indexes
- assistantRepository with appendMessage in a transaction so the message
  insert and parent updated_at bump are atomic
- deriveThreadTitle pure helper (first line, ≤80 chars, ellipsis on overflow)

API: 4 endpoints under /api/assistant
- POST /messages, GET /threads, GET /threads/:id, DELETE /threads/:id
- All under sessionAuth + requireOwnership
- 409 when no AI provider configured, 502 on AllProvidersFailedError
- Don't-leak-existence on 404 (same response for not-found and not-owned)
- User message persisted BEFORE LLM call so it survives provider outage

Web: #/assistant route with dark glass aesthetic
- Reuses warm-glass dark variant (#0c0a14 ≈ spec'd #0b0d10) instead of
  forking a parallel theme system
- Two-column layout: threads rail + composer/messages
- Optimistic user bubble, animated typing dots while reply pending
- Singleton click+submit+keydown delegators wired once on document, gated
  by hash route (CLAUDE.md "Frontend Event Handling")
- All Gmail/user strings escapeHtml'd, no inline onclick
- Respects prefers-reduced-motion

Tests: 24 new (8 service + 7 thread title + 9 validator). Full suite
green across 40 packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 5, 2026 08:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the first phase of a new assistant feature: a persisted chat surface in the web app, a new @skytwin/assistant package for LLM-backed replies, and assistant thread/message storage plus API endpoints to create, fetch, list, and delete conversations.

Changes:

  • Added assistant persistence primitives in @skytwin/db plus migration 026-assistant-threads.sql.
  • Added a new @skytwin/assistant package with prompt formatting and reply generation logic.
  • Wired the assistant into the API and web SPA with new routes, client helpers, page rendering, and styling.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
VERSION Bumps release to 0.6.0.0.
pnpm-lock.yaml Registers the new workspace package and API dependency.
packages/db/src/repositories/index.ts Re-exports assistant repository/types.
packages/db/src/repositories/assistant-repository.ts Adds thread/message repo operations and title derivation.
packages/db/src/migrations/026-assistant-threads.sql Creates assistant thread/message tables and indexes.
packages/db/src/index.ts Surfaces assistant DB exports from package root.
packages/db/src/__tests__/assistant-thread-title.test.ts Tests thread title helper behavior.
packages/assistant/tsconfig.json Adds TS config for new assistant package.
packages/assistant/src/index.ts Exports assistant service API.
packages/assistant/src/assistant-service.ts Implements prompt shaping and LLM reply service.
packages/assistant/src/__tests__/assistant-service.test.ts Adds unit tests for assistant service/prompt formatting.
packages/assistant/package.json Defines new workspace package metadata/scripts.
CHANGELOG.md Documents the assistant feature release.
apps/web/public/js/pages/assistant.js Implements assistant SPA page, state, and event handling.
apps/web/public/js/app.js Registers #/assistant route.
apps/web/public/js/api-client.js Adds assistant API client helpers.
apps/web/public/index.html Loads assistant CSS and adds nav entry.
apps/web/public/css/assistant.css Styles assistant layout, bubbles, and composer.
apps/api/src/validators/assistant-message.ts Validates assistant message POST payloads.
apps/api/src/routes/assistant.ts Adds assistant HTTP endpoints and LLM integration.
apps/api/src/index.ts Mounts assistant router behind auth/ownership middleware.
apps/api/src/__tests__/assistant-message-validator.test.ts Tests assistant message validation rules.
apps/api/package.json Adds API dependency on @skytwin/assistant.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const trimmed = firstLine.trim();
if (trimmed.length === 0) return 'New conversation';
if (trimmed.length <= 80) return trimmed;
return `${trimmed.slice(0, 77)}…`;
Comment on lines +23 to +29
CREATE TABLE IF NOT EXISTS assistant_threads (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
title STRING NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Comment on lines +33 to +58
export async function renderAssistant(container, userId) {
_state.userId = userId;
ensureAssistantListener();

// Initial fetch — threads and (if any) the most recent thread's messages.
let threads = [];
try {
const data = await fetchAssistantThreads(userId);
threads = Array.isArray(data?.threads) ? data.threads : [];
} catch (err) {
container.innerHTML = renderError(err);
return;
}
_state.threads = threads;

// Default-select the most recent thread on first render of an existing
// session. If there are no threads, leave the right pane on the empty
// state and the composer ready to start a new conversation.
if (!_state.activeThreadId && threads.length > 0) {
_state.activeThreadId = threads[0].id;
}

if (_state.activeThreadId) {
try {
const data = await fetchAssistantThread(_state.activeThreadId, userId);
_state.messages = Array.isArray(data?.messages) ? data.messages : [];
Comment on lines +293 to +300
try {
const data = await fetchAssistantThread(threadId, _state.userId);
_state.messages = Array.isArray(data?.messages) ? data.messages : [];
} catch {
_state.activeThreadId = null;
_state.messages = [];
}
paint(container);
{
id: 'error',
role: 'assistant',
content: `Couldn't reach the assistant — ${err instanceof Error ? err.message : String(err)}`,
Comment on lines +78 to +175
router.post('/messages', async (req, res, next) => {
try {
const validation = validateAssistantMessage(req.body);
if (!validation.ok) {
res.status(400).json({
error: 'Invalid message payload',
details: validation.errors,
});
return;
}
const { userId, content, threadId: providedThreadId } = validation;

const llm = await buildLlmClientForUser(userId);
if (!llm) {
res.status(409).json({
error: 'No AI provider configured',
message:
'Configure at least one provider in Settings → AI providers before chatting with the assistant.',
});
return;
}

// Resolve the thread: existing one or new one based on the first
// user message. We persist the user message FIRST so it's durable
// even if the LLM call fails — the user shouldn't lose their input
// because of an upstream provider outage.
let threadId: string;
let isNewThread = false;
if (providedThreadId) {
const existing = await assistantRepository.getThread(userId, providedThreadId);
if (!existing) {
// Don't leak whether the thread exists vs. is owned by another
// user — same hygiene as the repository's documented contract.
res.status(404).json({ error: 'Thread not found' });
return;
}
threadId = existing.thread.id;
} else {
const newThread = await assistantRepository.createThread(userId, content);
threadId = newThread.id;
isNewThread = true;
}

const userMessage = await assistantRepository.appendMessage(threadId, 'user', content);

// Build the prompt history from the persisted thread (gives us the
// full conversation including the user message we just appended).
const fetched = await assistantRepository.getThread(userId, threadId);
// Defensive — we just wrote the thread, but if a concurrent DELETE
// landed between INSERT and SELECT we'd have a stale view. Treat as
// 404 so the client retries with a fresh thread.
if (!fetched) {
res.status(404).json({ error: 'Thread vanished mid-request' });
return;
}
const history: ChatTurn[] = fetched.messages.map((m) => ({
role: m.role,
content: m.content,
}));

const service = new AssistantService(llm);
let reply;
try {
reply = await service.reply(history);
} catch (err) {
if (err instanceof AllProvidersFailedError) {
log.warn('All LLM providers failed for assistant request', {
userId,
threadId,
attempted: err.attempted,
});
res.status(502).json({
error: 'All configured AI providers failed',
message:
'Every configured provider returned an error. Try again in a moment, or check Settings → AI providers.',
attempted: err.attempted,
});
return;
}
throw err;
}

const assistantMessage = await assistantRepository.appendMessage(
threadId,
'assistant',
reply.content,
reply.metadata,
);

res.json({
thread: { id: threadId, isNew: isNewThread },
userMessage,
assistantMessage,
});
} catch (err) {
next(err);
}
});
Comment on lines +307 to +333
const previous = _state.threads.slice();
_state.threads = _state.threads.filter((t) => t.id !== threadId);
let switched = false;
if (_state.activeThreadId === threadId) {
_state.activeThreadId = _state.threads[0]?.id ?? null;
_state.messages = [];
switched = true;
}
const container = document.getElementById('page-content');
if (container) paint(container);

try {
await deleteAssistantThread(threadId, _state.userId);
// If the active thread switched to a different one, fetch its messages.
if (switched && _state.activeThreadId && container) {
try {
const data = await fetchAssistantThread(_state.activeThreadId, _state.userId);
_state.messages = Array.isArray(data?.messages) ? data.messages : [];
paint(container);
} catch {
/* swallow — leave state as is */
}
}
} catch (err) {
// Rollback on failure so the UI doesn't lie about the server state.
_state.threads = previous;
if (container) paint(container);
Comment on lines +388 to +400
// Restore the input so the user can retry, drop the optimistic bubble,
// and surface the error in an assistant-shaped bubble. Phase 2 should
// make this a proper toast.
_state.messages = _state.messages.filter((m) => m.id !== 'optimistic');
_state.messages = _state.messages.concat([
{
id: 'error',
role: 'assistant',
content: `Couldn't reach the assistant — ${err instanceof Error ? err.message : String(err)}`,
createdAt: new Date().toISOString(),
},
]);
if (input) input.value = content;
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.

2 participants