fix: skills/MCP/completion system overhaul — separate skill kinds, fix MCP remote servers, deduplicate completion processing

Skills system:
- Replace `CommandBadge.isSkill: boolean` with `kind: SkillKind` (agent_skill | slash_command | sdk_command | codepilot_command)
- handleSubmit dispatches by kind: agent_skill retains skill name in prompt, slash/sdk commands pass through to SDK, codepilot commands expand via COMMAND_PROMPTS
- Popover groups commands into Commands / Slash Commands / Agent Skills
- API `/api/skills` returns `kind` field; `displayOverride` persisted through full chain (frontend → API → DB)
- Forward SDK `system:init` slash_commands/skills metadata to frontend via SSE

MCP management:
- Fix remote server (SSE/HTTP) creation: validate `url` instead of `command`
- Fix dual-config read/write asymmetry: PUT routes servers by `_source` to correct file (~/.claude.json vs ~/.claude/settings.json)
- DELETE checks both config files; POST checks both for name collision
- McpManager preserves `_source` tags on edit/rename; JSON editor only manages settings.json servers

Completion processing:
- Extract core logic into `onboarding-processor.ts` and `checkin-processor.ts` (shared by route handlers and server-side detection)
- Remove frontend `detectAssistantCompletion` from ChatView — server-side path in `finally` block is now the single source of truth
- Replace hard-coded `fetch("http://localhost:PORT/...")` with direct function imports
- Both processors are internally idempotent (check state before processing)

Other:
- `settingSources` always includes 'user' so SDK loads ~/.claude/skills/
- Add 24 unit tests for SkillKind dispatch logic
- Add onboarding-completion extraction tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-10 12:49:43 +08:00
1 parent 789b453e92
commit 281e7f9502
24 files changed
+1972 -465

No files matched your search

+81 -5
View File
@@ -3,6 +3,7 @@ import { streamClaude } from '@/lib/claude-client';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver';
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
import { extractCompletion } from '@/lib/onboarding-completion';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
import crypto from 'crypto';
import fs from 'fs';
@@ -37,8 +38,8 @@ export async function POST(request: NextRequest) {
let activeLockId: string | undefined;
try {
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json();
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body;
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean; displayOverride?: string } = await request.json();
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing, displayOverride } = body;
console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200));
console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none');
@@ -81,7 +82,8 @@ export async function POST(request: NextRequest) {
// Save user message — persist file metadata so attachments survive page reload
// Skip saving for autoTrigger messages (invisible system triggers for assistant hooks)
let savedContent = content;
// Use displayOverride for DB storage if provided (e.g. /skillName instead of expanded prompt)
let savedContent = displayOverride || content;
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
if (!autoTrigger) {
if (files && files.length > 0) {
@@ -97,7 +99,7 @@ export async function POST(request: NextRequest) {
fs.writeFileSync(filePath, buffer);
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
});
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayOverride || content}`;
}
addMessage(session_id, 'user', savedContent);
@@ -240,12 +242,21 @@ Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before
12. Where should new information go by default?
13. How should completed tasks be archived?
After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers:
After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13.
CRITICAL FORMATTING RULES for the completion block:
- Each value must be a single line (replace any newlines with spaces)
- Escape all double quotes inside values with backslash: \\"
- Do NOT use single quotes for JSON keys or values
- Do NOT add trailing commas
- The JSON must be on a SINGLE line
\`\`\`onboarding-complete
{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"}
\`\`\`
After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly.
Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers.
Start by greeting the user and asking the first question.
@@ -555,6 +566,27 @@ async function collectStreamResponse(
}
}
} finally {
// ── Server-side completion detection (reliable path) ──
// After persisting the assistant message, check for onboarding/checkin
// fences and process them directly on the server. This ensures completion
// is captured even if the frontend misses it (page refresh, parse failure, etc.).
try {
const fullText = contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('');
const completion = extractCompletion(fullText);
if (completion) {
const workspacePath = getSetting('assistant_workspace_path');
const session = getSession(sessionId);
if (workspacePath && session && session.working_directory === workspacePath) {
await processCompletionServerSide(completion, workspacePath, sessionId);
}
}
} catch (e) {
console.error('[chat API] Server-side completion detection failed:', e);
}
// Telegram notifications: completion or error (fire-and-forget)
if (hasError) {
notifySessionError(errorMessage, telegramOpts).catch(() => {});
@@ -570,3 +602,47 @@ async function collectStreamResponse(
onComplete?.();
}
}
/**
* Process a detected onboarding/checkin completion on the server side.
* Calls the shared processor functions directly — no HTTP round-trip needed.
*
* Both processors are internally idempotent:
* - processOnboarding checks state.onboardingComplete
* - processCheckin checks state.lastCheckInDate === today
*/
async function processCompletionServerSide(
completion: import('@/lib/onboarding-completion').ExtractedCompletion,
_workspacePath: string,
sessionId: string,
): Promise<void> {
try {
if (completion.type === 'onboarding') {
const { processOnboarding } = await import('@/lib/onboarding-processor');
console.log('[chat API] Server-side onboarding completion detected');
await processOnboarding(completion.answers, sessionId);
console.log('[chat API] Server-side onboarding completion succeeded');
} else if (completion.type === 'checkin') {
const { processCheckin } = await import('@/lib/checkin-processor');
console.log('[chat API] Server-side checkin completion detected');
await processCheckin(completion.answers, sessionId);
console.log('[chat API] Server-side checkin completion succeeded');
}
// Clear hookTriggeredSessionId directly (no HTTP needed)
try {
const { loadState, saveState } = await import('@/lib/assistant-workspace');
const { getSetting: getSettingDirect } = await import('@/lib/db');
const wsPath = getSettingDirect('assistant_workspace_path');
if (wsPath) {
const state = loadState(wsPath);
state.hookTriggeredSessionId = undefined;
saveState(wsPath, state);
}
} catch {
// Best effort
}
} catch (e) {
console.error(`[chat API] Server-side ${completion.type} processing failed:`, e);
}
}
+33 -15
View File
@@ -8,23 +8,26 @@ function getSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
function readSettings(): Record<string, unknown> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return {};
// ~/.claude.json — Claude CLI stores user-scoped MCP servers here
function getUserConfigPath(): string {
return path.join(os.homedir(), '.claude.json');
}
function readJsonFile(filePath: string): Record<string, unknown> {
if (!fs.existsSync(filePath)) return {};
try {
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return {};
}
}
function writeSettings(settings: Record<string, unknown>): void {
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
function writeJsonFile(filePath: string, data: Record<string, unknown>): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
export async function DELETE(
@@ -35,20 +38,35 @@ export async function DELETE(
const { name } = await params;
const serverName = decodeURIComponent(name);
const settings = readSettings();
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
let deleted = false;
if (!mcpServers[serverName]) {
// Try deleting from ~/.claude/settings.json
const settings = readJsonFile(getSettingsPath());
const settingsServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
if (settingsServers[serverName]) {
delete settingsServers[serverName];
settings.mcpServers = settingsServers;
writeJsonFile(getSettingsPath(), settings);
deleted = true;
}
// Also try deleting from ~/.claude.json
const userConfig = readJsonFile(getUserConfigPath());
const userServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
if (userServers[serverName]) {
delete userServers[serverName];
userConfig.mcpServers = userServers;
writeJsonFile(getUserConfigPath(), userConfig);
deleted = true;
}
if (!deleted) {
return NextResponse.json(
{ error: `MCP server "${serverName}" not found` },
{ status: 404 }
);
}
delete mcpServers[serverName];
settings.mcpServers = mcpServers;
writeSettings(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
+50 -10
View File
@@ -44,11 +44,18 @@ export async function GET(): Promise<NextResponse<MCPConfigResponse | ErrorRespo
try {
const settings = readSettings();
const userConfig = readJsonFile(getUserConfigPath());
const settingsServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
const userConfigServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
// Merge: ~/.claude/settings.json takes precedence over ~/.claude.json
const mcpServers = {
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
};
// Tag each server with _source so UI knows where it came from
const mcpServers: Record<string, MCPServerConfig & { _source?: string }> = {};
for (const [name, server] of Object.entries(userConfigServers)) {
mcpServers[name] = { ...server, _source: 'claude.json' };
}
for (const [name, server] of Object.entries(settingsServers)) {
mcpServers[name] = { ...server, _source: 'settings.json' };
}
return NextResponse.json({ mcpServers });
} catch (error) {
return NextResponse.json(
@@ -63,12 +70,38 @@ export async function PUT(
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
try {
const body = await request.json();
const { mcpServers } = body as { mcpServers: Record<string, MCPServerConfig> };
const incoming = body.mcpServers as Record<string, MCPServerConfig & { _source?: string }>;
// Split incoming servers by source and write to the correct file.
// Servers without _source or with _source='settings.json' → settings.json
// Servers with _source='claude.json' → ~/.claude.json
const forSettings: Record<string, MCPServerConfig> = {};
const forUserConfig: Record<string, MCPServerConfig> = {};
for (const [name, server] of Object.entries(incoming)) {
const { _source, ...cleanServer } = server;
if (_source === 'claude.json') {
forUserConfig[name] = cleanServer;
} else {
forSettings[name] = cleanServer;
}
}
// Write settings.json
const settings = readSettings();
settings.mcpServers = mcpServers;
settings.mcpServers = forSettings;
writeSettings(settings);
// Write ~/.claude.json (only the mcpServers key, preserve other fields)
const userConfig = readJsonFile(getUserConfigPath());
userConfig.mcpServers = forUserConfig;
const userConfigPath = getUserConfigPath();
const dir = path.dirname(userConfigPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8');
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
@@ -85,26 +118,33 @@ export async function POST(
const body = await request.json();
const { name, server } = body as { name: string; server: MCPServerConfig };
if (!name || !server || !server.command) {
// stdio servers require command; sse/http servers require url
const isRemote = server?.type === 'sse' || server?.type === 'http';
if (!name || !server || (!isRemote && !server.command) || (isRemote && !server.url)) {
return NextResponse.json(
{ error: 'Name and server command are required' },
{ error: isRemote ? 'Name and server URL are required' : 'Name and server command are required' },
{ status: 400 }
);
}
// Check both config files for name collision (merged namespace)
const settings = readSettings();
const userConfig = readJsonFile(getUserConfigPath());
if (!settings.mcpServers) {
settings.mcpServers = {};
}
const mcpServers = settings.mcpServers as Record<string, MCPServerConfig>;
if (mcpServers[name]) {
const settingsServers = settings.mcpServers as Record<string, MCPServerConfig>;
const userConfigServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
if (settingsServers[name] || userConfigServers[name]) {
return NextResponse.json(
{ error: `MCP server "${name}" already exists` },
{ status: 409 }
);
}
const mcpServers = settingsServers;
mcpServers[name] = server;
writeSettings(settings);
+7
View File
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import type { SkillKind } from "@/types";
function getGlobalCommandsDir(): string {
return path.join(os.homedir(), ".claude", "commands");
@@ -281,6 +282,8 @@ export async function GET(
: firstLine || `Skill: /${name}`;
}
const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command";
return NextResponse.json({
skill: {
name,
@@ -289,6 +292,7 @@ export async function GET(
source: found.source,
installedSource: found.installedSource,
filePath: found.filePath,
kind,
},
});
} catch (error) {
@@ -342,6 +346,8 @@ export async function PUT(
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${name}`;
const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command";
return NextResponse.json({
skill: {
name,
@@ -350,6 +356,7 @@ export async function PUT(
source: found.source,
installedSource: found.installedSource,
filePath: found.filePath,
kind,
},
});
} catch (error) {
+8 -3
View File
@@ -3,12 +3,14 @@ import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import type { SkillKind } from "@/types";
interface SkillFile {
name: string;
description: string;
content: string;
source: "global" | "project" | "plugin" | "installed";
source: "global" | "project" | "plugin" | "installed" | "sdk";
kind: SkillKind;
installedSource?: "agents" | "claude";
filePath: string;
}
@@ -86,6 +88,7 @@ function scanProjectSkills(dir: string): SkillFile[] {
description,
content,
source: "project",
kind: "agent_skill",
filePath: skillMdPath,
});
}
@@ -177,6 +180,7 @@ function scanInstalledSkills(
description,
content,
source: "installed",
kind: "agent_skill",
installedSource,
contentHash,
filePath: skillMdPath,
@@ -256,7 +260,7 @@ function scanDirectory(
const description = firstLine.startsWith("#")
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${name}`;
skills.push({ name, description, content, source, filePath });
skills.push({ name, description, content, source, kind: "slash_command", filePath });
}
} catch {
// ignore read errors
@@ -332,7 +336,8 @@ export async function GET(request: NextRequest) {
name: cmd.name,
description: cmd.description || `SDK command: /${cmd.name}`,
content: '', // SDK commands don't have local content
source: 'sdk' as typeof all[number]['source'],
source: 'sdk',
kind: 'sdk_command',
filePath: '',
});
}
+7 -146
View File
@@ -1,11 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getSession } from '@/lib/db';
import { resolveProvider } from '@/lib/provider-resolver';
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
import { processCheckin } from '@/lib/checkin-processor';
const CHECK_IN_QUESTIONS = [
'assistant.checkInQ1',
@@ -31,11 +25,6 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const workspacePath = getSetting('assistant_workspace_path');
if (!workspacePath) {
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
}
const body = await request.json();
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
@@ -43,142 +32,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
// Look up the calling session for workspace validation AND provider/model context
let session: ReturnType<typeof getSession> | undefined;
if (sessionId) {
session = getSession(sessionId) ?? undefined;
if (session && session.working_directory !== workspacePath) {
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
}
}
const qaText = CHECK_IN_LABELS.map((q, i) => {
const key = `q${i + 1}`;
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
}).join('\n\n');
const today = getLocalDateString();
// Read existing files for context
const memoryPath = path.join(workspacePath, 'memory.md');
const userPath = path.join(workspacePath, 'user.md');
let existingMemory = '';
let existingUser = '';
try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ }
try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ }
try {
// Resolve using session's provider/model so check-in uses the same provider as chat
const resolved = resolveProvider({
sessionProviderId: session?.provider_id || undefined,
sessionModel: session?.model || undefined,
});
// Preserve 'env' semantics (see onboarding route for rationale)
const providerId = resolved.provider?.id || 'env';
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
// Generate daily memory entry (episodic, not destructive)
const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}.
Format it with these sections:
## Work Log
(What the user accomplished today)
## Priority Changes
(Any shifts in goals or priorities)
## To Remember
(Things the user wants remembered)
## Candidate Long-Term Memory
(Facts that seem stable enough to promote to long-term memory — only include genuinely persistent facts, not transient updates)
Keep under 2000 characters.
Today's check-in (${today}):
${qaText}`;
// Generate promotion candidates: only stable facts to append to memory.md
const promotionPrompt = `You maintain a long-term memory file for an AI assistant. Given the user's check-in and the existing memory, output ONLY new stable facts that should be APPENDED to memory.md. These must be genuinely persistent facts (user preferences, recurring patterns, important relationships), NOT daily transients.
If there's nothing worth promoting, output exactly: (nothing to promote)
Existing memory.md:
${existingMemory || '(empty)'}
Today's check-in (${today}):
${qaText}`;
// Generate incremental user.md updates
const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters.
Existing user.md:
${existingUser || '(empty)'}
Today's check-in (${today}):
${qaText}`;
const [dailyContent, promotionContent, newUser] = await Promise.all([
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: dailyMemoryPrompt }),
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the content to append, no explanations.', prompt: promotionPrompt }),
generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
]);
// Write daily memory file (episodic, per-day)
if (dailyContent.trim()) {
writeDailyMemory(workspacePath, today, dailyContent);
}
// Promote stable facts to memory.md (additive append, NOT rewrite)
// Dedup: skip if today's promotion already exists in memory.md
if (promotionContent.trim() && !promotionContent.includes('(nothing to promote)')) {
const currentMemory = fs.existsSync(memoryPath) ? fs.readFileSync(memoryPath, 'utf-8') : '';
if (!currentMemory.includes(`## Promoted from ${today}`)) {
const appendText = `\n\n## Promoted from ${today}\n${promotionContent.trim()}\n`;
fs.appendFileSync(memoryPath, appendText, 'utf-8');
}
}
// Incremental user.md update
if (existingUser && newUser.trim()) {
fs.writeFileSync(userPath, newUser, 'utf-8');
}
// Archive old daily memories and promote candidates
try {
const { archiveDailyMemories, promoteDailyToLongTerm } = await import('@/lib/workspace-organizer');
archiveDailyMemories(workspacePath);
// Promote memories that are 7+ days old
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const dailyDir = path.join(workspacePath, 'memory', 'daily');
if (fs.existsSync(dailyDir)) {
const oldFiles = fs.readdirSync(dailyDir)
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
.filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo));
for (const f of oldFiles) {
promoteDailyToLongTerm(workspacePath, f.replace('.md', ''));
}
}
} catch {
// organizer module not available, skip archival
}
} catch (e) {
console.warn('[workspace/checkin] AI generation failed, writing raw daily entry:', e);
// Fallback: write raw answers as daily memory
const rawDailyContent = `# Daily Check-in ${today}\n\n${qaText}\n`;
writeDailyMemory(workspacePath, today, rawDailyContent);
}
// Update state
const state = loadState(workspacePath);
state.lastCheckInDate = today;
saveState(workspacePath, state);
await processCheckin(answers, sessionId);
return NextResponse.json({ success: true });
} catch (e) {
console.error('[workspace/checkin] POST failed:', e);
return NextResponse.json({ error: 'Check-in failed' }, { status: 500 });
const message = e instanceof Error ? e.message : 'Check-in failed';
const status = message.includes('No workspace path') ? 400
: message.includes('does not belong') ? 403
: 500;
return NextResponse.json({ error: message }, { status });
}
}
+7 -146
View File
@@ -1,11 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getSession } from '@/lib/db';
import { resolveProvider } from '@/lib/provider-resolver';
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
import { processOnboarding } from '@/lib/onboarding-processor';
const QUESTIONS = [
'assistant.onboardingQ1',
@@ -51,11 +45,6 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const workspacePath = getSetting('assistant_workspace_path');
if (!workspacePath) {
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
}
const body = await request.json();
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
@@ -63,142 +52,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
// Look up the calling session for workspace validation AND provider/model context
let session: ReturnType<typeof getSession> | undefined;
if (sessionId) {
session = getSession(sessionId) ?? undefined;
if (session && session.working_directory !== workspacePath) {
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
}
}
// Build Q&A text for the prompt
const qaText = QUESTION_LABELS.map((q, i) => {
const key = `q${i + 1}`;
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
}).join('\n\n');
let soulContent: string;
let userContent: string;
let claudeContent: string;
let memoryContent: string;
try {
// Resolve using session's provider/model so onboarding uses the same provider as chat
const resolved = resolveProvider({
sessionProviderId: session?.provider_id || undefined,
sessionModel: session?.model || undefined,
});
// Preserve 'env' semantics: when provider=undefined, pass 'env' so text-generator
// uses shell env credentials instead of falling back to a random DB provider
const providerId = resolved.provider?.id || 'env';
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
const claudePrompt = `Based on the following user onboarding answers, generate a "claude.md" rules file for an AI assistant. Include:
- Execution rules (what to do when entering a project, based on Q10)
- Communication style rules (based on Q3, Q4, Q7)
- Memory rules (what to remember/forget, based on Q8, Q9)
- Hard boundaries (based on Q5)
Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
const memoryPrompt = `Based on the following user onboarding answers, generate an initial "memory.md" file with long-term facts about the user worth remembering. Include user goals, preferences, and any stable facts. Keep it under 1000 characters. Use markdown headers.\n\n${qaText}`;
[soulContent, userContent, claudeContent, memoryContent] = await Promise.all([
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }),
generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: claudePrompt }),
generateTextFromProvider({ providerId, model, system: 'You generate knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }),
]);
// Fallback if AI returned empty
if (!soulContent.trim() || !userContent.trim()) {
throw new Error('AI returned empty content');
}
} catch (e) {
console.warn('[workspace/onboarding] AI generation failed, using raw answers:', e);
// Fallback: write raw answers
soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`;
userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n- Organization: ${answers.q11 || 'not specified'}\n- Default capture: ${answers.q12 || 'not specified'}\n- Archive policy: ${answers.q13 || 'not specified'}\n`;
claudeContent = `# Rules\n\n## Execution\n- On project entry: ${answers.q10 || 'not specified'}\n\n## Boundaries\n- ${answers.q5 || 'not specified'}\n\n## Memory\n- Allowed: ${answers.q8 || 'not specified'}\n- Forbidden: ${answers.q9 || 'not specified'}\n`;
memoryContent = `# Memory\n\n## User Goals\n- ${answers.q6 || 'not specified'}\n`;
}
// Write all core files
fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8');
fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8');
if (claudeContent.trim()) {
fs.writeFileSync(path.join(workspacePath, 'claude.md'), claudeContent, 'utf-8');
}
if (memoryContent.trim()) {
fs.writeFileSync(path.join(workspacePath, 'memory.md'), memoryContent, 'utf-8');
}
// Ensure V2 directories
ensureDailyDir(workspacePath);
const inboxDir = path.join(workspacePath, 'Inbox');
if (!fs.existsSync(inboxDir)) {
fs.mkdirSync(inboxDir, { recursive: true });
}
// Generate config.json from answers
try {
const { loadConfig, saveConfig } = await import('@/lib/workspace-config');
const config = loadConfig(workspacePath);
// Apply onboarding answers to config
const orgStyle = (answers.q11 || '').toLowerCase();
if (orgStyle.includes('project')) config.organizationStyle = 'project';
else if (orgStyle.includes('time')) config.organizationStyle = 'time';
else if (orgStyle.includes('topic')) config.organizationStyle = 'topic';
else config.organizationStyle = 'mixed';
if (answers.q12) {
// Sanitize captureDefault: only allow relative paths within workspace
let capture = answers.q12.trim();
if (path.isAbsolute(capture) || capture.startsWith('~') || capture.includes('..')) {
capture = 'Inbox'; // fallback to safe default
}
config.captureDefault = capture;
}
saveConfig(workspacePath, config);
} catch {
// config module not available, skip
}
// Generate taxonomy from existing directories
try {
const { loadTaxonomy, saveTaxonomy, inferTaxonomyFromDirs } = await import('@/lib/workspace-taxonomy');
const taxonomy = loadTaxonomy(workspacePath);
if (taxonomy.categories.length === 0) {
const inferred = inferTaxonomyFromDirs(workspacePath);
if (inferred.length > 0) {
taxonomy.categories = inferred;
saveTaxonomy(workspacePath, taxonomy);
}
}
} catch {
// taxonomy module not available, skip
}
// Generate root docs
generateRootDocs(workspacePath);
// Update state
const today = getLocalDateString();
const state = loadState(workspacePath);
state.onboardingComplete = true;
state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding
state.schemaVersion = 3;
saveState(workspacePath, state);
await processOnboarding(answers, sessionId);
return NextResponse.json({ success: true });
} catch (e) {
console.error('[workspace/onboarding] POST failed:', e);
return NextResponse.json({ error: 'Onboarding failed' }, { status: 500 });
const message = e instanceof Error ? e.message : 'Onboarding failed';
const status = message.includes('No workspace path') ? 400
: message.includes('does not belong') ? 403
: 500;
return NextResponse.json({ error: message }, { status });
}
}