Files
CodePilot/src/lib/workspace-indexer.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

428 lines
12 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import type { ManifestEntry, ChunkEntry, HotsetFile, AssistantWorkspaceConfig } from '@/types';
import { loadConfig, shouldIgnore } from '@/lib/workspace-config';
import { loadTaxonomy, classifyPath } from '@/lib/workspace-taxonomy';
export const INDEX_DIR = '.assistant/index';
export function computeFileHash(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
export function chunkMarkdown(
content: string,
chunkSize = 1200,
overlap = 150,
): Array<{ heading: string; text: string; startLine: number; endLine: number }> {
const lines = content.split('\n');
const sections: Array<{ heading: string; lines: string[]; startLine: number }> = [];
let currentHeading = '';
let currentLines: string[] = [];
let sectionStart = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.match(/^#{1,6}\s/)) {
if (currentLines.length > 0) {
sections.push({ heading: currentHeading, lines: currentLines, startLine: sectionStart });
}
currentHeading = line.replace(/^#{1,6}\s+/, '').trim();
currentLines = [line];
sectionStart = i;
} else {
currentLines.push(line);
}
}
if (currentLines.length > 0) {
sections.push({ heading: currentHeading, lines: currentLines, startLine: sectionStart });
}
const chunks: Array<{ heading: string; text: string; startLine: number; endLine: number }> = [];
for (const section of sections) {
const sectionText = section.lines.join('\n');
if (sectionText.length <= chunkSize) {
chunks.push({
heading: section.heading,
text: sectionText,
startLine: section.startLine,
endLine: section.startLine + section.lines.length - 1,
});
} else {
// Split large sections with overlap
let charPos = 0;
let lineIdx = 0;
while (charPos < sectionText.length) {
const end = Math.min(charPos + chunkSize, sectionText.length);
const chunkText = sectionText.slice(charPos, end);
// Determine line range for this chunk
const chunkLines = chunkText.split('\n');
const startLine = section.startLine + lineIdx;
const endLine = startLine + chunkLines.length - 1;
chunks.push({
heading: section.heading,
text: chunkText,
startLine,
endLine,
});
if (end >= sectionText.length) break;
// Advance by chunkSize - overlap
const advance = chunkSize - overlap;
const advancedText = sectionText.slice(charPos, charPos + advance);
const advancedLines = advancedText.split('\n');
lineIdx += advancedLines.length - 1;
charPos += advance;
}
}
}
return chunks;
}
export function extractMarkdownMeta(content: string): {
title: string;
tags: string[];
headings: string[];
aliases: string[];
} {
const lines = content.split('\n');
let title = '';
const tags: string[] = [];
const headings: string[] = [];
const aliases: string[] = [];
// Parse YAML frontmatter
if (lines[0]?.trim() === '---') {
let fmEnd = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
fmEnd = i;
break;
}
}
if (fmEnd > 0) {
const fmLines = lines.slice(1, fmEnd);
let currentKey = '';
for (const fmLine of fmLines) {
const kvMatch = fmLine.match(/^(\w+)\s*:\s*(.*)$/);
if (kvMatch) {
currentKey = kvMatch[1];
const value = kvMatch[2].trim();
if (currentKey === 'tags') {
// Inline array: tags: [a, b, c]
const inlineMatch = value.match(/^\[(.+)\]$/);
if (inlineMatch) {
tags.push(...inlineMatch[1].split(',').map((t) => t.trim().replace(/^["']|["']$/g, '')));
currentKey = '';
}
// If value is non-empty but not array syntax, treat as single tag
else if (value) {
tags.push(value.replace(/^["']|["']$/g, ''));
currentKey = '';
}
} else if (currentKey === 'aliases') {
const inlineMatch = value.match(/^\[(.+)\]$/);
if (inlineMatch) {
aliases.push(...inlineMatch[1].split(',').map((a) => a.trim().replace(/^["']|["']$/g, '')));
currentKey = '';
} else if (value) {
aliases.push(value.replace(/^["']|["']$/g, ''));
currentKey = '';
}
} else {
currentKey = '';
}
} else if (currentKey) {
// List item under current key
const listMatch = fmLine.match(/^\s*-\s+(.+)$/);
if (listMatch) {
const item = listMatch[1].trim().replace(/^["']|["']$/g, '');
if (currentKey === 'tags') tags.push(item);
else if (currentKey === 'aliases') aliases.push(item);
} else {
currentKey = '';
}
}
}
}
}
// Extract headings and title
for (const line of lines) {
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
headings.push(headingMatch[2].trim());
if (!title && headingMatch[1] === '#') {
title = headingMatch[2].trim();
}
}
}
// Extract inline #tags from content (not inside code blocks)
let inCodeBlock = false;
for (const line of lines) {
if (line.startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const tagMatches = line.match(/(?:^|\s)#([a-zA-Z][a-zA-Z0-9_-]*)/g);
if (tagMatches) {
for (const match of tagMatches) {
const tag = match.trim().slice(1); // remove #
// Skip if it looks like a heading (line starts with #)
if (!line.match(/^#{1,6}\s/) && !tags.includes(tag)) {
tags.push(tag);
}
}
}
}
return { title, tags, headings, aliases };
}
export function needsReindex(dir: string, filePath: string): boolean {
const manifestPath = path.join(dir, INDEX_DIR, 'manifest.jsonl');
if (!fs.existsSync(manifestPath)) return true;
let fileMtime: number;
try {
fileMtime = fs.statSync(path.join(dir, filePath)).mtimeMs;
} catch {
return true;
}
const manifest = loadManifest(dir);
const entry = manifest.find((e) => e.path === filePath);
if (!entry) return true;
return fileMtime > entry.mtime;
}
export function indexFile(
dir: string,
filePath: string,
): { manifest: ManifestEntry; chunks: ChunkEntry[] } {
const fullPath = path.join(dir, filePath);
const content = fs.readFileSync(fullPath, 'utf-8');
const stat = fs.statSync(fullPath);
const hash = computeFileHash(content);
const meta = extractMarkdownMeta(content);
const noteId = computeFileHash(filePath);
const taxonomy = loadTaxonomy(dir);
const category = classifyPath(filePath, taxonomy);
const categoryIds = category ? [category.id] : [];
const config = loadConfig(dir);
const rawChunks = chunkMarkdown(content, config.index.chunkSize, config.index.chunkOverlap);
const chunks: ChunkEntry[] = rawChunks.map((c, i) => ({
chunkId: `${noteId}-${i}`,
noteId,
path: filePath,
heading: c.heading,
text: c.text,
startLine: c.startLine,
endLine: c.endLine,
}));
const manifest: ManifestEntry = {
noteId,
path: filePath,
title: meta.title || path.basename(filePath, path.extname(filePath)),
aliases: meta.aliases,
tags: meta.tags,
headings: meta.headings,
mtime: stat.mtimeMs,
size: stat.size,
hash,
summary: content.slice(0, 200).replace(/\n/g, ' ').trim(),
categoryIds,
};
return { manifest, chunks };
}
function walkDir(
dir: string,
baseDir: string,
config: AssistantWorkspaceConfig,
depth: number,
): string[] {
if (depth > config.index.maxDepth) return [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
const maxBytes = config.index.maxFileSizeKB * 1024;
const extensions = new Set(config.index.includeExtensions);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
if (shouldIgnore(relPath, config)) continue;
if (entry.isDirectory()) {
files.push(...walkDir(fullPath, baseDir, config, depth + 1));
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (!extensions.has(ext)) continue;
try {
const stat = fs.statSync(fullPath);
if (stat.size > maxBytes) continue;
} catch {
continue;
}
files.push(relPath);
}
}
return files;
}
export function indexWorkspace(dir: string, options?: { force?: boolean }): { fileCount: number; chunkCount: number } {
const config = loadConfig(dir);
const force = options?.force ?? false;
const indexDir = path.join(dir, INDEX_DIR);
if (!fs.existsSync(indexDir)) {
fs.mkdirSync(indexDir, { recursive: true });
}
const files = walkDir(dir, dir, config, 0);
const fileSet = new Set(files);
// Load existing index for incremental update
const existingManifest = force ? [] : loadManifest(dir);
const existingChunks = force ? [] : loadChunks(dir);
// Build lookup maps from existing index
const existingByPath = new Map<string, ManifestEntry>();
for (const entry of existingManifest) {
existingByPath.set(entry.path, entry);
}
const existingChunksByNote = new Map<string, ChunkEntry[]>();
for (const chunk of existingChunks) {
const list = existingChunksByNote.get(chunk.noteId) || [];
list.push(chunk);
existingChunksByNote.set(chunk.noteId, list);
}
const manifests: ManifestEntry[] = [];
const allChunks: ChunkEntry[] = [];
let reindexed = 0;
for (const filePath of files) {
const existing = existingByPath.get(filePath);
// Skip files that haven't changed (incremental)
if (!force && existing) {
try {
const stat = fs.statSync(path.join(dir, filePath));
if (stat.mtimeMs <= existing.mtime) {
// Reuse existing manifest and chunks
manifests.push(existing);
const existingFileChunks = existingChunksByNote.get(existing.noteId) || [];
allChunks.push(...existingFileChunks);
continue;
}
} catch {
// File stat failed, re-index it
}
}
try {
const { manifest, chunks } = indexFile(dir, filePath);
manifests.push(manifest);
allChunks.push(...chunks);
reindexed++;
} catch {
// Skip files that fail to index
}
}
// Only write if something changed (or force)
if (force || reindexed > 0 || manifests.length !== existingManifest.length) {
const manifestLines = manifests.map((m) => JSON.stringify(m)).join('\n');
fs.writeFileSync(path.join(indexDir, 'manifest.jsonl'), manifestLines + '\n', 'utf-8');
const chunkLines = allChunks.map((c) => JSON.stringify(c)).join('\n');
fs.writeFileSync(path.join(indexDir, 'chunks.jsonl'), chunkLines + '\n', 'utf-8');
}
return { fileCount: manifests.length, chunkCount: allChunks.length };
}
export function loadManifest(dir: string): ManifestEntry[] {
const manifestPath = path.join(dir, INDEX_DIR, 'manifest.jsonl');
try {
const content = fs.readFileSync(manifestPath, 'utf-8').trim();
if (!content) return [];
return content.split('\n').map((line) => JSON.parse(line) as ManifestEntry);
} catch {
return [];
}
}
export function loadChunks(dir: string): ChunkEntry[] {
const chunksPath = path.join(dir, INDEX_DIR, 'chunks.jsonl');
try {
const content = fs.readFileSync(chunksPath, 'utf-8').trim();
if (!content) return [];
return content.split('\n').map((line) => JSON.parse(line) as ChunkEntry);
} catch {
return [];
}
}
export function getIndexStats(dir: string): {
fileCount: number;
chunkCount: number;
lastIndexed: number;
staleCount: number;
} {
const manifest = loadManifest(dir);
const chunks = loadChunks(dir);
let lastIndexed = 0;
let staleCount = 0;
for (const entry of manifest) {
if (entry.mtime > lastIndexed) {
lastIndexed = entry.mtime;
}
try {
const stat = fs.statSync(path.join(dir, entry.path));
if (stat.mtimeMs > entry.mtime) {
staleCount++;
}
} catch {
staleCount++; // File deleted
}
}
return {
fileCount: manifest.length,
chunkCount: chunks.length,
lastIndexed,
staleCount,
};
}