feat: assistant workspace V2 — indexing, retrieval, security hardening & workspace switching
Assistant Workspace V2: - Markdown indexing engine (incremental manifest + chunks JSONL) - Keyword retrieval with CJK bigram support and hotset boosting - Directory taxonomy inference and evolution suggestions - Workspace config management (.assistant/config.json) - Organize API: capture, classify, move, archive, suggest-evolution Workspace switching: - Inspect API (GET /api/workspace/inspect) for pre-switch validation - Two-phase switching with confirmation dialogs (5 workspace statuses) - Atomic save: init/reset runs before setSetting(), rollback on failure - Auto-navigate to new/reused session after switch - Mismatch banner in ChatView (only for former assistant sessions) - Debounced path validation with visual indicators Security hardening: - Completion fence (onboarding-complete/checkin-complete) only processes when session.workingDirectory === assistant_workspace_path (frontend) AND session.working_directory === workspacePath (backend double-check) - assertContained() in workspace-organizer: rejects absolute paths, ~/, ../ traversal, and symlink escapes for captureNote/moveFile - organize API validates all path params at entry (validateRelativePath) - captureDefault sanitized to relative path on onboarding write - R_OK + W_OK validation on GET settings, session creation, and PUT Bug fixes: - hookTriggeredSessionId now read before auto-trigger; allows retry if session has no messages (previous trigger failed silently) - workspace-switched event listener matches oldPath (not just != newPath) so normal project chats never show the mismatch banner - Daily check-in card hidden when onboarding not complete - Refresh docs now calls generateRootDocs() + generateDirectoryDocs() - Banner "Open" button reuses latest session (checkin mode) instead of always creating new onboarding session Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
19aba25c8f
commit
94fdd36d27
28 files changed
+3855
-181
No files matched your search
@@ -1,8 +1,8 @@
|
||||
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 { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
|
||||
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
|
||||
const CHECK_IN_QUESTIONS = [
|
||||
@@ -35,12 +35,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { answers } = body as { answers: Record<string, string> };
|
||||
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
|
||||
|
||||
if (!answers || typeof answers !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that the calling session belongs to this workspace
|
||||
if (sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
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)'}`;
|
||||
@@ -60,27 +68,98 @@ export async function POST(request: NextRequest) {
|
||||
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}`;
|
||||
// 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}.
|
||||
|
||||
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}`;
|
||||
Format it with these sections:
|
||||
## Work Log
|
||||
(What the user accomplished today)
|
||||
|
||||
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 }),
|
||||
## 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 }),
|
||||
]);
|
||||
|
||||
if (!newMemory.trim()) {
|
||||
throw new Error('AI returned empty memory content');
|
||||
// Write daily memory file (episodic, per-day)
|
||||
if (dailyContent.trim()) {
|
||||
writeDailyMemory(workspacePath, today, dailyContent);
|
||||
}
|
||||
fs.writeFileSync(memoryPath, newMemory, 'utf-8');
|
||||
|
||||
// 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', '') <= sevenDaysAgo.toISOString().slice(0, 10));
|
||||
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, 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');
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { generateDirectoryDocs } from '@/lib/assistant-workspace';
|
||||
import { generateDirectoryDocs, generateRootDocs } from '@/lib/assistant-workspace';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
@@ -12,12 +12,13 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
|
||||
const files = generateDirectoryDocs(workspacePath);
|
||||
return NextResponse.json({ files });
|
||||
const rootFiles = generateRootDocs(workspacePath);
|
||||
const directoryFiles = generateDirectoryDocs(workspacePath);
|
||||
return NextResponse.json({ root: rootFiles, directory: directoryFiles, files: [...rootFiles, ...directoryFiles] });
|
||||
} catch (e) {
|
||||
console.error('[workspace/docs] Failed to generate directory docs:', e);
|
||||
console.error('[workspace/docs] Failed to generate docs:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate directory docs' },
|
||||
{ error: 'Failed to generate docs' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSetting } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { getIndexStats } = await import('@/lib/workspace-indexer');
|
||||
const stats = getIndexStats(workspacePath);
|
||||
|
||||
return NextResponse.json(stats);
|
||||
} catch (e) {
|
||||
console.error('[workspace/index] GET failed:', e);
|
||||
return NextResponse.json({ error: 'Failed to get index stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { indexWorkspace } = await import('@/lib/workspace-indexer');
|
||||
const result = indexWorkspace(workspacePath, { force: true });
|
||||
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
console.error('[workspace/index] POST failed:', e);
|
||||
return NextResponse.json({ error: 'Indexing failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
type WorkspaceStatus = 'empty' | 'normal_directory' | 'existing_workspace' | 'partial_workspace' | 'invalid';
|
||||
|
||||
const ASSISTANT_FILES = ['soul.md', 'Soul.md', 'SOUL.md', 'user.md', 'User.md', 'USER.md', 'PROFILE.md', 'memory.md', 'Memory.md', 'MEMORY.md', 'claude.md', 'Claude.md', 'CLAUDE.md', 'AGENTS.md'];
|
||||
const CORE_FILE_KEYS = ['soul', 'user', 'memory', 'claude'] as const;
|
||||
|
||||
function countExistingCoreFiles(dir: string): number {
|
||||
const found = new Set<string>();
|
||||
const keyMap: Record<string, string> = {
|
||||
'soul.md': 'soul', 'Soul.md': 'soul', 'SOUL.md': 'soul',
|
||||
'user.md': 'user', 'User.md': 'user', 'USER.md': 'user', 'PROFILE.md': 'user',
|
||||
'memory.md': 'memory', 'Memory.md': 'memory', 'MEMORY.md': 'memory',
|
||||
'claude.md': 'claude', 'Claude.md': 'claude', 'CLAUDE.md': 'claude', 'AGENTS.md': 'claude',
|
||||
};
|
||||
for (const filename of ASSISTANT_FILES) {
|
||||
if (fs.existsSync(path.join(dir, filename))) {
|
||||
const key = keyMap[filename];
|
||||
if (key) found.add(key);
|
||||
}
|
||||
}
|
||||
return found.size;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const inspectPath = searchParams.get('path');
|
||||
|
||||
if (!inspectPath || typeof inspectPath !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing required query parameter: path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check existence
|
||||
let exists = false;
|
||||
let isDirectory = false;
|
||||
try {
|
||||
const stat = fs.statSync(inspectPath);
|
||||
exists = true;
|
||||
isDirectory = stat.isDirectory();
|
||||
} catch {
|
||||
// Path doesn't exist
|
||||
}
|
||||
|
||||
if (!exists || !isDirectory) {
|
||||
return NextResponse.json({
|
||||
exists,
|
||||
isDirectory,
|
||||
readable: false,
|
||||
writable: false,
|
||||
hasAssistantData: false,
|
||||
workspaceStatus: 'invalid' as WorkspaceStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
let readable = false;
|
||||
let writable = false;
|
||||
try {
|
||||
fs.accessSync(inspectPath, fs.constants.R_OK);
|
||||
readable = true;
|
||||
} catch { /* not readable */ }
|
||||
try {
|
||||
fs.accessSync(inspectPath, fs.constants.W_OK);
|
||||
writable = true;
|
||||
} catch { /* not writable */ }
|
||||
|
||||
if (!readable) {
|
||||
return NextResponse.json({
|
||||
exists,
|
||||
isDirectory,
|
||||
readable,
|
||||
writable,
|
||||
hasAssistantData: false,
|
||||
workspaceStatus: 'invalid' as WorkspaceStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for .assistant/state.json
|
||||
const statePath = path.join(inspectPath, '.assistant', 'state.json');
|
||||
const hasAssistantData = fs.existsSync(statePath);
|
||||
|
||||
// Determine workspace status
|
||||
let workspaceStatus: WorkspaceStatus;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(inspectPath, { withFileTypes: true });
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
// Filter out hidden entries for "emptiness" check
|
||||
const visibleEntries = entries.filter(e => !e.name.startsWith('.'));
|
||||
const hasAssistantDir = fs.existsSync(path.join(inspectPath, '.assistant'));
|
||||
const fileCount = countExistingCoreFiles(inspectPath);
|
||||
|
||||
if (hasAssistantData) {
|
||||
workspaceStatus = 'existing_workspace';
|
||||
} else if (hasAssistantDir || fileCount > 0) {
|
||||
// Has some assistant-related files but no state.json
|
||||
workspaceStatus = 'partial_workspace';
|
||||
} else if (visibleEntries.length === 0) {
|
||||
workspaceStatus = 'empty';
|
||||
} else {
|
||||
workspaceStatus = 'normal_directory';
|
||||
}
|
||||
|
||||
// Build response
|
||||
const result: Record<string, unknown> = {
|
||||
exists,
|
||||
isDirectory,
|
||||
readable,
|
||||
writable,
|
||||
hasAssistantData,
|
||||
workspaceStatus,
|
||||
};
|
||||
|
||||
// If existing_workspace, return summary
|
||||
if (workspaceStatus === 'existing_workspace') {
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as { onboardingComplete?: boolean; lastCheckInDate?: string | null };
|
||||
result.summary = {
|
||||
onboardingComplete: state.onboardingComplete ?? false,
|
||||
lastCheckInDate: state.lastCheckInDate ?? null,
|
||||
fileCount,
|
||||
};
|
||||
} catch {
|
||||
result.summary = {
|
||||
onboardingComplete: false,
|
||||
lastCheckInDate: null,
|
||||
fileCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
console.error('[workspace/inspect] GET failed:', e);
|
||||
return NextResponse.json({ error: 'Failed to inspect workspace path' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
|
||||
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
|
||||
const QUESTIONS = [
|
||||
@@ -16,6 +16,9 @@ const QUESTIONS = [
|
||||
'assistant.onboardingQ8',
|
||||
'assistant.onboardingQ9',
|
||||
'assistant.onboardingQ10',
|
||||
'assistant.onboardingQ11',
|
||||
'assistant.onboardingQ12',
|
||||
'assistant.onboardingQ13',
|
||||
];
|
||||
|
||||
const QUESTION_LABELS = [
|
||||
@@ -29,6 +32,9 @@ const QUESTION_LABELS = [
|
||||
'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?',
|
||||
'How do you organize your materials? (by project / time / topic / mixed)',
|
||||
'Where should new information go by default?',
|
||||
'How should completed tasks be archived?',
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
@@ -49,12 +55,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { answers } = body as { answers: Record<string, string> };
|
||||
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
|
||||
|
||||
if (!answers || typeof answers !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that the calling session belongs to this workspace
|
||||
if (sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
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}`;
|
||||
@@ -63,6 +77,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let soulContent: string;
|
||||
let userContent: string;
|
||||
let claudeContent: string;
|
||||
let memoryContent: string;
|
||||
|
||||
try {
|
||||
const providerId = getDefaultProviderId() || '';
|
||||
@@ -72,10 +88,22 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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([
|
||||
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');
|
||||
@@ -84,18 +112,78 @@ export async function POST(request: NextRequest) {
|
||||
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`;
|
||||
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 files
|
||||
// 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 = 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
|
||||
state.schemaVersion = 2;
|
||||
saveState(workspacePath, state);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import { getSetting } from '@/lib/db';
|
||||
|
||||
interface OrganizeAction {
|
||||
action: 'capture' | 'classify' | 'move' | 'archive' | 'suggest-evolution';
|
||||
title?: string;
|
||||
content?: string;
|
||||
filePath?: string;
|
||||
fromPath?: string;
|
||||
toPath?: string;
|
||||
}
|
||||
|
||||
/** Pre-validate that user-supplied paths are safe relative paths within the workspace. */
|
||||
function validateRelativePath(p: string, fieldName: string): NextResponse | null {
|
||||
if (!p) return null;
|
||||
if (path.isAbsolute(p) || p.startsWith('~') || p.includes('..')) {
|
||||
return NextResponse.json(
|
||||
{ error: `${fieldName} must be a relative path within the workspace (no absolute paths, ~, or ..)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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: OrganizeAction = await request.json();
|
||||
|
||||
// Path safety checks at API boundary
|
||||
if (body.filePath) {
|
||||
const err = validateRelativePath(body.filePath, 'filePath');
|
||||
if (err) return err;
|
||||
}
|
||||
if (body.fromPath) {
|
||||
const err = validateRelativePath(body.fromPath, 'fromPath');
|
||||
if (err) return err;
|
||||
}
|
||||
if (body.toPath) {
|
||||
const err = validateRelativePath(body.toPath, 'toPath');
|
||||
if (err) return err;
|
||||
}
|
||||
|
||||
const organizer = await import('@/lib/workspace-organizer');
|
||||
|
||||
switch (body.action) {
|
||||
case 'capture': {
|
||||
if (!body.title || !body.content) {
|
||||
return NextResponse.json({ error: 'title and content required' }, { status: 400 });
|
||||
}
|
||||
const filePath = organizer.captureNote(workspacePath, body.title, body.content);
|
||||
return NextResponse.json({ success: true, filePath });
|
||||
}
|
||||
|
||||
case 'classify': {
|
||||
if (!body.filePath) {
|
||||
return NextResponse.json({ error: 'filePath required' }, { status: 400 });
|
||||
}
|
||||
const result = organizer.classifyAndSuggest(workspacePath, body.filePath);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!body.fromPath || !body.toPath) {
|
||||
return NextResponse.json({ error: 'fromPath and toPath required' }, { status: 400 });
|
||||
}
|
||||
organizer.moveFile(workspacePath, body.fromPath, body.toPath);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
case 'archive': {
|
||||
const result = organizer.archiveDailyMemories(workspacePath);
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
}
|
||||
|
||||
case 'suggest-evolution': {
|
||||
const suggestions = organizer.suggestTaxonomyEvolution(workspacePath);
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Organize action failed';
|
||||
// Surface containment errors as 400
|
||||
if (msg.includes('escapes workspace') || msg.includes('not allowed')) {
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
console.error('[workspace/organize] POST failed:', e);
|
||||
return NextResponse.json({ error: 'Organize action failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const limit = parseInt(searchParams.get('limit') || '5', 10);
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: 'Query parameter "q" is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { searchWorkspace } = await import('@/lib/workspace-retrieval');
|
||||
const results = searchWorkspace(workspacePath, query, { limit });
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (e) {
|
||||
console.error('[workspace/search] GET failed:', e);
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { getLatestSessionByWorkingDirectory, createSession } from '@/lib/db';
|
||||
|
||||
@@ -9,6 +10,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate that the workspace directory actually exists and is accessible
|
||||
try {
|
||||
const stat = fs.statSync(workspacePath);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: 'Workspace path is not a directory', code: 'not_a_directory' }, { status: 400 });
|
||||
}
|
||||
fs.accessSync(workspacePath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Workspace path does not exist or is not accessible', code: 'path_invalid' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { mode } = body as { mode?: 'onboarding' | 'checkin' };
|
||||
|
||||
|
||||
Reference in new issue
Block a user