feat: assistant workspace — persistent AI personality & memory via Markdown files (#165)

Add "Assistant Workspace" feature that lets users designate a directory
(e.g. Obsidian vault) containing 4 Markdown files (soul.md, user.md,
memory.md, claude.md) to define AI personality, user profile, long-term
memory, and execution rules. These files are automatically injected into
the system prompt for assistant project sessions.

Core library (src/lib/assistant-workspace.ts):
- Workspace validation, initialization with template files
- File loading with 8000-char truncation, XML prompt assembly (40k limit)
- State management via .assistant/state.json
- Directory docs generation (README.ai.md + PATH.ai.md)
- Case-insensitive file name fallback (soul.md → SOUL.md)

System prompt injection (src/app/api/chat/route.ts):
- Scoped to sessions where working_directory === workspace path
- Workspace prompt prepended before session-level system prompt
- Onboarding/check-in instructions injected as <assistant-project-task>
- autoTrigger flag skips user message save and title update

Chat-based onboarding (10 questions) & daily check-in (3 questions):
- AI conducts conversation, outputs structured completion code blocks
- Frontend detects onboarding-complete/checkin-complete blocks
- Backend calls Claude API to generate file content from answers
- Fallback to raw answers if API call fails

Auto-trigger pipeline:
- Mount trigger (500ms delay) + focus fallback in MessageInput
- assistantTriggerFiredRef prevents duplicate triggers per mount
- hookTriggeredSessionId in state.json prevents cross-reload duplicates
- Daily check-in only triggers in the most recent workspace session
- hookTriggeredSessionId cleared via __clear__ sentinel after completion

API endpoints:
- GET/PUT /api/settings/workspace — path CRUD + file status
- POST /api/workspace/session — create (onboarding) or reuse (check-in)
- POST /api/workspace/onboarding — AI-generate soul.md + user.md
- POST /api/workspace/checkin — AI-update memory.md + user.md
- POST /api/workspace/hook-triggered — set/clear trigger guard
- GET /api/workspace/latest-session — latest session lookup
- POST /api/workspace/docs — refresh directory documentation

UI:
- Settings "Assistant" tab with workspace path, file status, controls
- Violet badge on workspace project group in ChatListPanel
- useAssistantWorkspace hook for state queries

Also includes:
- 102 passing tests (16 new regression tests for workspace logic)
- i18n keys for en.ts and zh.ts
- Handover documentation and execution plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-06 20:31:42 +08:00
1 parent 3f19290c8d
commit 19aba25c8f
24 files changed
+1812 -24

No files matched your search

+106 -21
View File
@@ -15,8 +15,8 @@ export async function POST(request: NextRequest) {
let activeLockId: string | undefined;
try {
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string } = await request.json();
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend } = body;
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json();
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = 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');
@@ -58,29 +58,32 @@ export async function POST(request: NextRequest) {
notifySessionStart(telegramNotifyOpts).catch(() => {});
// 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;
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
if (files && files.length > 0) {
const workDir = session.working_directory;
const uploadDir = path.join(workDir, '.codepilot-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
if (!autoTrigger) {
if (files && files.length > 0) {
const workDir = session.working_directory;
const uploadDir = path.join(workDir, '.codepilot-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
fileMeta = files.map((f) => {
const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`);
const buffer = Buffer.from(f.data, 'base64');
fs.writeFileSync(filePath, buffer);
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
});
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
fileMeta = files.map((f) => {
const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`);
const buffer = Buffer.from(f.data, 'base64');
fs.writeFileSync(filePath, buffer);
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
});
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
addMessage(session_id, 'user', savedContent);
addMessage(session_id, 'user', savedContent);
// Auto-generate title from first message if still default
if (session.title === 'New Chat') {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
updateSessionTitle(session_id, title);
// Auto-generate title from first message if still default
if (session.title === 'New Chat') {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
updateSessionTitle(session_id, title);
}
}
// Determine model: request override > session model > default setting
@@ -164,12 +167,94 @@ export async function POST(request: NextRequest) {
})
: undefined;
// Load assistant workspace prompt if configured
let workspacePrompt = '';
let assistantProjectInstructions = '';
try {
const workspacePath = getSetting('assistant_workspace_path');
if (workspacePath) {
const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace');
// Only inject workspace files for assistant project sessions
const sessionWd = session.working_directory || '';
const isAssistantProject = sessionWd === workspacePath;
if (isAssistantProject) {
const files = loadWorkspaceFiles(workspacePath);
workspacePrompt = assembleWorkspacePrompt(files);
const state = loadState(workspacePath);
if (!state.onboardingComplete) {
// First-time onboarding: instruct AI to ask onboarding questions
assistantProjectInstructions = `<assistant-project-task type="onboarding">
You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile.
Ask the following 10 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly.
1. How should I address you?
2. What name should I use for myself?
3. Do you prefer "concise and direct" or "detailed explanations"?
4. Do you prefer "minimal interruptions" or "proactive suggestions"?
5. What are your three hard boundaries?
6. What are your three most important current goals?
7. Do you prefer output as "lists", "reports", or "conversation summaries"?
8. What information may be written to long-term memory?
9. What information must never be written to long-term memory?
10. What three things should I do first when entering a project?
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:
\`\`\`onboarding-complete
{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10"}
\`\`\`
Do NOT try to write files yourself. The system will automatically generate soul.md and user.md from your collected answers.
Start by greeting the user and asking the first question.
</assistant-project-task>`;
} else if (needsDailyCheckIn(state)) {
// Daily check-in: instruct AI to ask 3 quick questions
assistantProjectInstructions = `<assistant-project-task type="daily-checkin">
You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME:
1. What did you work on or accomplish today?
2. Any changes to your current priorities or goals?
3. Anything you'd like me to remember going forward?
After collecting all 3 answers, output a summary in exactly this format:
\`\`\`checkin-complete
{"q1":"answer1","q2":"answer2","q3":"answer3"}
\`\`\`
Do NOT try to write files yourself. The system will automatically update memory.md and user.md from your collected answers.
Start by greeting the user and asking the first question.
</assistant-project-task>`;
}
}
}
} catch (e) {
console.warn('[chat API] Failed to load assistant workspace:', e);
}
// Append per-request system prompt (e.g. skill injection for image generation)
let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined;
if (systemPromptAppend) {
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend;
}
// Workspace prompt goes first (base personality), session prompt after (task override)
if (workspacePrompt) {
finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || '');
}
// Assistant project instructions go after workspace prompt
if (assistantProjectInstructions) {
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions;
}
// Load recent conversation history from DB as fallback context.
// This is used when SDK session resume is unavailable or fails,
// so the model still has conversation context.
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import { getSetting, setSetting } from '@/lib/db';
import { validateWorkspace, initializeWorkspace, loadState } from '@/lib/assistant-workspace';
export async function GET() {
try {
const workspacePath = getSetting('assistant_workspace_path');
if (!workspacePath) {
return NextResponse.json({ path: null, files: {}, state: null });
}
const validation = validateWorkspace(workspacePath);
const state = loadState(workspacePath);
// Build file status with preview
const fileStatus: Record<string, { exists: boolean; chars: number; preview: string }> = {};
for (const [key, info] of Object.entries(validation.files)) {
let preview = '';
if (info.exists && info.path) {
try {
const content = fs.readFileSync(info.path, 'utf-8');
preview = content.split('\n').slice(0, 3).join('\n');
} catch { /* ignore */ }
}
fileStatus[key] = {
exists: info.exists,
chars: info.size,
preview,
};
}
return NextResponse.json({
path: workspacePath,
exists: validation.exists,
files: fileStatus,
state,
});
} catch (e) {
console.error('[settings/workspace] GET failed:', e);
return NextResponse.json({ error: 'Failed to load workspace info' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { path: workspacePath, initialize } = body as { path: string; initialize?: boolean };
if (!workspacePath || typeof workspacePath !== 'string') {
return NextResponse.json({ error: 'Invalid workspace path' }, { status: 400 });
}
setSetting('assistant_workspace_path', workspacePath);
let createdFiles: string[] = [];
if (initialize) {
createdFiles = initializeWorkspace(workspacePath);
}
return NextResponse.json({ success: true, createdFiles });
} catch (e) {
console.error('[settings/workspace] PUT failed:', e);
return NextResponse.json({ error: 'Failed to save workspace settings' }, { status: 500 });
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId } from '@/lib/db';
import { loadState, saveState } from '@/lib/assistant-workspace';
import { generateTextFromProvider } from '@/lib/text-generator';
const CHECK_IN_QUESTIONS = [
'assistant.checkInQ1',
'assistant.checkInQ2',
'assistant.checkInQ3',
];
const CHECK_IN_LABELS = [
'What did you work on or accomplish today?',
'Any changes to your current priorities or goals?',
'Anything you\'d like me to remember going forward?',
];
export async function GET() {
return NextResponse.json({
questions: CHECK_IN_QUESTIONS.map((key, i) => ({
key,
label: CHECK_IN_LABELS[i],
index: i + 1,
})),
});
}
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 } = body as { answers: Record<string, string> };
if (!answers || typeof answers !== 'object') {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
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 = new Date().toISOString().slice(0, 10);
// 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 {
const providerId = getDefaultProviderId() || '';
const model = getSetting('default_model') || 'claude-sonnet-4-20250514';
const memoryPrompt = `You maintain a memory.md file for an AI assistant. Given the user's daily check-in answers and the existing memory file, generate an UPDATED memory.md. Add new facts, update changed information, remove outdated items. Keep it organized with markdown headers. Keep under 4000 characters.\n\nExisting memory.md:\n${existingMemory || '(empty)'}\n\nToday's check-in (${today}):\n${qaText}`;
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.\n\nExisting user.md:\n${existingUser || '(empty)'}\n\nToday's check-in (${today}):\n${qaText}`;
const [newMemory, newUser] = await Promise.all([
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }),
generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
]);
if (!newMemory.trim()) {
throw new Error('AI returned empty memory content');
}
fs.writeFileSync(memoryPath, newMemory, 'utf-8');
if (existingUser && newUser.trim()) {
fs.writeFileSync(userPath, newUser, 'utf-8');
}
} catch (e) {
console.warn('[workspace/checkin] AI generation failed, appending raw answers:', e);
// Fallback: append raw answers to memory.md
const appendText = `\n\n## Check-in ${today}\n${qaText}\n`;
fs.appendFileSync(memoryPath, appendText, 'utf-8');
}
// Update state
const state = loadState(workspacePath);
state.lastCheckInDate = today;
saveState(workspacePath, state);
return NextResponse.json({ success: true });
} catch (e) {
console.error('[workspace/checkin] POST failed:', e);
return NextResponse.json({ error: 'Check-in failed' }, { status: 500 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getSetting } from '@/lib/db';
import { generateDirectoryDocs } from '@/lib/assistant-workspace';
export async function POST() {
try {
const workspacePath = getSetting('assistant_workspace_path');
if (!workspacePath) {
return NextResponse.json(
{ error: 'No assistant workspace path configured' },
{ status: 400 },
);
}
const files = generateDirectoryDocs(workspacePath);
return NextResponse.json({ files });
} catch (e) {
console.error('[workspace/docs] Failed to generate directory docs:', e);
return NextResponse.json(
{ error: 'Failed to generate directory docs' },
{ status: 500 },
);
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting } from '@/lib/db';
import { loadState, saveState } from '@/lib/assistant-workspace';
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 { sessionId } = await request.json();
const state = loadState(workspacePath);
// '__clear__' sentinel clears the field (used after onboarding/checkin completes)
state.hookTriggeredSessionId = sessionId === '__clear__' ? undefined : sessionId;
saveState(workspacePath, state);
return NextResponse.json({ success: true });
} catch (e) {
console.error('[workspace/hook-triggered] POST failed:', e);
return NextResponse.json({ error: 'Failed' }, { status: 500 });
}
}
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLatestSessionByWorkingDirectory } from '@/lib/db';
export async function GET(request: NextRequest) {
const workingDirectory = request.nextUrl.searchParams.get('workingDirectory');
if (!workingDirectory) {
return NextResponse.json({ error: 'Missing workingDirectory' }, { status: 400 });
}
const session = getLatestSessionByWorkingDirectory(workingDirectory);
return NextResponse.json({ sessionId: session?.id ?? null });
}
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId } from '@/lib/db';
import { loadState, saveState } from '@/lib/assistant-workspace';
import { generateTextFromProvider } from '@/lib/text-generator';
const QUESTIONS = [
'assistant.onboardingQ1',
'assistant.onboardingQ2',
'assistant.onboardingQ3',
'assistant.onboardingQ4',
'assistant.onboardingQ5',
'assistant.onboardingQ6',
'assistant.onboardingQ7',
'assistant.onboardingQ8',
'assistant.onboardingQ9',
'assistant.onboardingQ10',
];
const QUESTION_LABELS = [
'How should I address you?',
'What name should I use for myself?',
'Do you prefer "concise and direct" or "detailed explanations"?',
'Do you prefer "minimal interruptions" or "proactive suggestions"?',
'What are your three hard boundaries?',
'What are your three most important current goals?',
'Do you prefer output as "lists", "reports", or "conversation summaries"?',
'What information may be written to long-term memory?',
'What information must never be written to long-term memory?',
'What three things should I do first when entering a project?',
];
export async function GET() {
return NextResponse.json({
questions: QUESTIONS.map((key, i) => ({
key,
label: QUESTION_LABELS[i],
index: i + 1,
})),
});
}
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 } = body as { answers: Record<string, string> };
if (!answers || typeof answers !== 'object') {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
// 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;
try {
const providerId = getDefaultProviderId() || '';
const 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}`;
[soulContent, userContent] = 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 }),
]);
// 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`;
}
// Write files
fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8');
fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8');
// Update state
const today = new Date().toISOString().slice(0, 10);
const state = loadState(workspacePath);
state.onboardingComplete = true;
state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding
saveState(workspacePath, state);
return NextResponse.json({ success: true });
} catch (e) {
console.error('[workspace/onboarding] POST failed:', e);
return NextResponse.json({ error: 'Onboarding failed' }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting } from '@/lib/db';
import { getLatestSessionByWorkingDirectory, createSession } from '@/lib/db';
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().catch(() => ({}));
const { mode } = body as { mode?: 'onboarding' | 'checkin' };
// For onboarding: always create new session
// For checkin: reuse latest session if exists
let session;
if (mode === 'checkin') {
session = getLatestSessionByWorkingDirectory(workspacePath);
}
if (!session) {
const model = typeof body.model === 'string' ? body.model : '';
const provider_id = typeof body.provider_id === 'string' ? body.provider_id : '';
session = createSession(undefined, model, undefined, workspacePath, 'code', provider_id);
}
return NextResponse.json({ session, isNew: !session.sdk_session_id });
} catch (e) {
console.error('[workspace/session] POST failed:', e);
return NextResponse.json({ error: 'Failed to create/find session' }, { status: 500 });
}
}