Bump version to 2.35.0: Knowledge Resilience & Standardization Milestone.

This commit is contained in:
g1nation
2026-05-02 13:18:07 +09:00
parent 11f25534d0
commit 8bb8c065d7
27 changed files with 2452 additions and 14 deletions
@@ -0,0 +1,50 @@
import { ProjectProfile } from './types';
export function buildProjectChronicleGuardContext(project: ProjectProfile | null): string {
const hasUsableProject = !!project?.recordRoot?.trim();
const projectLines = project ? [
`Project selection status: selected`,
`Active record project: ${project.projectName}`,
`Project root: ${project.projectRoot || 'Not set'}`,
`Record root: ${project.recordRoot}`,
`Core purpose: ${project.corePurpose || project.description || 'Not captured yet.'}`,
`Record detail level: ${project.detailLevel}`
] : [
'Project selection status: not selected',
'No active record project is selected. Before writing records, ask the user to select or create one.'
];
return [
...projectLines,
'',
'This guard is active for project ideas, feature requests, architecture proposals, implementation planning, and design decisions.',
'',
'Required response order for new ideas or feature requests:',
'1. Request summary.',
'2. Inferred user intent.',
'3. Project record target check. If no project is selected, ask whether to use an existing project, create a new project, or skip recording.',
'4. Record path check. If no record root is available, say a Markdown record path is required before writing files.',
'5. Ask only 1 to 3 blocking questions.',
'6. For every question, include "Question reason" explaining why it changes storage, scope, dependencies, or implementation direction.',
'7. Direction review focused on project fit and dependency risk.',
'8. Recommend a low-dependency MVP first.',
'9. Put Vector DB, relational DB, knowledge graph, semantic search, and complex automation only under "Later expansion" unless the user explicitly asks for them now.',
'10. End with "Candidate records for this discussion" and list planning, discussions, decisions, development, bugs, or retrospectives paths as candidates.',
'',
'Decision policy:',
'- Do not mark a decision as accepted until the user confirms it.',
'- Before confirmation, call decisions "candidates" or "pending".',
'- Prefer "reduced adoption" when the idea is useful but too large for the MVP.',
'',
'Tone and scope:',
'- Be practical and plain-spoken.',
'- Avoid grand phrases like advanced cognitive architecture, compounding knowledge, perfect graph, or ultimate knowledge distiller.',
'- When the user wants low dependency, keep the first proposal to Markdown, JSON, local files, and explicit user save actions.',
'- Do not jump directly to large architectures. Narrow direction before expanding.',
'',
hasUsableProject
? 'The current project and record root are available, so candidate record paths may use this project.'
: 'The project or record root is missing, so candidate records must be described but not written.',
'Do not claim a Markdown file was written unless a tool or explicit sidebar action actually wrote it.'
].join('\n');
}
+189
View File
@@ -0,0 +1,189 @@
import * as fs from 'fs';
import * as path from 'path';
import { MarkdownFileWriter } from './markdownFileWriter';
import {
BugRecord,
ChronicleRecordEntry,
ChronicleWriteResult,
DecisionRecord,
DevelopmentLog,
DiscussionRecord,
PlanningDocument,
ProjectProfile,
RetrospectiveRecord
} from './types';
import {
renderBugRecord,
renderDecisionRecord,
renderDevelopmentLog,
renderDiscussionRecord,
renderPlanningDocument,
renderProjectProfile,
renderProjectReadme,
renderRetrospective,
renderTimelineSeed
} from './templates';
export * from './types';
export * from './guardPrompt';
const sectionDirs = ['planning', 'discussions', 'decisions', 'development', 'bugs', 'retrospectives'];
export class ProjectChronicleManager {
private readonly writer = new MarkdownFileWriter();
public ensureProject(profile: ProjectProfile): void {
if (!profile.recordRoot || !profile.recordRoot.trim()) {
throw new Error('Record root is required before writing chronicle documents.');
}
this.writer.ensureDir(profile.recordRoot);
for (const dir of sectionDirs) {
this.writer.ensureDir(path.join(profile.recordRoot, dir));
}
this.writeSeedFile(path.join(profile.recordRoot, 'README.md'), renderProjectReadme(profile));
this.writeSeedFile(path.join(profile.recordRoot, 'project-profile.md'), renderProjectProfile(profile));
this.writeSeedFile(path.join(profile.recordRoot, 'timeline.md'), renderTimelineSeed(profile));
this.writeProjectConfig(profile);
}
public writePlanning(profile: ProjectProfile, doc: PlanningDocument): ChronicleWriteResult {
this.ensureProject(profile);
const date = this.datePart(doc.createdAt);
const fileName = `${date}_${this.slug(doc.featureName)}.md`;
return this.write(profile, 'planning', fileName, renderPlanningDocument(doc));
}
public writeDiscussion(profile: ProjectProfile, record: DiscussionRecord): ChronicleWriteResult {
this.ensureProject(profile);
const date = this.datePart(record.createdAt);
const fileName = `${date}_${this.slug(record.title)}.md`;
return this.write(profile, 'discussions', fileName, renderDiscussionRecord(record));
}
public writeDecision(profile: ProjectProfile, record: DecisionRecord, adrNumber: number): ChronicleWriteResult {
this.ensureProject(profile);
const padded = String(adrNumber).padStart(4, '0');
const fileName = `ADR-${padded}-${this.slug(record.title)}.md`;
return this.write(profile, 'decisions', fileName, renderDecisionRecord(record));
}
public writeDevelopmentLog(profile: ProjectProfile, log: DevelopmentLog): ChronicleWriteResult {
this.ensureProject(profile);
const date = this.datePart(log.createdAt);
const fileName = `${date}_${this.slug(log.featureName)}_implementation.md`;
return this.write(profile, 'development', fileName, renderDevelopmentLog(log));
}
public writeBug(profile: ProjectProfile, record: BugRecord, bugNumber: number): ChronicleWriteResult {
this.ensureProject(profile);
const padded = String(bugNumber).padStart(4, '0');
const fileName = `BUG-${padded}-${this.slug(record.title)}.md`;
return this.write(profile, 'bugs', fileName, renderBugRecord(record));
}
public writeRetrospective(profile: ProjectProfile, record: RetrospectiveRecord): ChronicleWriteResult {
this.ensureProject(profile);
const date = this.datePart(record.createdAt);
const fileName = `${date}_${this.slug(record.title)}.md`;
return this.write(profile, 'retrospectives', fileName, renderRetrospective(record));
}
public appendTimeline(profile: ProjectProfile, lines: string[], createdAt: string = new Date().toISOString()): void {
this.ensureProject(profile);
const timelinePath = path.join(profile.recordRoot, 'timeline.md');
const markdown = [
'',
`## ${this.datePart(createdAt)}`,
...lines.map(line => `- ${line}`)
].join('\n');
this.writer.appendMarkdown(timelinePath, markdown);
}
public nextAdrNumber(profile: ProjectProfile): number {
return this.nextNumber(path.join(profile.recordRoot, 'decisions'), /^ADR-(\d+)/);
}
public nextBugNumber(profile: ProjectProfile): number {
return this.nextNumber(path.join(profile.recordRoot, 'bugs'), /^BUG-(\d+)/);
}
public listRecords(profile: ProjectProfile): ChronicleRecordEntry[] {
this.ensureProject(profile);
const records: ChronicleRecordEntry[] = [];
for (const section of sectionDirs) {
const sectionPath = path.join(profile.recordRoot, section);
if (!fs.existsSync(sectionPath)) continue;
for (const fileName of fs.readdirSync(sectionPath)) {
if (!fileName.endsWith('.md')) continue;
const filePath = path.join(sectionPath, fileName);
const stat = fs.statSync(filePath);
records.push({
section,
fileName,
filePath,
relativePath: path.relative(profile.recordRoot, filePath),
updatedAt: stat.mtimeMs
});
}
}
return records.sort((a, b) => b.updatedAt - a.updatedAt);
}
private write(profile: ProjectProfile, section: string, fileName: string, markdown: string): ChronicleWriteResult {
const filePath = this.writer.writeMarkdown(path.join(profile.recordRoot, section, fileName), markdown);
return {
filePath,
relativePath: path.relative(profile.recordRoot, filePath)
};
}
private writeSeedFile(filePath: string, content: string): void {
if (!fs.existsSync(filePath)) {
this.writer.writeMarkdown(filePath, content);
}
}
private writeProjectConfig(profile: ProjectProfile): void {
const configPath = path.join(profile.recordRoot, 'chronicle.config.json');
const config = {
projectId: profile.projectId,
projectName: profile.projectName,
projectRoot: profile.projectRoot || '',
recordRoot: profile.recordRoot,
description: profile.description || '',
corePurpose: profile.corePurpose || '',
detailLevel: profile.detailLevel,
createdAt: profile.createdAt,
updatedAt: new Date().toISOString()
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}
private nextNumber(dir: string, pattern: RegExp): number {
if (!fs.existsSync(dir)) return 1;
const max = fs.readdirSync(dir).reduce((current, fileName) => {
const match = fileName.match(pattern);
if (!match) return current;
return Math.max(current, Number(match[1]) || 0);
}, 0);
return max + 1;
}
private slug(value: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60);
return slug || 'record';
}
private datePart(iso?: string): string {
return (iso || new Date().toISOString()).slice(0, 10);
}
}
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import * as path from 'path';
export class MarkdownFileWriter {
public ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
public writeMarkdown(filePath: string, content: string): string {
this.ensureDir(path.dirname(filePath));
const finalPath = this.getAvailablePath(filePath);
fs.writeFileSync(finalPath, this.normalize(content), 'utf8');
return finalPath;
}
public appendMarkdown(filePath: string, content: string): void {
this.ensureDir(path.dirname(filePath));
fs.appendFileSync(filePath, this.normalize(content), 'utf8');
}
private getAvailablePath(filePath: string): string {
if (!fs.existsSync(filePath)) return filePath;
const dir = path.dirname(filePath);
const ext = path.extname(filePath);
const base = path.basename(filePath, ext);
let index = 2;
while (true) {
const candidate = path.join(dir, `${base}-${index}${ext}`);
if (!fs.existsSync(candidate)) return candidate;
index += 1;
}
}
private normalize(content: string): string {
return content.replace(/\r\n/g, '\n').trimEnd() + '\n';
}
}
+258
View File
@@ -0,0 +1,258 @@
import {
BugRecord,
DecisionRecord,
DevelopmentLog,
DiscussionRecord,
PlanningDocument,
ProjectProfile,
RetrospectiveRecord
} from './types';
const list = (items: string[] | undefined, fallback: string = 'Not captured yet.') => {
if (!items || items.length === 0) return fallback;
return items.map(item => `- ${item}`).join('\n');
};
const dateOnly = (iso?: string) => (iso || new Date().toISOString()).slice(0, 10);
export function renderProjectReadme(profile: ProjectProfile): string {
return [
`# ${profile.projectName} Chronicle Records`,
'',
'## Project',
`- ID: ${profile.projectId}`,
`- Root: ${profile.projectRoot || 'Not set'}`,
`- Record root: ${profile.recordRoot}`,
`- Detail level: ${profile.detailLevel}`,
'',
'## Purpose',
profile.corePurpose || profile.description || 'Not captured yet.',
'',
'## Folders',
'- `planning/`',
'- `discussions/`',
'- `decisions/`',
'- `development/`',
'- `bugs/`',
'- `retrospectives/`'
].join('\n');
}
export function renderProjectProfile(profile: ProjectProfile): string {
return [
'# Project Profile',
'',
'## Project Name',
profile.projectName,
'',
'## Description',
profile.description || 'Not captured yet.',
'',
'## Project Root',
profile.projectRoot || 'Not set',
'',
'## Record Root',
profile.recordRoot,
'',
'## Core Purpose',
profile.corePurpose || 'Not captured yet.',
'',
'## Target Users',
list(profile.targetUsers),
'',
'## Avoid Directions',
list(profile.avoidDirections),
'',
'## Record Detail Level',
profile.detailLevel,
'',
'## Created',
profile.createdAt,
'',
'## Updated',
profile.updatedAt
].join('\n');
}
export function renderTimelineSeed(profile: ProjectProfile): string {
return [
'# Project Timeline',
'',
`## ${dateOnly(profile.createdAt)}`,
`- Project Chronicle record folder initialized for ${profile.projectName}.`
].join('\n');
}
export function renderPlanningDocument(doc: PlanningDocument): string {
return [
`# Feature Plan: ${doc.featureName}`,
'',
'## 1. Feature Name',
doc.featureName,
'',
'## 2. Reason',
doc.purpose,
'',
'## 3. Original User Request',
doc.sourceRequest || 'Not captured yet.',
'',
'## 4. Interpreted User Intent',
doc.userIntent,
'',
'## 5. Background',
doc.background,
'',
'## 6. Scope',
list(doc.scope),
'',
'## 7. Out Of Scope',
list(doc.outOfScope),
'',
'## 8. Development Direction',
doc.developmentDirection,
'',
'## 9. Dependency Strategy',
doc.dependencyStrategy,
'',
'## 10. Expected Value',
doc.expectedValue,
'',
'## 11. Success Criteria',
list(doc.successCriteria),
'',
'## 12. Developer Instruction',
doc.developerInstruction
].join('\n');
}
export function renderDiscussionRecord(record: DiscussionRecord): string {
const questions = record.questions.length
? record.questions.map((q, index) => [
`### Question ${index + 1}`,
q.question,
'',
'### Reason',
q.reason,
'',
'### Expected Information',
q.expectedInformation,
'',
'### Impact On Decision',
q.impactOnDecision,
'',
q.userAnswer ? `### User Answer\n${q.userAnswer}` : '',
q.result ? `### Result\n${q.result}` : ''
].filter(Boolean).join('\n')).join('\n\n')
: 'No explicit question was captured.';
return [
`# Discussion: ${record.title}`,
'',
'## User Request Summary',
record.userRequest,
'',
'## Interpreted Intent',
record.interpretedIntent,
'',
'## Questions',
questions,
'',
'## Main Discussion',
list(record.discussions),
'',
'## Decisions',
record.decisions.length
? record.decisions.map(decision => `- ${decision.title}: ${decision.decision}`).join('\n')
: 'No decisions captured yet.'
].join('\n');
}
export function renderDecisionRecord(record: DecisionRecord): string {
return [
`# ADR: ${record.title}`,
'',
'## Status',
record.status,
'',
'## Context',
record.context,
'',
'## Decision',
record.decision,
'',
'## Reason',
record.reason,
'',
'## Alternatives',
list(record.alternatives),
'',
'## Consequences',
list(record.consequences)
].join('\n');
}
export function renderDevelopmentLog(log: DevelopmentLog): string {
return [
`# Development Log: ${log.featureName}`,
'',
'## Purpose',
log.purpose,
'',
'## Implementation Summary',
log.implementationSummary,
'',
'## Architecture',
log.architecture,
'',
'## Changed Files',
list(log.changedFiles),
'',
'## Dependency Notes',
log.dependencyNotes,
'',
'## Bugs',
log.bugs.length ? log.bugs.map(bug => `- ${bug.title}: ${bug.symptom}`).join('\n') : 'No bugs recorded.',
'',
'## Lessons',
list(log.lessons)
].join('\n');
}
export function renderBugRecord(record: BugRecord): string {
return [
`# Bug: ${record.title}`,
'',
'## Date',
dateOnly(record.createdAt),
'',
'## Symptom',
record.symptom,
'',
'## Cause',
record.cause,
'',
'## Fix',
record.fix,
'',
'## Prevention',
record.prevention
].join('\n');
}
export function renderRetrospective(record: RetrospectiveRecord): string {
return [
`# Retrospective: ${record.title}`,
'',
'## Summary',
record.summary,
'',
'## Went Well',
list(record.wentWell),
'',
'## To Improve',
list(record.toImprove),
'',
'## Next Actions',
list(record.nextActions)
].join('\n');
}
+104
View File
@@ -0,0 +1,104 @@
export type ChronicleDetailLevel = 'simple' | 'standard' | 'detailed';
export interface ProjectProfile {
projectId: string;
projectName: string;
projectRoot?: string;
recordRoot: string;
description?: string;
corePurpose?: string;
targetUsers?: string[];
avoidDirections?: string[];
detailLevel: ChronicleDetailLevel;
createdAt: string;
updatedAt: string;
}
export interface QuestionRecord {
question: string;
reason: string;
expectedInformation: string;
impactOnDecision: string;
userAnswer?: string;
result?: string;
}
export interface DecisionRecord {
title: string;
status: 'accepted' | 'rejected' | 'deferred' | 'changed';
context: string;
decision: string;
reason: string;
alternatives?: string[];
consequences?: string[];
createdAt?: string;
}
export interface PlanningDocument {
featureName: string;
purpose: string;
background: string;
userIntent: string;
scope: string[];
outOfScope: string[];
developmentDirection: string;
dependencyStrategy: string;
expectedValue: string;
successCriteria: string[];
developerInstruction: string;
sourceRequest?: string;
createdAt?: string;
}
export interface DiscussionRecord {
title: string;
userRequest: string;
interpretedIntent: string;
questions: QuestionRecord[];
discussions: string[];
decisions: DecisionRecord[];
createdAt?: string;
}
export interface DevelopmentLog {
featureName: string;
purpose: string;
implementationSummary: string;
architecture: string;
changedFiles: string[];
dependencyNotes: string;
bugs: BugRecord[];
lessons: string[];
createdAt?: string;
}
export interface BugRecord {
title: string;
symptom: string;
cause: string;
fix: string;
prevention: string;
createdAt?: string;
}
export interface RetrospectiveRecord {
title: string;
summary: string;
wentWell: string[];
toImprove: string[];
nextActions: string[];
createdAt?: string;
}
export interface ChronicleWriteResult {
filePath: string;
relativePath: string;
}
export interface ChronicleRecordEntry {
section: string;
fileName: string;
filePath: string;
relativePath: string;
updatedAt: number;
}
+264
View File
@@ -0,0 +1,264 @@
import * as fs from 'fs';
import * as path from 'path';
import { findBrainFiles, summarizeText } from '../utils';
export interface SecondBrainTraceDocument {
title: string;
path: string;
absolutePath: string;
score: number;
excerpt: string;
usedInAnswer: boolean;
usedFor?: string;
excludedReason?: string;
}
export interface SecondBrainTrace {
userQuery: string;
shouldUseSecondBrain: boolean;
secondBrainUsed: boolean;
reason: string;
retrievalQuery: string;
searchedCollections: string[];
retrievedDocuments: SecondBrainTraceDocument[];
groundingScore: number;
}
export function buildSecondBrainTrace(userQuery: string, brainRoot: string, options: {
force?: boolean;
limit?: number;
} = {}): SecondBrainTrace {
const query = userQuery.trim();
const shouldUseSecondBrain = !!options.force || shouldUseBrain(query);
const retrievalQuery = buildRetrievalQuery(query);
const baseTrace: SecondBrainTrace = {
userQuery: query,
shouldUseSecondBrain,
secondBrainUsed: false,
reason: shouldUseSecondBrain
? 'Project-specific or memory-sensitive information may be needed.'
: 'This looks answerable without project-specific Second Brain context.',
retrievalQuery,
searchedCollections: [],
retrievedDocuments: [],
groundingScore: 0
};
if (!shouldUseSecondBrain) return baseTrace;
if (!brainRoot || !fs.existsSync(brainRoot)) {
return {
...baseTrace,
reason: 'Second Brain was requested, but the active brain folder does not exist.'
};
}
const files = findBrainFiles(brainRoot);
const terms = tokenize(retrievalQuery);
const scored = files.map((file) => scoreFile(file, brainRoot, terms))
.filter((doc) => doc.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, options.limit || 5);
const usedDocs = scored.slice(0, Math.min(3, scored.length)).map((doc) => ({
...doc,
usedInAnswer: true,
usedFor: inferUsedFor(doc.excerpt)
}));
const unusedDocs = scored.slice(usedDocs.length).map((doc) => ({
...doc,
usedInAnswer: false,
excludedReason: 'Lower relevance than the documents selected as answer context.'
}));
const retrievedDocuments = [...usedDocs, ...unusedDocs];
const usedCount = retrievedDocuments.filter((doc) => doc.usedInAnswer).length;
return {
...baseTrace,
secondBrainUsed: retrievedDocuments.length > 0,
reason: retrievedDocuments.length > 0
? 'Relevant Markdown notes were found and selected as answer context.'
: 'Second Brain search ran, but no sufficiently relevant Markdown notes were found.',
searchedCollections: inferCollections(retrievedDocuments),
retrievedDocuments,
groundingScore: retrievedDocuments.length === 0
? 0
: Number((usedCount / retrievedDocuments.length).toFixed(2))
};
}
export function renderSecondBrainTraceContext(trace: SecondBrainTrace): string {
if (!trace.shouldUseSecondBrain) {
return [
'[SECOND BRAIN TRACE]',
'Second Brain was not used for this request.',
`Reason: ${trace.reason}`,
'If the user explicitly asks to use Second Brain or asks project-specific memory questions, use it.'
].join('\n');
}
const docs = trace.retrievedDocuments
.filter((doc) => doc.usedInAnswer)
.map((doc) => [
`- ${doc.path}`,
` Score: ${doc.score}`,
` Relevant content: ${doc.excerpt}`
].join('\n'))
.join('\n');
return [
'[SECOND BRAIN TRACE]',
`Second Brain used: ${trace.secondBrainUsed ? 'yes' : 'no'}`,
`Retrieval query: ${trace.retrievalQuery}`,
`Reason: ${trace.reason}`,
docs ? `Selected notes:\n${docs}` : 'Selected notes: none',
'',
'When answering, use only selected notes that are relevant. If these notes influence the answer, mention them in the final reference section.'
].join('\n');
}
export function renderSecondBrainTraceMarkdown(trace: SecondBrainTrace, debug: boolean = false): string {
const usedDocs = trace.retrievedDocuments.filter((doc) => doc.usedInAnswer);
const unusedDocs = trace.retrievedDocuments.filter((doc) => !doc.usedInAnswer);
const usedText = usedDocs.length
? usedDocs.map((doc) => [
`- \`${doc.path}\``,
` - Score: ${doc.score}`,
` - 참고 내용: ${doc.excerpt}`
].join('\n')).join('\n')
: '- 없음';
const unusedText = unusedDocs.length
? unusedDocs.map((doc) => [
`- \`${doc.path}\``,
` - 제외 이유: ${doc.excludedReason || '이번 답변의 핵심 근거로 선택되지 않았습니다.'}`
].join('\n')).join('\n')
: '- 없음';
const sections = [
'',
'## 2nd Brain 사용 여부',
trace.secondBrainUsed ? '사용함' : '사용하지 않음',
'',
'## 이유',
trace.reason,
'',
'## 참고한 2nd Brain 문서',
usedText,
'',
'## 검색했지만 사용하지 않은 문서',
unusedText,
'',
'## 참고 품질',
`- 검색된 노트: ${trace.retrievedDocuments.length}`,
`- 실제 사용된 노트: ${usedDocs.length}`,
`- 답변 근거도: ${trace.groundingScore}`
];
if (debug) {
sections.push(
'',
'## Second Brain Debug JSON',
'```json',
JSON.stringify({
secondBrainUsed: trace.secondBrainUsed,
shouldUseSecondBrain: trace.shouldUseSecondBrain,
retrievalQuery: trace.retrievalQuery,
searchedCollections: trace.searchedCollections,
retrievedDocuments: trace.retrievedDocuments.map((doc) => ({
path: doc.path,
score: doc.score,
usedInAnswer: doc.usedInAnswer,
usedFor: doc.usedFor,
excludedReason: doc.excludedReason
})),
groundingScore: trace.groundingScore
}, null, 2),
'```'
);
}
return sections.join('\n');
}
function shouldUseBrain(query: string): boolean {
const normalized = query.toLowerCase();
return /(second brain|2nd brain|제2뇌|브레인|brain|기억|기록|문서|노트|내가|우리|프로젝트|결정|adr|chronicle|가드|설계 원칙|mvp|제외|왜)/i.test(normalized);
}
function buildRetrievalQuery(query: string): string {
return tokenize(query).slice(0, 16).join(' ');
}
function tokenize(value: string): string[] {
const stopWords = new Set(['그리고', '그런데', '해서', '하는', '있어', 'what', 'how', 'the', 'and', 'for', 'with']);
return value
.toLowerCase()
.split(/[^a-z0-9가-힣_]+/g)
.map((term) => term.trim())
.filter((term) => term.length >= 2 && !stopWords.has(term));
}
function scoreFile(file: string, brainRoot: string, terms: string[]): SecondBrainTraceDocument {
const relative = path.relative(brainRoot, file);
const title = path.basename(file, path.extname(file));
const basename = relative.toLowerCase();
let content = '';
try {
content = fs.readFileSync(file, 'utf8');
} catch {
content = '';
}
const lower = content.toLowerCase();
let score = 0;
for (const term of terms) {
if (basename.includes(term)) score += 4;
const matches = lower.split(term).length - 1;
if (matches > 0) score += Math.min(matches, 6);
}
return {
title,
path: relative,
absolutePath: file,
score: Number((score / Math.max(terms.length, 1)).toFixed(2)),
excerpt: summarizeText(bestExcerpt(content, terms), 420),
usedInAnswer: false
};
}
function bestExcerpt(content: string, terms: string[]): string {
const paragraphs = content
.split(/\n\s*\n/g)
.map((part) => part.replace(/\s+/g, ' ').trim())
.filter(Boolean);
if (paragraphs.length === 0) return '';
let best = paragraphs[0];
let bestScore = -1;
for (const paragraph of paragraphs) {
const lower = paragraph.toLowerCase();
const score = terms.reduce((sum, term) => sum + (lower.includes(term) ? 1 : 0), 0);
if (score > bestScore) {
best = paragraph;
bestScore = score;
}
}
return best;
}
function inferCollections(docs: SecondBrainTraceDocument[]): string[] {
const collections = new Set<string>();
for (const doc of docs) {
const first = doc.path.split(/[\\/]/)[0];
if (first) collections.add(first);
}
return Array.from(collections);
}
function inferUsedFor(excerpt: string): string {
if (/의존|coupl|독립|분리/i.test(excerpt)) return '의존도와 독립 모듈 판단';
if (/markdown|마크다운/i.test(excerpt)) return 'Markdown 기반 저장 방향';
if (/질문|의도|reason/i.test(excerpt)) return '질문 의도와 기록 방식';
if (/mvp|제외|scope/i.test(excerpt)) return 'MVP 범위 판단';
return '프로젝트 고유 맥락 확인';
}