@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.
- package/CHANGELOG.md +28 -0
- package/README.ja.md +79 -14
- package/README.ko.md +89 -14
- package/README.md +77 -14
- package/README.zh.md +79 -14
- package/commands/deep-interview.md +171 -0
- package/commands/specify.md +128 -0
- package/dist/cli.js +9 -0
- package/dist/core/dashboard.d.ts +91 -0
- package/dist/core/dashboard.js +385 -0
- package/dist/core/doctor.js +151 -21
- package/dist/core/drift-score.d.ts +49 -0
- package/dist/core/drift-score.js +87 -0
- package/dist/core/mcp-config.d.ts +2 -0
- package/dist/core/mcp-config.js +6 -1
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/paths.js +1 -1
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-loop.js +18 -0
- package/dist/engine/solution-matcher.d.ts +23 -0
- package/dist/engine/solution-matcher.js +124 -11
- package/dist/hooks/context-guard.d.ts +10 -0
- package/dist/hooks/context-guard.js +104 -58
- package/dist/hooks/db-guard.js +2 -2
- package/dist/hooks/hook-config.d.ts +27 -1
- package/dist/hooks/hook-config.js +72 -12
- package/dist/hooks/intent-classifier.d.ts +0 -2
- package/dist/hooks/intent-classifier.js +32 -18
- package/dist/hooks/keyword-detector.js +117 -111
- package/dist/hooks/notepad-injector.js +2 -2
- package/dist/hooks/permission-handler.js +2 -2
- package/dist/hooks/post-tool-failure.js +12 -6
- package/dist/hooks/post-tool-handlers.d.ts +1 -1
- package/dist/hooks/post-tool-handlers.js +14 -11
- package/dist/hooks/post-tool-use.d.ts +11 -0
- package/dist/hooks/post-tool-use.js +184 -71
- package/dist/hooks/pre-compact.d.ts +11 -1
- package/dist/hooks/pre-compact.js +112 -37
- package/dist/hooks/pre-tool-use.js +86 -56
- package/dist/hooks/rate-limiter.js +3 -3
- package/dist/hooks/secret-filter.js +2 -2
- package/dist/hooks/session-recovery.js +256 -236
- package/dist/hooks/shared/hook-response.d.ts +4 -4
- package/dist/hooks/shared/hook-response.js +13 -24
- package/dist/hooks/shared/hook-timing.d.ts +15 -0
- package/dist/hooks/shared/hook-timing.js +64 -0
- package/dist/hooks/skill-injector.js +41 -12
- package/dist/hooks/slop-detector.js +3 -3
- package/dist/hooks/solution-injector.js +224 -197
- package/dist/hooks/subagent-tracker.js +2 -2
- package/dist/renderer/rule-renderer.js +9 -11
- package/package.json +1 -1
- package/skills/deep-interview/SKILL.md +166 -0
- 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
|
+
}
|
package/dist/core/mcp-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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 — 팀 팩 연결 파일 */
|
package/dist/core/paths.js
CHANGED
|
@@ -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
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
//
|
|
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
|
|
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:
|
|
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>;
|