Bump version to 2.35.0: Knowledge Resilience & Standardization Milestone.
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '프로젝트 고유 맥락 확인';
|
||||
}
|
||||
Reference in New Issue
Block a user