release: v2.0.1 - Advanced Knowledge Mix & Architectural Intelligence
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Project Architecture Context (Feature 2)
|
||||
*
|
||||
* Builds a markdown document that captures the *durable* facts about a project
|
||||
* — its purpose, modules, key files, constraints, decisions — so Astra can
|
||||
* attach it to every prompt instead of re-discovering the project on each
|
||||
* turn.
|
||||
*
|
||||
* Two-layer design so we get the best of both deterministic generation and
|
||||
* user-curated knowledge:
|
||||
*
|
||||
* AUTO-MANAGED sections – regenerated on every refresh from static
|
||||
* analysis (package.json, top-level tree, etc.).
|
||||
* Bracketed by `<!-- ASTRA:AUTO-START --> …
|
||||
* <!-- ASTRA:AUTO-END -->` markers so the file
|
||||
* watcher can rewrite them without trampling
|
||||
* anything the user wrote.
|
||||
* USER-OWNED sections – created with TODO placeholders on first build,
|
||||
* never overwritten thereafter. Users (or the
|
||||
* assistant, when asked) fill in Purpose,
|
||||
* Key Workflows, Constraints, Risks, Decisions.
|
||||
*
|
||||
* The generator is purely synchronous, never makes network calls, and never
|
||||
* touches the model — by design. Refresh runs are cheap (single-digit ms on
|
||||
* a project this size) so they can fire after every file change without
|
||||
* starving the rest of the extension.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/** Sub-folder under the project root where the architecture doc lives. */
|
||||
const ARCH_DIR_REL = path.join('.astra', 'project-context');
|
||||
const ARCH_FILE = 'architecture.md';
|
||||
|
||||
/** Top-level directories we consider "code" worth listing under Main Modules. */
|
||||
const CODE_DIRS = ['src', 'media', 'core_py', 'lib', 'app', 'apps', 'packages', 'tests'];
|
||||
|
||||
/** Files at the project root worth highlighting under "Important Files". */
|
||||
const ROOT_IMPORTANT = [
|
||||
'package.json', 'pnpm-workspace.yaml', 'tsconfig.json',
|
||||
'README.md', 'CHANGELOG.md', 'ARCHITECTURE.md',
|
||||
'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod',
|
||||
'Dockerfile', 'docker-compose.yml',
|
||||
];
|
||||
|
||||
const AUTO_START = '<!-- ASTRA:AUTO-START -->';
|
||||
const AUTO_END = '<!-- ASTRA:AUTO-END -->';
|
||||
|
||||
export interface ArchitectureScanResult {
|
||||
projectName: string;
|
||||
projectRoot: string;
|
||||
description: string;
|
||||
runtimes: string[]; // e.g. ["TypeScript", "Node", "VS Code Extension"]
|
||||
mainModules: { dir: string; description: string }[];
|
||||
importantFiles: string[]; // root-relative
|
||||
/** Cheap hash of the scan inputs — used by the watcher to skip no-ops. */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
/** Absolute path to the architecture markdown. */
|
||||
docPath: string;
|
||||
/** True if the file was newly created (vs. an in-place auto-block refresh). */
|
||||
created: boolean;
|
||||
/** Result of the scan that fed this build. */
|
||||
scan: ArchitectureScanResult;
|
||||
}
|
||||
|
||||
/** Resolve the architecture doc path for a given project root. */
|
||||
export function architectureDocPathFor(projectRoot: string): string {
|
||||
return path.join(projectRoot, ARCH_DIR_REL, ARCH_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a project root and return a structured summary. Pure, side-effect free
|
||||
* (apart from reading the file system) so we can unit-test the signature/diff
|
||||
* logic without writing any files.
|
||||
*/
|
||||
export function scanProject(projectRoot: string, projectName?: string): ArchitectureScanResult {
|
||||
const safeRoot = projectRoot && fs.existsSync(projectRoot) ? projectRoot : '';
|
||||
const name = (projectName?.trim()) || (safeRoot ? path.basename(safeRoot) : 'Unknown Project');
|
||||
|
||||
// ── package.json ─────────────────────────────────────────────────────────
|
||||
let description = '';
|
||||
let pkgJson: any = null;
|
||||
const pkgPath = safeRoot ? path.join(safeRoot, 'package.json') : '';
|
||||
if (pkgPath && fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (typeof pkgJson?.description === 'string') description = pkgJson.description.trim();
|
||||
} catch (e: any) {
|
||||
logError('projectArchitecture: package.json parse failed.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime / framework fingerprint ─────────────────────────────────────
|
||||
const runtimes: string[] = [];
|
||||
if (safeRoot && fs.existsSync(path.join(safeRoot, 'tsconfig.json'))) runtimes.push('TypeScript');
|
||||
if (pkgJson) {
|
||||
runtimes.push('Node.js');
|
||||
const deps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) } as Record<string, string>;
|
||||
if (deps['@types/vscode'] || pkgJson.engines?.vscode) runtimes.push('VS Code Extension');
|
||||
if (deps['react']) runtimes.push('React');
|
||||
if (deps['next']) runtimes.push('Next.js');
|
||||
if (deps['express'] || deps['fastify']) runtimes.push('HTTP server');
|
||||
if (deps['@anthropic-ai/sdk']) runtimes.push('Anthropic SDK');
|
||||
if (deps['openai']) runtimes.push('OpenAI SDK');
|
||||
if (deps['@lmstudio/sdk']) runtimes.push('LM Studio SDK');
|
||||
}
|
||||
if (safeRoot && fs.existsSync(path.join(safeRoot, 'pyproject.toml'))) runtimes.push('Python');
|
||||
if (safeRoot && fs.existsSync(path.join(safeRoot, 'Cargo.toml'))) runtimes.push('Rust');
|
||||
if (safeRoot && fs.existsSync(path.join(safeRoot, 'go.mod'))) runtimes.push('Go');
|
||||
|
||||
// ── Main modules (top-level code directories) ───────────────────────────
|
||||
const mainModules: ArchitectureScanResult['mainModules'] = [];
|
||||
if (safeRoot) {
|
||||
for (const candidate of CODE_DIRS) {
|
||||
const dirAbs = path.join(safeRoot, candidate);
|
||||
if (!_isDir(dirAbs)) continue;
|
||||
const entries = _readDirSafe(dirAbs);
|
||||
const fileCount = entries.filter((e) => _isFileLike(path.join(dirAbs, e))).length;
|
||||
const subDirs = entries.filter((e) => _isDir(path.join(dirAbs, e)));
|
||||
const desc = _describeModule(candidate, fileCount, subDirs);
|
||||
mainModules.push({ dir: candidate, description: desc });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Important files at the root ─────────────────────────────────────────
|
||||
const importantFiles: string[] = [];
|
||||
if (safeRoot) {
|
||||
for (const f of ROOT_IMPORTANT) {
|
||||
if (fs.existsSync(path.join(safeRoot, f))) importantFiles.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// Signature: hash of the structural inputs only. We do NOT hash file
|
||||
// *contents* — the goal is "did the shape of the project change" so the
|
||||
// watcher doesn't re-render the doc for every keystroke in a TS file.
|
||||
const signature = _hashSignature({
|
||||
name,
|
||||
runtimes,
|
||||
mainModules: mainModules.map((m) => `${m.dir}|${m.description}`),
|
||||
importantFiles,
|
||||
pkgVersion: pkgJson?.version || '',
|
||||
pkgDeps: pkgJson ? Object.keys({ ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) }).sort().join(',') : '',
|
||||
});
|
||||
|
||||
return {
|
||||
projectName: name,
|
||||
projectRoot: safeRoot,
|
||||
description,
|
||||
runtimes,
|
||||
mainModules,
|
||||
importantFiles,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
function _describeModule(dir: string, fileCount: number, subDirs: string[]): string {
|
||||
const subSummary = subDirs.length > 0
|
||||
? ` — ${subDirs.slice(0, 6).join(', ')}${subDirs.length > 6 ? `, +${subDirs.length - 6} more` : ''}`
|
||||
: '';
|
||||
const known: Record<string, string> = {
|
||||
src: 'Source code',
|
||||
media: 'Webview assets (HTML/CSS/JS)',
|
||||
core_py: 'Python utilities',
|
||||
tests: 'Test suite',
|
||||
lib: 'Library code',
|
||||
app: 'Application entry',
|
||||
apps: 'Application bundles',
|
||||
packages: 'Monorepo packages',
|
||||
};
|
||||
const label = known[dir] || 'Module';
|
||||
return `${label} (${fileCount} files${subSummary})`;
|
||||
}
|
||||
|
||||
function _isDir(p: string): boolean {
|
||||
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
||||
}
|
||||
function _isFileLike(p: string): boolean {
|
||||
try { return fs.statSync(p).isFile(); } catch { return false; }
|
||||
}
|
||||
function _readDirSafe(p: string): string[] {
|
||||
try {
|
||||
// Skip hidden + heavy noise dirs so the listing reads usefully.
|
||||
return fs.readdirSync(p).filter((e) => !e.startsWith('.') && e !== 'node_modules' && e !== 'out' && e !== 'dist' && e !== '__pycache__');
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function _hashSignature(obj: unknown): string {
|
||||
return crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build or refresh the architecture doc. Idempotent:
|
||||
* • If the file doesn't exist: scaffold full doc with auto + user-owned blocks.
|
||||
* • If it exists: rewrite only the auto-managed block; preserve everything else.
|
||||
*/
|
||||
export function buildOrRefreshArchitectureDoc(
|
||||
projectRoot: string,
|
||||
projectName?: string,
|
||||
nowIso: string = new Date().toISOString()
|
||||
): BuildResult {
|
||||
const scan = scanProject(projectRoot, projectName);
|
||||
const docPath = architectureDocPathFor(projectRoot);
|
||||
const docDir = path.dirname(docPath);
|
||||
try {
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
} catch (e: any) {
|
||||
logError('projectArchitecture: mkdir failed.', { docDir, error: e?.message ?? String(e) });
|
||||
}
|
||||
|
||||
const autoBlock = _renderAutoBlock(scan, nowIso);
|
||||
|
||||
if (!fs.existsSync(docPath)) {
|
||||
const full = _renderFullDoc(scan, autoBlock);
|
||||
fs.writeFileSync(docPath, full, 'utf8');
|
||||
logInfo('projectArchitecture: created.', { docPath, signature: scan.signature });
|
||||
return { docPath, created: true, scan };
|
||||
}
|
||||
|
||||
// In-place refresh: rewrite the auto-managed block, keep user-owned sections.
|
||||
const existing = fs.readFileSync(docPath, 'utf8');
|
||||
const replaced = _replaceAutoBlock(existing, autoBlock);
|
||||
if (replaced !== existing) {
|
||||
fs.writeFileSync(docPath, replaced, 'utf8');
|
||||
logInfo('projectArchitecture: refreshed.', { docPath, signature: scan.signature });
|
||||
}
|
||||
return { docPath, created: false, scan };
|
||||
}
|
||||
|
||||
function _renderAutoBlock(scan: ArchitectureScanResult, nowIso: string): string {
|
||||
const modules = scan.mainModules.length > 0
|
||||
? scan.mainModules.map((m) => `- \`${m.dir}/\` — ${m.description}`).join('\n')
|
||||
: '_(no top-level code directories detected)_';
|
||||
const importantFiles = scan.importantFiles.length > 0
|
||||
? scan.importantFiles.map((f) => `- \`${f}\``).join('\n')
|
||||
: '_(none detected)_';
|
||||
const runtimes = scan.runtimes.length > 0 ? scan.runtimes.join(', ') : '_(unknown)_';
|
||||
return [
|
||||
AUTO_START,
|
||||
'## Project Name',
|
||||
scan.projectName,
|
||||
'',
|
||||
'## Project Root',
|
||||
scan.projectRoot || '_(not set)_',
|
||||
'',
|
||||
'## Description',
|
||||
scan.description || '_(no package.json description)_',
|
||||
'',
|
||||
'## Runtime / Stack',
|
||||
runtimes,
|
||||
'',
|
||||
'## Main Modules',
|
||||
modules,
|
||||
'',
|
||||
'## Important Files',
|
||||
importantFiles,
|
||||
'',
|
||||
`_Last auto-scan: ${nowIso}_`,
|
||||
AUTO_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _renderFullDoc(scan: ArchitectureScanResult, autoBlock: string): string {
|
||||
// User-owned sections start as placeholders so first-time activation gives
|
||||
// the user a clear "fill these in" surface without confusing the model.
|
||||
return [
|
||||
`# ${scan.projectName} — Project Architecture Context`,
|
||||
'',
|
||||
'> Auto-managed sections (between the AUTO markers) are rewritten by Astra on every refresh.',
|
||||
'> The rest is yours — Astra never touches it once this file exists.',
|
||||
'',
|
||||
autoBlock,
|
||||
'',
|
||||
'## Purpose',
|
||||
'_TODO: 이 프로젝트가 해결하려는 문제를 1–3문장으로._',
|
||||
'',
|
||||
'## Key Workflows',
|
||||
'_TODO: 사용자/시스템의 주요 흐름 (예: 입력 → context assembly → model 호출 → action)._',
|
||||
'',
|
||||
'## Current Constraints',
|
||||
'_TODO: 의도된 제약 (local-first, offline, 특정 API 의존 등)._',
|
||||
'',
|
||||
'## Known Risks',
|
||||
'_TODO: 알려진 위험/디버깅 함정._',
|
||||
'',
|
||||
'## Active Decisions',
|
||||
'_TODO: 살아 있는 ADR/원칙 (e.g. "기록은 markdown으로", "agent별 model override 우선")._',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _replaceAutoBlock(existing: string, autoBlock: string): string {
|
||||
const startIdx = existing.indexOf(AUTO_START);
|
||||
const endIdx = existing.indexOf(AUTO_END);
|
||||
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
||||
// No marker pair (likely an older file or hand-edited). Prepend the new
|
||||
// auto block at the top so refreshes never silently lose the scan.
|
||||
return `${autoBlock}\n\n${existing}`;
|
||||
}
|
||||
const before = existing.slice(0, startIdx);
|
||||
const after = existing.slice(endIdx + AUTO_END.length);
|
||||
return `${before}${autoBlock}${after}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the architecture doc, returning the trimmed content suitable for
|
||||
* injection into a prompt. Returns empty string if the file can't be read.
|
||||
*
|
||||
* Truncation strategy: try to keep the most decision-relevant sections —
|
||||
* Purpose, Main Modules, Key Workflows, Current Constraints, Known Risks,
|
||||
* Active Decisions — and drop the long auto-listing of files first.
|
||||
*/
|
||||
export function readArchitectureForPrompt(docPath: string, maxChars: number = 8000): string {
|
||||
if (!docPath || !fs.existsSync(docPath)) return '';
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(docPath, 'utf8');
|
||||
} catch (e: any) {
|
||||
logError('projectArchitecture: read failed.', { docPath, error: e?.message ?? String(e) });
|
||||
return '';
|
||||
}
|
||||
if (raw.length <= maxChars) return raw;
|
||||
|
||||
// Section-aware trim: parse `## ` headers, prioritise the high-signal
|
||||
// sections, drop the rest until we fit. Important Files is the longest
|
||||
// auto section so it gets dropped first.
|
||||
const sections = _splitSections(raw);
|
||||
const priority = [
|
||||
'Purpose',
|
||||
'Project Name',
|
||||
'Description',
|
||||
'Active Decisions',
|
||||
'Current Constraints',
|
||||
'Known Risks',
|
||||
'Key Workflows',
|
||||
'Main Modules',
|
||||
'Runtime / Stack',
|
||||
'Project Root',
|
||||
'Important Files', // drop first
|
||||
];
|
||||
sections.sort((a, b) => {
|
||||
const ai = priority.indexOf(a.title); const bi = priority.indexOf(b.title);
|
||||
const aw = ai === -1 ? 999 : ai;
|
||||
const bw = bi === -1 ? 999 : bi;
|
||||
return aw - bw;
|
||||
});
|
||||
const out: string[] = [sections.find((s) => s.title === '__HEADER__')?.body || ''];
|
||||
let used = out[0].length;
|
||||
for (const sec of sections) {
|
||||
if (sec.title === '__HEADER__') continue;
|
||||
const block = `\n\n## ${sec.title}\n${sec.body}`;
|
||||
if (used + block.length > maxChars) continue;
|
||||
out.push(block);
|
||||
used += block.length;
|
||||
}
|
||||
const trimmed = out.join('');
|
||||
return trimmed.length < raw.length
|
||||
? `${trimmed}\n\n_(architecture doc truncated to fit context budget)_`
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function _splitSections(raw: string): { title: string; body: string }[] {
|
||||
const lines = raw.split('\n');
|
||||
const sections: { title: string; body: string }[] = [];
|
||||
let currentTitle = '__HEADER__';
|
||||
let currentBody: string[] = [];
|
||||
for (const line of lines) {
|
||||
const m = /^##\s+(.+)$/.exec(line);
|
||||
if (m) {
|
||||
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
||||
currentTitle = m[1].trim();
|
||||
currentBody = [];
|
||||
} else {
|
||||
currentBody.push(line);
|
||||
}
|
||||
}
|
||||
sections.push({ title: currentTitle, body: currentBody.join('\n').trim() });
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the doc content for injection into the system prompt. Includes a
|
||||
* minimal preamble so the model knows what the block is and treats it as
|
||||
* authoritative project ground truth (not just background reading).
|
||||
*/
|
||||
export function formatArchitectureContextForPrompt(opts: {
|
||||
projectName: string;
|
||||
docPath: string;
|
||||
lastUpdated?: string;
|
||||
maxChars?: number;
|
||||
}): string {
|
||||
const content = readArchitectureForPrompt(opts.docPath, opts.maxChars ?? 8000);
|
||||
if (!content) return '';
|
||||
const stamp = opts.lastUpdated ? `\nLast updated: ${opts.lastUpdated}` : '';
|
||||
return [
|
||||
'[ACTIVE PROJECT ARCHITECTURE CONTEXT]',
|
||||
`Source: ${opts.docPath}`,
|
||||
`Project: ${opts.projectName}${stamp}`,
|
||||
'Use this as authoritative ground truth about the project structure, constraints, and active decisions. Do not contradict it without flagging the conflict.',
|
||||
'---',
|
||||
content,
|
||||
'---',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Project-intent detection from a chat message.
|
||||
*
|
||||
* Goal: when the user says "나 ConnectAI 프로젝트 진행할 거야" (or similar),
|
||||
* spot the intent + project handle so the sidebar can activate Project Mode
|
||||
* and auto-attach the architecture doc.
|
||||
*
|
||||
* Design philosophy:
|
||||
* - Heuristic only. No LLM call. This is a routing decision, not a chat
|
||||
* reply — false positives are cheap to correct (a chip the user can detach)
|
||||
* but a 200 ms latency on every message would be unacceptable.
|
||||
* - Multi-modal: pick up either an absolute project path OR a project NAME
|
||||
* that matches an already-registered project. We avoid inventing brand
|
||||
* new projects from arbitrary noun extraction — that produces too much
|
||||
* noise.
|
||||
* - Bilingual: Korean phrasing is the primary surface, English second.
|
||||
*/
|
||||
|
||||
/** A registered project the detector can match a name against. */
|
||||
export interface KnownProject {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
/** Optional aliases (lowercased) the user might say instead of the name. */
|
||||
aliases?: string[];
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
/** Project entry the message refers to. */
|
||||
project: KnownProject;
|
||||
/** How we matched it — surfaced in logs so we can tune the regexes. */
|
||||
via: 'path' | 'name' | 'alias';
|
||||
/** The text fragment that triggered the match (for debugging). */
|
||||
matchedText: string;
|
||||
}
|
||||
|
||||
// Korean activation verbs that strongly imply "start / continue working on X".
|
||||
// We keep this small and high-precision rather than trying to enumerate every
|
||||
// phrasing — a missed match just means the user has to click the activate chip.
|
||||
const KO_INTENT_PATTERNS: RegExp[] = [
|
||||
/(?:나|이제|오늘은|이번엔)?\s*([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)?\s*(?:진행|작업|시작|할\s*거야|볼\s*거야|볼게|하자|시작하자)/u,
|
||||
/([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)\s*(?:열어|열자|확인)/u,
|
||||
];
|
||||
|
||||
// English equivalents — same precision-first stance.
|
||||
const EN_INTENT_PATTERNS: RegExp[] = [
|
||||
/\b(?:i'?m\s+working\s+on|let'?s\s+work\s+on|switching\s+to|i\s+want\s+to\s+work\s+on)\s+(?:the\s+)?([\w\-\.]+)\s*(?:project)?/i,
|
||||
/\bopen\s+(?:the\s+)?([\w\-\.]+)\s+project\b/i,
|
||||
];
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
// High-frequency Korean particles/verbs that show up where the regex
|
||||
// greedily captures a "noun" but shouldn't be treated as a project handle.
|
||||
'나', '이제', '오늘은', '이번엔', '나도', '나는',
|
||||
// English filler.
|
||||
'the', 'this', 'that', 'my', 'your', 'a',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Try to detect a project handle in `text` and resolve it against the list of
|
||||
* `known` projects. Returns `null` when no high-confidence match is found.
|
||||
*/
|
||||
export function detectProjectIntent(text: string, known: KnownProject[]): DetectionResult | null {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// 1) Direct absolute path (highest confidence). We also accept ~-prefixed
|
||||
// paths because the chat history is full of them. The path doesn't have
|
||||
// to match an existing project — sidebarProvider handles ephemeral
|
||||
// project creation when needed.
|
||||
const pathMatch = _matchPath(trimmed);
|
||||
if (pathMatch) {
|
||||
const exact = known.find((k) => k.projectRoot && _samePath(k.projectRoot, pathMatch));
|
||||
if (exact) return { project: exact, via: 'path', matchedText: pathMatch };
|
||||
// Synthesise an ephemeral entry — caller decides whether to materialise it.
|
||||
return {
|
||||
project: {
|
||||
projectId: _slugify(pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch),
|
||||
projectName: pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch,
|
||||
projectRoot: pathMatch,
|
||||
},
|
||||
via: 'path',
|
||||
matchedText: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Phrase-based extraction → match against known project names/aliases.
|
||||
// We require an intent pattern AND a known-name match: this rules out
|
||||
// "나는 글을 쓸 거야" → it has the verb but no project handle.
|
||||
const candidates: string[] = [];
|
||||
for (const re of KO_INTENT_PATTERNS) {
|
||||
const m = re.exec(trimmed);
|
||||
if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]);
|
||||
}
|
||||
for (const re of EN_INTENT_PATTERNS) {
|
||||
const m = re.exec(trimmed);
|
||||
if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const hit = _findKnown(known, candidate);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _matchPath(text: string): string | null {
|
||||
// Absolute POSIX path (including macOS volumes) or Windows drive path.
|
||||
// We're permissive on what characters can appear — anything quoted or
|
||||
// surrounded by whitespace counts.
|
||||
const macVol = /\/Volumes\/[^\s`'"<>]+/;
|
||||
const posix = /(?:^|\s)(\/[^\s`'"<>]+)/;
|
||||
const win = /[A-Za-z]:[\\/][^\s`'"<>]+/;
|
||||
return (text.match(macVol) || [])[0]
|
||||
|| (text.match(win) || [])[0]
|
||||
|| ((): string | null => {
|
||||
const m = posix.exec(text);
|
||||
return m ? m[1] : null;
|
||||
})();
|
||||
}
|
||||
|
||||
function _samePath(a: string, b: string): boolean {
|
||||
return a.replace(/[\\/]+$/, '').toLowerCase() === b.replace(/[\\/]+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function _findKnown(known: KnownProject[], handle: string): DetectionResult | null {
|
||||
const needle = _slugify(handle);
|
||||
if (!needle) return null;
|
||||
for (const k of known) {
|
||||
if (_slugify(k.projectName) === needle) {
|
||||
return { project: k, via: 'name', matchedText: handle };
|
||||
}
|
||||
for (const alias of k.aliases ?? []) {
|
||||
if (_slugify(alias) === needle) {
|
||||
return { project: k, via: 'alias', matchedText: handle };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same slug logic the chronicle module uses — lowercase, non-word→hyphen. */
|
||||
function _slugify(s: string): string {
|
||||
return (s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
@@ -12,6 +12,20 @@ export interface ProjectProfile {
|
||||
detailLevel: ChronicleDetailLevel;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// ── Project Architecture Context (Feature 2) ───────────────────────────────
|
||||
/** Absolute path to the auto-generated architecture markdown. */
|
||||
architectureDocPath?: string;
|
||||
/** When true, the architecture doc is auto-attached to every prompt. */
|
||||
architectureAutoAttach?: boolean;
|
||||
/** When true, file changes under projectRoot trigger a debounced refresh. */
|
||||
architectureAutoUpdate?: boolean;
|
||||
/** ISO timestamp of the last (auto or manual) refresh. */
|
||||
architectureLastUpdated?: string;
|
||||
/**
|
||||
* Cheap hash of the inputs used by the last scan (package.json + top-level tree).
|
||||
* Used by the file watcher to skip no-op regenerations.
|
||||
*/
|
||||
architectureLastScanSignature?: string;
|
||||
}
|
||||
|
||||
export interface QuestionRecord {
|
||||
|
||||
Reference in New Issue
Block a user