Files
CodePilot/src/lib/workspace-organizer.ts
T
7418andClaude Opus 4.6 94fdd36d27 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>
2026-03-07 00:37:02 +08:00

295 lines
9.8 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import type { TaxonomyCategory, TaxonomyFile } from '@/types';
import { loadConfig } from '@/lib/workspace-config';
import { loadTaxonomy, saveTaxonomy, classifyPath } from '@/lib/workspace-taxonomy';
import { loadManifest } from '@/lib/workspace-indexer';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Assert that `target` resolves to a path inside `workspaceDir`.
* Rejects absolute paths, `../` traversals, and symlink escapes.
*/
export function assertContained(workspaceDir: string, relativePath: string): string {
// Reject absolute paths and obvious traversal attempts early
if (path.isAbsolute(relativePath)) {
throw new Error(`Absolute paths are not allowed: ${relativePath}`);
}
if (relativePath.startsWith('~')) {
throw new Error(`Home-relative paths are not allowed: ${relativePath}`);
}
const resolved = path.resolve(workspaceDir, relativePath);
const normalizedWorkspace = path.resolve(workspaceDir);
// Must start with workspace dir + separator (or equal it)
if (resolved !== normalizedWorkspace && !resolved.startsWith(normalizedWorkspace + path.sep)) {
throw new Error(`Path escapes workspace boundary: ${relativePath}`);
}
// If the resolved path exists, check it's not a symlink pointing outside
try {
const real = fs.realpathSync(resolved);
if (real !== normalizedWorkspace && !real.startsWith(normalizedWorkspace + path.sep)) {
throw new Error(`Symlink target escapes workspace boundary: ${relativePath}`);
}
} catch (e) {
// File doesn't exist yet — that's fine for targets being created
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
}
return resolved;
}
function sanitizeFilename(title: string): string {
return title
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 200);
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function parseDateFromFilename(filename: string): Date | null {
const match = filename.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
if (!match) return null;
const d = new Date(match[1] + 'T00:00:00');
return isNaN(d.getTime()) ? null : d;
}
function daysBetween(a: Date, b: Date): number {
const msPerDay = 86_400_000;
return Math.floor(Math.abs(a.getTime() - b.getTime()) / msPerDay);
}
// ---------------------------------------------------------------------------
// 1. Capture
// ---------------------------------------------------------------------------
export function captureNote(dir: string, title: string, content: string): string {
const config = loadConfig(dir);
const captureDir = config.captureDefault || 'Inbox';
// Validate captureDefault is a safe relative path within workspace
const filename = sanitizeFilename(title) + '.md';
const relativePath = path.join(captureDir, filename);
const absTarget = assertContained(dir, relativePath);
ensureDir(path.dirname(absTarget));
fs.writeFileSync(absTarget, content, 'utf-8');
return absTarget;
}
// ---------------------------------------------------------------------------
// 2. Classify & Suggest
// ---------------------------------------------------------------------------
export function classifyAndSuggest(
dir: string,
filePath: string,
): { suggestedCategory: TaxonomyCategory | null; confidence: number; suggestedPath: string } {
const taxonomy = loadTaxonomy(dir);
const category = classifyPath(filePath, taxonomy);
if (category) {
const primaryPath = category.paths[0] || 'Inbox/';
const basename = path.basename(filePath);
return {
suggestedCategory: category,
confidence: category.confidence,
suggestedPath: path.join(primaryPath, basename),
};
}
// No good match — suggest Inbox
const basename = path.basename(filePath);
return {
suggestedCategory: null,
confidence: 0,
suggestedPath: path.join('Inbox', basename),
};
}
// ---------------------------------------------------------------------------
// 3. Move File
// ---------------------------------------------------------------------------
export function moveFile(dir: string, fromPath: string, toPath: string): void {
const absFrom = assertContained(dir, fromPath);
const absTo = assertContained(dir, toPath);
ensureDir(path.dirname(absTo));
fs.renameSync(absFrom, absTo);
}
// ---------------------------------------------------------------------------
// 4. Archive Daily Memories
// ---------------------------------------------------------------------------
export function archiveDailyMemories(
dir: string,
): { archived: number; retained: number } {
const config = loadConfig(dir);
const retentionDays = config.archivePolicy.dailyMemoryRetentionDays;
const dailyDir = path.join(dir, 'memory', 'daily');
if (!fs.existsSync(dailyDir)) {
return { archived: 0, retained: 0 };
}
const now = new Date();
let archived = 0;
let retained = 0;
const entries = fs.readdirSync(dailyDir);
const archivePath = path.join(dailyDir, 'archive.md');
for (const entry of entries) {
if (entry === 'archive.md') continue;
const fileDate = parseDateFromFilename(entry);
if (!fileDate) {
retained++;
continue;
}
if (daysBetween(now, fileDate) > retentionDays) {
// Read content and build summary line
const fullPath = path.join(dailyDir, entry);
const content = fs.readFileSync(fullPath, 'utf-8');
const firstLine = content.split('\n').find((l) => l.trim().length > 0) || entry;
const summaryLine = `- **${entry}**: ${firstLine.replace(/^#+\s*/, '').trim()}\n`;
// Append to archive
fs.appendFileSync(archivePath, summaryLine, 'utf-8');
fs.unlinkSync(fullPath);
archived++;
} else {
retained++;
}
}
return { archived, retained };
}
// ---------------------------------------------------------------------------
// 5. Promote Daily to Long-Term
// ---------------------------------------------------------------------------
export function promoteDailyToLongTerm(dir: string, date: string): boolean {
const dailyFile = path.join(dir, 'memory', 'daily', `${date}.md`);
if (!fs.existsSync(dailyFile)) return false;
const content = fs.readFileSync(dailyFile, 'utf-8');
// Skip if already promoted (idempotency marker)
if (content.includes('<!-- promoted -->')) return false;
// Look for promotion-worthy sections
const sectionPattern = /^##\s+(Candidate Long-Term Memory|To Remember)\s*$/im;
const match = content.match(sectionPattern);
if (!match) return false;
// Extract content from the matched section until the next ## heading or EOF
const sectionStart = match.index! + match[0].length;
const rest = content.slice(sectionStart);
const nextHeading = rest.search(/^##\s+/m);
const sectionContent =
nextHeading >= 0 ? rest.slice(0, nextHeading).trim() : rest.trim();
// Only promote if non-trivial
if (sectionContent.length <= 50) return false;
// Check for duplicates in memory.md
const memoryFile = path.join(dir, 'memory.md');
let existingMemory = '';
try { existingMemory = fs.readFileSync(memoryFile, 'utf-8'); } catch { /* new file */ }
if (existingMemory.includes(`## Promoted from ${date}`)) return false;
const promotionBlock = `\n\n## Promoted from ${date}\n\n${sectionContent}\n`;
fs.appendFileSync(memoryFile, promotionBlock, 'utf-8');
// Mark daily file as promoted so it won't be promoted again
fs.appendFileSync(dailyFile, '\n<!-- promoted -->\n', 'utf-8');
return true;
}
// ---------------------------------------------------------------------------
// 6. Suggest Taxonomy Evolution
// ---------------------------------------------------------------------------
export function suggestTaxonomyEvolution(
dir: string,
): Array<{ type: 'new_category' | 'merge' | 'archive_category'; description: string; confidence: number }> {
const taxonomy = loadTaxonomy(dir);
const manifest = loadManifest(dir);
const suggestions: Array<{
type: 'new_category' | 'merge' | 'archive_category';
description: string;
confidence: number;
}> = [];
// Count files per category
const categoryFileCounts = new Map<string, number>();
for (const cat of taxonomy.categories) {
categoryFileCounts.set(cat.id, 0);
}
const uncategorized: string[] = [];
for (const entry of manifest) {
const category = classifyPath(entry.path, taxonomy);
if (category) {
categoryFileCounts.set(
category.id,
(categoryFileCounts.get(category.id) || 0) + 1,
);
} else {
uncategorized.push(entry.path);
}
}
// Files without a good category → suggest new category
if (uncategorized.length > 0) {
// Group by top-level directory
const dirGroups = new Map<string, number>();
for (const p of uncategorized) {
const topDir = p.split('/')[0] || p;
dirGroups.set(topDir, (dirGroups.get(topDir) || 0) + 1);
}
for (const [dirName, count] of dirGroups) {
if (count >= 2) {
suggestions.push({
type: 'new_category',
description: `Create category for "${dirName}" (${count} uncategorized files)`,
confidence: Math.min(0.9, 0.4 + count * 0.1),
});
}
}
}
// Categories with 0 files → suggest archive
for (const [catId, count] of categoryFileCounts) {
if (count === 0) {
const cat = taxonomy.categories.find((c) => c.id === catId);
suggestions.push({
type: 'archive_category',
description: `Archive empty category "${cat?.label || catId}" (0 files)`,
confidence: 0.7,
});
}
}
// Sort by confidence descending
suggestions.sort((a, b) => b.confidence - a.confidence);
return suggestions;
}