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:
7418andClaude Opus 4.6 committed 2026-03-07 00:37:02 +08:00
1 parent 19aba25c8f
commit 94fdd36d27
28 files changed
+3855 -181

No files matched your search

+93 -14
View File
@@ -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
+6 -5
View File
@@ -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 },
);
}
+36
View File
@@ -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 });
}
}
+144
View File
@@ -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 });
}
}
+94 -6
View File
@@ -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 });
+98
View File
@@ -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 });
}
}
+27
View File
@@ -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 });
}
}
+12
View File
@@ -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' };