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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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