@wooojin/forgen 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +89 -14
  4. package/README.md +77 -14
  5. package/README.zh.md +79 -14
  6. package/commands/deep-interview.md +171 -0
  7. package/commands/specify.md +128 -0
  8. package/dist/cli.js +9 -0
  9. package/dist/core/dashboard.d.ts +91 -0
  10. package/dist/core/dashboard.js +385 -0
  11. package/dist/core/doctor.js +151 -21
  12. package/dist/core/drift-score.d.ts +49 -0
  13. package/dist/core/drift-score.js +87 -0
  14. package/dist/core/mcp-config.d.ts +2 -0
  15. package/dist/core/mcp-config.js +6 -1
  16. package/dist/core/paths.d.ts +1 -1
  17. package/dist/core/paths.js +1 -1
  18. package/dist/engine/compound-export.d.ts +41 -0
  19. package/dist/engine/compound-export.js +169 -0
  20. package/dist/engine/compound-loop.js +18 -0
  21. package/dist/engine/solution-matcher.d.ts +23 -0
  22. package/dist/engine/solution-matcher.js +124 -11
  23. package/dist/hooks/context-guard.d.ts +10 -0
  24. package/dist/hooks/context-guard.js +104 -58
  25. package/dist/hooks/db-guard.js +2 -2
  26. package/dist/hooks/hook-config.d.ts +27 -1
  27. package/dist/hooks/hook-config.js +72 -12
  28. package/dist/hooks/intent-classifier.d.ts +0 -2
  29. package/dist/hooks/intent-classifier.js +32 -18
  30. package/dist/hooks/keyword-detector.js +117 -111
  31. package/dist/hooks/notepad-injector.js +2 -2
  32. package/dist/hooks/permission-handler.js +2 -2
  33. package/dist/hooks/post-tool-failure.js +12 -6
  34. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  35. package/dist/hooks/post-tool-handlers.js +14 -11
  36. package/dist/hooks/post-tool-use.d.ts +11 -0
  37. package/dist/hooks/post-tool-use.js +184 -71
  38. package/dist/hooks/pre-compact.d.ts +11 -1
  39. package/dist/hooks/pre-compact.js +112 -37
  40. package/dist/hooks/pre-tool-use.js +86 -56
  41. package/dist/hooks/rate-limiter.js +3 -3
  42. package/dist/hooks/secret-filter.js +2 -2
  43. package/dist/hooks/session-recovery.js +256 -236
  44. package/dist/hooks/shared/hook-response.d.ts +4 -4
  45. package/dist/hooks/shared/hook-response.js +13 -24
  46. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  47. package/dist/hooks/shared/hook-timing.js +64 -0
  48. package/dist/hooks/skill-injector.js +41 -12
  49. package/dist/hooks/slop-detector.js +3 -3
  50. package/dist/hooks/solution-injector.js +224 -197
  51. package/dist/hooks/subagent-tracker.js +2 -2
  52. package/dist/renderer/rule-renderer.js +9 -11
  53. package/package.json +1 -1
  54. package/skills/deep-interview/SKILL.md +166 -0
  55. package/skills/specify/SKILL.md +122 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Forgen — Drift Score (Session Drift Detection)
3
+ *
4
+ * 세션 내 수정 패턴을 추적하여 drift(산만/반복 수정)를 감지.
5
+ * EWMA(Exponentially Weighted Moving Average) 기반 이동평균으로
6
+ * 최근 수정 강도를 측정하고, 임계값 초과 시 경고.
7
+ *
8
+ * Codex 합의: DriftState + evaluateDrift 2개만. 최소 인터페이스.
9
+ */
10
+ const DEFAULTS = {
11
+ alpha: 0.35,
12
+ warningEdits: 15,
13
+ criticalEdits: 30,
14
+ criticalReverts: 2,
15
+ hardCapEdits: 50,
16
+ warningCooldownMs: 5 * 60 * 1000,
17
+ criticalCooldownMs: 10 * 60 * 1000,
18
+ };
19
+ /** EWMA 업데이트 (순수 함수) */
20
+ export function updateEwma(prev, sample, alpha) {
21
+ return alpha * sample + (1 - alpha) * prev;
22
+ }
23
+ /** 새 DriftState 생성 */
24
+ export function createDriftState(sessionId) {
25
+ return {
26
+ sessionId,
27
+ totalEdits: 0,
28
+ totalReverts: 0,
29
+ ewmaEditRate: 0,
30
+ ewmaRevertRate: 0,
31
+ lastWarningAt: 0,
32
+ lastCriticalAt: 0,
33
+ hardCapReached: false,
34
+ };
35
+ }
36
+ /**
37
+ * 도구 호출 이벤트로 drift 상태를 갱신하고 평가 결과를 반환.
38
+ * @param state 현재 상태 (mutate됨)
39
+ * @param isEdit Write/Edit 도구 호출 여부
40
+ * @param isRevert revert 감지 여부
41
+ * @param thresholds 커스텀 임계치 (hook-config에서 로드)
42
+ */
43
+ export function evaluateDrift(state, isEdit, isRevert, thresholds = {}) {
44
+ const t = { ...DEFAULTS, ...thresholds };
45
+ const now = Date.now();
46
+ // Update counters
47
+ if (isEdit)
48
+ state.totalEdits++;
49
+ if (isRevert)
50
+ state.totalReverts++;
51
+ // Update EWMA
52
+ state.ewmaEditRate = updateEwma(state.ewmaEditRate, isEdit ? 1 : 0, t.alpha);
53
+ state.ewmaRevertRate = updateEwma(state.ewmaRevertRate, isRevert ? 1 : 0, t.alpha);
54
+ // Calculate drift score: edit rate 65% + revert rate 35%
55
+ const rawScore = (state.ewmaEditRate * 65) + (state.ewmaRevertRate * 35);
56
+ const score = Math.min(100, Math.max(0, Math.round(rawScore)));
57
+ // Hard cap
58
+ if (state.totalEdits >= t.hardCapEdits) {
59
+ state.hardCapReached = true;
60
+ return {
61
+ level: 'hardcap',
62
+ score: 100,
63
+ message: `[Forgen] ⛔ Session drift hard cap reached (${state.totalEdits} edits). Stop and reassess the approach before continuing.`,
64
+ };
65
+ }
66
+ // Critical: 2+ reverts OR 30+ edits OR score >= 78
67
+ if ((state.totalReverts >= t.criticalReverts || state.totalEdits >= t.criticalEdits || score >= 78) &&
68
+ (now - state.lastCriticalAt > t.criticalCooldownMs)) {
69
+ state.lastCriticalAt = now;
70
+ return {
71
+ level: 'critical',
72
+ score,
73
+ message: `[Forgen] ⚠ High drift detected (score: ${score}, edits: ${state.totalEdits}, reverts: ${state.totalReverts}). Consider stopping to redesign the approach.`,
74
+ };
75
+ }
76
+ // Warning: 15+ edits OR score >= 52
77
+ if ((state.totalEdits >= t.warningEdits || score >= 52) &&
78
+ (now - state.lastWarningAt > t.warningCooldownMs)) {
79
+ state.lastWarningAt = now;
80
+ return {
81
+ level: 'warning',
82
+ score,
83
+ message: `[Forgen] Drift building up (score: ${score}, edits: ${state.totalEdits}). Review your approach if changes feel repetitive.`,
84
+ };
85
+ }
86
+ return { level: 'normal', score, message: null };
87
+ }
@@ -11,6 +11,8 @@ export interface McpServerConfig {
11
11
  command: string;
12
12
  args: string[];
13
13
  env?: Record<string, string>;
14
+ /** HTTP/SSE transport URL (alternative to command+args) */
15
+ url?: string;
14
16
  }
15
17
  /**
16
18
  * 기본 MCP 서버 템플릿 목록 반환
@@ -101,7 +101,12 @@ export async function handleMcp(args) {
101
101
  for (const name of names) {
102
102
  const cfg = installed[name];
103
103
  console.log(` ${name}`);
104
- console.log(` command: ${cfg.command} ${cfg.args.join(' ')}`);
104
+ if (cfg.command) {
105
+ console.log(` command: ${cfg.command} ${(cfg.args ?? []).join(' ')}`);
106
+ }
107
+ else if (cfg.url) {
108
+ console.log(` url: ${cfg.url}`);
109
+ }
105
110
  if (cfg.env && Object.keys(cfg.env).length > 0) {
106
111
  console.log(` env: ${JSON.stringify(cfg.env)}`);
107
112
  }
@@ -74,7 +74,7 @@ export declare const V1_RAW_LOGS_DIR: string;
74
74
  /** @deprecated use GLOBAL_CONFIG */
75
75
  export declare const V1_GLOBAL_CONFIG: string;
76
76
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
77
- export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "ecomode"];
77
+ export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "ecomode", "specify"];
78
78
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
79
79
  export declare function projectDir(cwd: string): string;
80
80
  /** {repo}/.compound/pack.link — 팀 팩 연결 파일 */
@@ -81,7 +81,7 @@ export const V1_GLOBAL_CONFIG = GLOBAL_CONFIG;
81
81
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
82
82
  export const ALL_MODES = [
83
83
  'ralph', 'autopilot', 'ultrawork', 'team', 'pipeline',
84
- 'ccg', 'ralplan', 'deep-interview', 'ecomode',
84
+ 'ccg', 'ralplan', 'deep-interview', 'ecomode', 'specify',
85
85
  ];
86
86
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
87
87
  export function projectDir(cwd) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Forgen — Compound Knowledge Export/Import
3
+ *
4
+ * Provides backup, migration, and sharing of accumulated personal knowledge
5
+ * stored under ~/.forgen/me/ (solutions/, rules/, behavior/).
6
+ *
7
+ * Export creates a tar.gz archive; Import extracts it while skipping existing
8
+ * files to prevent accidental overwrites.
9
+ */
10
+ export interface ExportResult {
11
+ outputPath: string;
12
+ counts: Record<string, number>;
13
+ totalFiles: number;
14
+ }
15
+ export interface ImportResult {
16
+ imported: number;
17
+ skipped: number;
18
+ details: {
19
+ file: string;
20
+ action: 'imported' | 'skipped';
21
+ }[];
22
+ }
23
+ /**
24
+ * Export knowledge directories to a tar.gz archive.
25
+ *
26
+ * Uses `tar czf` via child_process for simplicity and reliability.
27
+ * Only archives solutions/, rules/, behavior/ under ME_DIR.
28
+ */
29
+ export declare function exportKnowledge(outputPath?: string): ExportResult;
30
+ /**
31
+ * Import knowledge from a tar.gz archive.
32
+ *
33
+ * For each file in the archive, if a file with the same name already exists
34
+ * in the target directory, it is SKIPPED (no overwrite). Only new files are
35
+ * added.
36
+ */
37
+ export declare function importKnowledge(archivePath: string): ImportResult;
38
+ /** CLI handler: forgen compound export */
39
+ export declare function handleExport(args: string[]): Promise<void>;
40
+ /** CLI handler: forgen compound import */
41
+ export declare function handleImport(args: string[]): Promise<void>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Forgen — Compound Knowledge Export/Import
3
+ *
4
+ * Provides backup, migration, and sharing of accumulated personal knowledge
5
+ * stored under ~/.forgen/me/ (solutions/, rules/, behavior/).
6
+ *
7
+ * Export creates a tar.gz archive; Import extracts it while skipping existing
8
+ * files to prevent accidental overwrites.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { ME_DIR } from '../core/paths.js';
15
+ /** Directories within ME_DIR to include in the archive. */
16
+ const KNOWLEDGE_DIRS = ['solutions', 'rules', 'behavior'];
17
+ /**
18
+ * Count .md files in a directory (non-recursive).
19
+ * Returns 0 if the directory does not exist.
20
+ */
21
+ function countFiles(dir) {
22
+ try {
23
+ if (!fs.existsSync(dir))
24
+ return 0;
25
+ return fs.readdirSync(dir).filter(f => f.endsWith('.md')).length;
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ /**
32
+ * Export knowledge directories to a tar.gz archive.
33
+ *
34
+ * Uses `tar czf` via child_process for simplicity and reliability.
35
+ * Only archives solutions/, rules/, behavior/ under ME_DIR.
36
+ */
37
+ export function exportKnowledge(outputPath) {
38
+ const date = new Date().toISOString().split('T')[0];
39
+ const resolved = outputPath ?? path.join(process.cwd(), `forgen-knowledge-${date}.tar.gz`);
40
+ // Gather counts before archiving
41
+ const counts = {};
42
+ const existingDirs = [];
43
+ for (const name of KNOWLEDGE_DIRS) {
44
+ const dir = path.join(ME_DIR, name);
45
+ const count = countFiles(dir);
46
+ counts[name] = count;
47
+ if (fs.existsSync(dir)) {
48
+ existingDirs.push(name);
49
+ }
50
+ }
51
+ const totalFiles = Object.values(counts).reduce((a, b) => a + b, 0);
52
+ if (existingDirs.length === 0) {
53
+ throw new Error('No knowledge directories found to export.');
54
+ }
55
+ // Ensure output directory exists
56
+ const outDir = path.dirname(resolved);
57
+ fs.mkdirSync(outDir, { recursive: true });
58
+ // Create tar.gz relative to ME_DIR so archive paths are solutions/*, rules/*, behavior/*
59
+ execFileSync('tar', ['czf', resolved, ...existingDirs], {
60
+ cwd: ME_DIR,
61
+ timeout: 30000,
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ return { outputPath: resolved, counts, totalFiles };
65
+ }
66
+ /**
67
+ * Import knowledge from a tar.gz archive.
68
+ *
69
+ * For each file in the archive, if a file with the same name already exists
70
+ * in the target directory, it is SKIPPED (no overwrite). Only new files are
71
+ * added.
72
+ */
73
+ export function importKnowledge(archivePath) {
74
+ if (!fs.existsSync(archivePath)) {
75
+ throw new Error(`Archive not found: ${archivePath}`);
76
+ }
77
+ // List files in the archive
78
+ const listOutput = execFileSync('tar', ['tzf', archivePath], {
79
+ timeout: 30000,
80
+ encoding: 'utf-8',
81
+ stdio: ['pipe', 'pipe', 'pipe'],
82
+ });
83
+ const archiveFiles = listOutput
84
+ .split('\n')
85
+ .map(f => f.trim())
86
+ .filter(f => f && !f.endsWith('/'));
87
+ // Extract to a temp directory first, then selectively copy
88
+ const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'forgen-import-'));
89
+ try {
90
+ execFileSync('tar', ['xzf', archivePath, '-C', tmpDir], {
91
+ timeout: 30000,
92
+ stdio: ['pipe', 'pipe', 'pipe'],
93
+ });
94
+ const result = { imported: 0, skipped: 0, details: [] };
95
+ for (const relFile of archiveFiles) {
96
+ const srcPath = path.join(tmpDir, relFile);
97
+ const destPath = path.join(ME_DIR, relFile);
98
+ // Security: ensure the dest path stays within ME_DIR
99
+ const realDest = path.resolve(destPath);
100
+ if (!realDest.startsWith(ME_DIR)) {
101
+ result.skipped++;
102
+ result.details.push({ file: relFile, action: 'skipped' });
103
+ continue;
104
+ }
105
+ if (fs.existsSync(destPath)) {
106
+ result.skipped++;
107
+ result.details.push({ file: relFile, action: 'skipped' });
108
+ }
109
+ else {
110
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
111
+ fs.copyFileSync(srcPath, destPath);
112
+ result.imported++;
113
+ result.details.push({ file: relFile, action: 'imported' });
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+ finally {
119
+ // Clean up temp directory
120
+ fs.rmSync(tmpDir, { recursive: true, force: true });
121
+ }
122
+ }
123
+ /** CLI handler: forgen compound export */
124
+ export async function handleExport(args) {
125
+ const outputIdx = args.indexOf('--output');
126
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
127
+ try {
128
+ const result = exportKnowledge(outputPath);
129
+ console.log('\n Compound Knowledge Export\n');
130
+ console.log(` Output: ${result.outputPath}`);
131
+ console.log();
132
+ for (const [category, count] of Object.entries(result.counts)) {
133
+ console.log(` ${category}: ${count} files`);
134
+ }
135
+ console.log(`\n Total: ${result.totalFiles} files exported.\n`);
136
+ }
137
+ catch (e) {
138
+ console.error(`\n Export failed: ${e.message}\n`);
139
+ process.exit(1);
140
+ }
141
+ }
142
+ /** CLI handler: forgen compound import */
143
+ export async function handleImport(args) {
144
+ const archivePath = args[0];
145
+ if (!archivePath || archivePath.startsWith('--')) {
146
+ console.log(' Usage: forgen compound import <path-to-archive>\n');
147
+ return;
148
+ }
149
+ try {
150
+ const resolved = path.resolve(archivePath);
151
+ const result = importKnowledge(resolved);
152
+ console.log('\n Compound Knowledge Import\n');
153
+ console.log(` Archive: ${resolved}`);
154
+ console.log(` Imported: ${result.imported} new files`);
155
+ console.log(` Skipped: ${result.skipped} existing files`);
156
+ if (result.details.length > 0 && result.details.length <= 20) {
157
+ console.log();
158
+ for (const d of result.details) {
159
+ const icon = d.action === 'imported' ? '+' : '-';
160
+ console.log(` ${icon} ${d.file}`);
161
+ }
162
+ }
163
+ console.log();
164
+ }
165
+ catch (e) {
166
+ console.error(`\n Import failed: ${e.message}\n`);
167
+ process.exit(1);
168
+ }
169
+ }
@@ -190,6 +190,11 @@ export async function handleCompound(args) {
190
190
  forgen compound --lifecycle Run promotion/demotion/circuit-breaker check
191
191
  forgen compound --verify <name> Manually promote solution to verified
192
192
 
193
+ Export/Import:
194
+ forgen compound export [--output path]
195
+ Export knowledge to tar.gz archive
196
+ forgen compound import <path> Import knowledge from archive (skip existing)
197
+
193
198
  Auto-extraction:
194
199
  forgen compound --pause-auto Pause auto-extraction
195
200
  forgen compound --resume-auto Resume auto-extraction
@@ -199,6 +204,18 @@ export async function handleCompound(args) {
199
204
  `);
200
205
  return;
201
206
  }
207
+ // --- export command ---
208
+ if (args[0] === 'export') {
209
+ const { handleExport } = await import('./compound-export.js');
210
+ await handleExport(args.slice(1));
211
+ return;
212
+ }
213
+ // --- import command ---
214
+ if (args[0] === 'import') {
215
+ const { handleImport } = await import('./compound-export.js');
216
+ await handleImport(args.slice(1));
217
+ return;
218
+ }
202
219
  // --pause-auto / --resume-auto
203
220
  if (args.includes('--pause-auto') || args.includes('pause-auto')) {
204
221
  const { pauseExtraction } = await import('./compound-extractor.js');
@@ -375,6 +392,7 @@ export async function handleCompound(args) {
375
392
  '--lifecycle', '--verify', '--save', '--interactive',
376
393
  'list', 'inspect', 'remove', 'rollback', 'retag', 'lifecycle',
377
394
  '--list', '--inspect', '--remove', '--rollback', '--retag', '--since', 'interactive',
395
+ 'export', 'import', '--output',
378
396
  ];
379
397
  const hasTypeFlag = knownFlags.some(f => args.includes(f));
380
398
  if (!hasTypeFlag) {
@@ -6,6 +6,27 @@ import type { SolutionStatus, SolutionType } from './solution-format.js';
6
6
  * `synonym-tfidf.test.ts` and any external consumers.
7
7
  */
8
8
  export declare function expandTagsWithSynonyms(tags: string[]): string[];
9
+ /**
10
+ * Compute the Dice coefficient between two strings using character bigrams.
11
+ *
12
+ * Dice = 2 * |intersection| / (|A| + |B|)
13
+ *
14
+ * Both strings are lowercased and whitespace-stripped before bigram generation.
15
+ * Returns 0 for empty strings or single-character strings (no bigrams possible).
16
+ * Returns 1.0 for identical non-trivial strings.
17
+ *
18
+ * This is used as a lightweight fuzzy matching signal for borderline cases
19
+ * where the TF-IDF tag intersection produces a low score but the query and
20
+ * solution tags are character-similar (e.g., "database" vs "데이터베이스"
21
+ * won't match, but "database" vs "databse" will get a high score).
22
+ */
23
+ export declare function bigramSimilarity(a: string, b: string): number;
24
+ /**
25
+ * Simplified BM25 score for a single query-document pair.
26
+ * Uses tag overlap with term frequency normalization.
27
+ * k1=1.2, b=0.75 (standard BM25 parameters).
28
+ */
29
+ export declare function bm25Score(queryTags: string[], docTags: string[], avgDocLength: number): number;
9
30
  /** Apply IDF-like weight: common tags get reduced weight */
10
31
  export declare function tagWeight(tag: string): number;
11
32
  export interface SolutionMatch {
@@ -42,6 +63,8 @@ export interface CalculateRelevanceOptions {
42
63
  * pair — `solutionTagsExpanded` MUST be a superset of `solutionTags`.
43
64
  */
44
65
  solutionTagsExpanded?: string[];
66
+ /** Average document (solution) tag count for BM25 normalization. Defaults to 6. */
67
+ avgDocLength?: number;
45
68
  }
46
69
  export declare function calculateRelevance(promptTags: string[], solutionTags: string[], confidence: number, options?: CalculateRelevanceOptions): {
47
70
  relevance: number;
@@ -31,6 +31,73 @@ export function expandTagsWithSynonyms(tags) {
31
31
  return defaultNormalizer.normalizeTerms(tags);
32
32
  }
33
33
  // ── TF-IDF weighting for common tags ──
34
+ // ── Character bigram similarity (Dice coefficient) ──
35
+ /**
36
+ * Compute the Dice coefficient between two strings using character bigrams.
37
+ *
38
+ * Dice = 2 * |intersection| / (|A| + |B|)
39
+ *
40
+ * Both strings are lowercased and whitespace-stripped before bigram generation.
41
+ * Returns 0 for empty strings or single-character strings (no bigrams possible).
42
+ * Returns 1.0 for identical non-trivial strings.
43
+ *
44
+ * This is used as a lightweight fuzzy matching signal for borderline cases
45
+ * where the TF-IDF tag intersection produces a low score but the query and
46
+ * solution tags are character-similar (e.g., "database" vs "데이터베이스"
47
+ * won't match, but "database" vs "databse" will get a high score).
48
+ */
49
+ export function bigramSimilarity(a, b) {
50
+ const na = a.toLowerCase().replace(/\s+/g, '');
51
+ const nb = b.toLowerCase().replace(/\s+/g, '');
52
+ if (na.length < 2 || nb.length < 2)
53
+ return 0;
54
+ if (na === nb)
55
+ return 1.0;
56
+ const bigramsA = new Map();
57
+ for (let i = 0; i < na.length - 1; i++) {
58
+ const bg = na.slice(i, i + 2);
59
+ bigramsA.set(bg, (bigramsA.get(bg) ?? 0) + 1);
60
+ }
61
+ const bigramsB = new Map();
62
+ for (let i = 0; i < nb.length - 1; i++) {
63
+ const bg = nb.slice(i, i + 2);
64
+ bigramsB.set(bg, (bigramsB.get(bg) ?? 0) + 1);
65
+ }
66
+ let intersectionSize = 0;
67
+ for (const [bg, countA] of bigramsA) {
68
+ const countB = bigramsB.get(bg) ?? 0;
69
+ intersectionSize += Math.min(countA, countB);
70
+ }
71
+ const totalA = na.length - 1;
72
+ const totalB = nb.length - 1;
73
+ return (2 * intersectionSize) / (totalA + totalB);
74
+ }
75
+ // ── BM25-like scoring ──
76
+ /**
77
+ * Simplified BM25 score for a single query-document pair.
78
+ * Uses tag overlap with term frequency normalization.
79
+ * k1=1.2, b=0.75 (standard BM25 parameters).
80
+ */
81
+ export function bm25Score(queryTags, docTags, avgDocLength) {
82
+ const k1 = 1.2;
83
+ const b = 0.75;
84
+ const docLen = docTags.length;
85
+ if (docLen === 0 || queryTags.length === 0 || avgDocLength === 0)
86
+ return 0;
87
+ let score = 0;
88
+ for (const qt of queryTags) {
89
+ // Term frequency in document
90
+ const tf = docTags.filter(dt => dt === qt || (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt)))).length;
91
+ if (tf === 0)
92
+ continue;
93
+ // BM25 TF saturation
94
+ const numerator = tf * (k1 + 1);
95
+ const denominator = tf + k1 * (1 - b + b * (docLen / avgDocLength));
96
+ score += numerator / denominator;
97
+ }
98
+ // Normalize by query length
99
+ return score / queryTags.length;
100
+ }
34
101
  /** High-frequency tags that should be weighted lower */
35
102
  const COMMON_TAGS = new Set([
36
103
  'typescript', 'ts', 'javascript', 'js', 'fix', 'update', 'add', 'change',
@@ -72,20 +139,66 @@ export function calculateRelevance(promptOrTags, keywordsOrTags, confidence, opt
72
139
  // Apply TF-IDF weighting: common tags count less
73
140
  const weightedMatched = intersection.reduce((sum, t) => sum + tagWeight(t), 0)
74
141
  + partialMatches.reduce((sum, t) => sum + tagWeight(t) * 0.5, 0);
75
- // 완화된 임계값: 가중 점수 0.5 이상이면 후보
76
- if (weightedMatched < 0.5)
142
+ // ── Bigram similarity boost for borderline cases ──
143
+ //
144
+ // When the TF-IDF intersection score is below the match threshold (0.5),
145
+ // compute a character-bigram Dice coefficient between the query tags and
146
+ // the solution tags. If the best bigram similarity is high enough, blend
147
+ // it in at 20% weight (TF-IDF 80%, bigram 20%) to rescue fuzzy matches
148
+ // that the exact/substring intersection missed (e.g., typos, slight
149
+ // morphological variants).
150
+ //
151
+ // When TF-IDF score is already above threshold, the bigram boost is NOT
152
+ // applied — this preserves existing match quality and avoids disturbing
153
+ // already-good rankings. The bigram path is purely a rescue mechanism
154
+ // for borderline cases.
155
+ if (weightedMatched < 0.5) {
156
+ // Compute best bigram similarity across all (promptTag, solutionTag) pairs
157
+ let bestBigramScore = 0;
158
+ const bigramMatchedTags = [];
159
+ for (const st of matchTags) {
160
+ for (const pt of expandedPromptTags) {
161
+ const sim = bigramSimilarity(pt, st);
162
+ if (sim > bestBigramScore) {
163
+ bestBigramScore = sim;
164
+ }
165
+ // Track solution tags with meaningful bigram similarity (> 0.4)
166
+ if (sim > 0.4 && !bigramMatchedTags.includes(st)) {
167
+ bigramMatchedTags.push(st);
168
+ }
169
+ }
170
+ }
171
+ // Only rescue if the bigram signal is strong enough (> 0.4 threshold)
172
+ // to avoid noise from weakly similar strings
173
+ if (bestBigramScore > 0.4) {
174
+ const union = new Set([...promptOrTags, ...keywordsOrTags]).size;
175
+ const tfidfScore = weightedMatched / Math.max(union, 1);
176
+ const blendedScore = tfidfScore * 0.8 + bestBigramScore * 0.2;
177
+ return {
178
+ relevance: blendedScore * (confidence ?? 1),
179
+ matchedTags: [...intersection, ...partialMatches, ...bigramMatchedTags.filter(t => !intersection.includes(t) && !partialMatches.includes(t))],
180
+ };
181
+ }
77
182
  return { relevance: 0, matchedTags: [] };
78
- // Jaccard-like: weighted matched / union.
79
- // Union uses RAW promptTags and RAW solutionTags not the expanded set —
80
- // so that the denominator semantics are unchanged from pre-T2 behaviour.
81
- // This is intentional: expanding both sides of the Jaccard would
82
- // asymmetrically inflate recall and silently shift all baseline metrics.
83
- // R4-T1 explicitly preserves this: `keywordsOrTags` is the raw solution
84
- // tag list, not the compound-expanded `matchTags` used above.
183
+ }
184
+ // Ensemble: TF-IDF (Jaccard) 0.5 + BM25 0.3 + bigram 0.2
85
185
  const union = new Set([...promptOrTags, ...keywordsOrTags]).size;
86
- const tagScore = weightedMatched / Math.max(union, 1);
186
+ const tfidfScore = weightedMatched / Math.max(union, 1);
187
+ // BM25 component: average doc length defaults to 6 tags (typical solution)
188
+ const avgDocLen = options?.avgDocLength ?? 6;
189
+ const bm25 = bm25Score(promptOrTags, keywordsOrTags, avgDocLen);
190
+ // Bigram component (mild boost for partial string matches)
191
+ let bigramBoost = 0;
192
+ for (const st of matchTags) {
193
+ for (const pt of expandedPromptTags) {
194
+ const sim = bigramSimilarity(pt, st);
195
+ if (sim > bigramBoost)
196
+ bigramBoost = sim;
197
+ }
198
+ }
199
+ const ensembleScore = tfidfScore * 0.5 + bm25 * 0.3 + bigramBoost * 0.2;
87
200
  return {
88
- relevance: tagScore * (confidence ?? 1),
201
+ relevance: ensembleScore * (confidence ?? 1),
89
202
  matchedTags: [...intersection, ...partialMatches],
90
203
  };
91
204
  }
@@ -19,6 +19,16 @@ export declare function shouldWarn(contextPercent: {
19
19
  charsThreshold?: number;
20
20
  cooldownMs?: number;
21
21
  }): boolean;
22
+ /** auto-compact 트리거 여부 판정 (순수 함수) */
23
+ export declare function shouldAutoCompact(state: {
24
+ totalChars: number;
25
+ lastAutoCompactAt: number;
26
+ }, thresholds?: {
27
+ charsThreshold?: number;
28
+ cooldownMs?: number;
29
+ }): boolean;
30
+ /** auto-compact 지시 메시지 생성 (순수 함수) */
31
+ export declare function buildAutoCompactMessage(totalChars: number): string;
22
32
  /** 경고 메시지 생성 (순수 함수) */
23
33
  export declare function buildContextWarningMessage(promptCount: number, totalChars: number): string;
24
34
  export declare function main(): Promise<void>;