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:
1 parent
789b453e92
commit
281e7f9502
24 files changed
+1972
-465
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user