dual-brain 7.1.29 → 7.1.30
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/bin/dual-brain.mjs +29 -0
- package/package.json +3 -1
- package/src/cost-tracker.mjs +184 -0
- package/src/decide.mjs +47 -2
- package/src/pipeline.mjs +53 -2
- package/src/think-engine.mjs +428 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -2783,6 +2783,29 @@ async function settingsScreen(rl, ask) {
|
|
|
2783
2783
|
const _stC = typeof _stCal.corrections === 'number' ? _stCal.corrections.toFixed(1) : String(_stCal.corrections ?? 3);
|
|
2784
2784
|
const _stA = typeof _stCal.autonomy === 'number' ? _stCal.autonomy.toFixed(1) : String(_stCal.autonomy ?? 3);
|
|
2785
2785
|
|
|
2786
|
+
// Cost efficiency summary (graceful — only shown when data exists)
|
|
2787
|
+
let _stEffScore = null;
|
|
2788
|
+
let _stEffRate = null;
|
|
2789
|
+
let _stEffTrend = null;
|
|
2790
|
+
let _stEffTier = null;
|
|
2791
|
+
try {
|
|
2792
|
+
const _stCt = await import('../src/cost-tracker.mjs');
|
|
2793
|
+
const _stSummary = _stCt.getCostSummary(cwd, 7);
|
|
2794
|
+
if (_stSummary.totalActions > 0) {
|
|
2795
|
+
_stEffScore = _stCt.getEfficiencyScore(cwd);
|
|
2796
|
+
_stEffRate = Math.round(_stSummary.savingsRate * 100);
|
|
2797
|
+
_stEffTrend = _stSummary.trend;
|
|
2798
|
+
const tierOrder = ['recall', 'quick', 'standard', 'deep', 'ultra'];
|
|
2799
|
+
const _stTierKeys = tierOrder.filter(k => _stSummary.byTier[k]);
|
|
2800
|
+
_stEffTier = _stTierKeys.map(k => {
|
|
2801
|
+
const t = _stSummary.byTier[k];
|
|
2802
|
+
return `${k.padEnd(8)} ${String(t.count).padStart(3)}`;
|
|
2803
|
+
}).join(' ');
|
|
2804
|
+
}
|
|
2805
|
+
} catch { /* non-fatal */ }
|
|
2806
|
+
|
|
2807
|
+
const _stTrendIcon = _stEffTrend === 'improving' ? '↗' : _stEffTrend === 'degrading' ? '↘' : '→';
|
|
2808
|
+
|
|
2786
2809
|
const lines = [
|
|
2787
2810
|
top,
|
|
2788
2811
|
row('Settings'),
|
|
@@ -2799,6 +2822,12 @@ async function settingsScreen(rl, ask) {
|
|
|
2799
2822
|
row('User Calibration'),
|
|
2800
2823
|
row(` Specificity: ${_stS} Corrections: ${_stC} Autonomy: ${_stA}`),
|
|
2801
2824
|
row(` Level: ${_stLevel} · Style: ${_stStyle}`),
|
|
2825
|
+
...(_stEffScore !== null ? [
|
|
2826
|
+
sep,
|
|
2827
|
+
row('Cost Efficiency (7 days)'),
|
|
2828
|
+
row(` Score: ${_stEffScore}/100 Savings: ${_stEffRate}% Trend: ${_stTrendIcon} ${_stEffTrend}`),
|
|
2829
|
+
...(_stEffTier ? [row(` Tiers: ${_stEffTier}`)] : []),
|
|
2830
|
+
] : []),
|
|
2802
2831
|
sep,
|
|
2803
2832
|
row('[1-3] change style [r] reset calibration [b] back'),
|
|
2804
2833
|
row('[m] subscriptions [e] sessions [x] diagnostics'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.30",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
"src/awareness.mjs",
|
|
78
78
|
"src/tui.mjs",
|
|
79
79
|
"src/living-docs.mjs",
|
|
80
|
+
"src/cost-tracker.mjs",
|
|
81
|
+
"src/think-engine.mjs",
|
|
80
82
|
"src/install-hooks.mjs",
|
|
81
83
|
"src/update-check.mjs",
|
|
82
84
|
"src/prompt-intel.mjs",
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// cost-tracker.mjs — Lightweight cost estimation and efficiency tracking for .dual-brain/costs.jsonl.
|
|
2
|
+
|
|
3
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const TOKEN_COSTS = {
|
|
7
|
+
'claude-opus-4-6': 0.03,
|
|
8
|
+
'claude-sonnet-4-6': 0.006,
|
|
9
|
+
'claude-haiku-4-5-20251001': 0.001,
|
|
10
|
+
'gpt-5.5': 0.04,
|
|
11
|
+
'o3': 0.03,
|
|
12
|
+
'gpt-4o': 0.005,
|
|
13
|
+
'gpt-4o-mini': 0.0003,
|
|
14
|
+
'default': 0.01,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function estimateTokenCost(model, tokens) {
|
|
18
|
+
const rate = TOKEN_COSTS[model] ?? TOKEN_COSTS['default'];
|
|
19
|
+
return (tokens / 1000) * rate;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function trackCost(action, cwd = process.cwd()) {
|
|
23
|
+
try {
|
|
24
|
+
const dir = join(cwd, '.dual-brain');
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
const entry = {
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
action: action.action ?? 'execute',
|
|
29
|
+
model: action.model ?? 'default',
|
|
30
|
+
tokensEstimated: action.tokensEstimated ?? 0,
|
|
31
|
+
costEstimated: estimateTokenCost(action.model ?? 'default', action.tokensEstimated ?? 0),
|
|
32
|
+
tier: action.tier ?? 'standard',
|
|
33
|
+
wasCacheHit: action.wasCacheHit ?? false,
|
|
34
|
+
tokensSaved: action.tokensSaved ?? 0,
|
|
35
|
+
};
|
|
36
|
+
appendFileSync(join(dir, 'costs.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
|
|
37
|
+
return entry;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readCostLines(cwd) {
|
|
44
|
+
const p = join(cwd, '.dual-brain', 'costs.jsonl');
|
|
45
|
+
if (!existsSync(p)) return [];
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).flatMap(line => {
|
|
48
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
49
|
+
});
|
|
50
|
+
} catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getCostSummary(cwd = process.cwd(), days = 7) {
|
|
54
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
55
|
+
const all = readCostLines(cwd).filter(e => e.timestamp >= cutoff);
|
|
56
|
+
|
|
57
|
+
if (all.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
period: `${days} days`,
|
|
60
|
+
totalCost: 0, totalTokens: 0, totalActions: 0,
|
|
61
|
+
cacheHits: 0, tokensSaved: 0, costSaved: 0, savingsRate: 0,
|
|
62
|
+
byTier: {}, byModel: {}, trend: 'stable',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let totalCost = 0, totalTokens = 0, cacheHits = 0, tokensSaved = 0;
|
|
67
|
+
const byTier = {};
|
|
68
|
+
const byModel = {};
|
|
69
|
+
|
|
70
|
+
for (const e of all) {
|
|
71
|
+
totalCost += e.costEstimated ?? 0;
|
|
72
|
+
totalTokens += e.tokensEstimated ?? 0;
|
|
73
|
+
if (e.wasCacheHit) { cacheHits++; tokensSaved += e.tokensSaved ?? 0; }
|
|
74
|
+
|
|
75
|
+
const tier = e.tier ?? 'standard';
|
|
76
|
+
if (!byTier[tier]) byTier[tier] = { count: 0, tokens: 0, cost: 0 };
|
|
77
|
+
byTier[tier].count += 1;
|
|
78
|
+
byTier[tier].tokens += e.tokensEstimated ?? 0;
|
|
79
|
+
byTier[tier].cost += e.costEstimated ?? 0;
|
|
80
|
+
|
|
81
|
+
const model = e.model ?? 'default';
|
|
82
|
+
if (!byModel[model]) byModel[model] = { count: 0, tokens: 0, cost: 0 };
|
|
83
|
+
byModel[model].count += 1;
|
|
84
|
+
byModel[model].tokens += e.tokensEstimated ?? 0;
|
|
85
|
+
byModel[model].cost += e.costEstimated ?? 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const costSaved = estimateTokenCost('default', tokensSaved);
|
|
89
|
+
const savingsRate = (tokensSaved + totalTokens) > 0
|
|
90
|
+
? tokensSaved / (tokensSaved + totalTokens)
|
|
91
|
+
: 0;
|
|
92
|
+
|
|
93
|
+
// Trend: compare first half vs second half savings rate
|
|
94
|
+
const mid = Math.floor(all.length / 2);
|
|
95
|
+
const first = all.slice(0, mid);
|
|
96
|
+
const second = all.slice(mid);
|
|
97
|
+
const halfSavings = (half) => {
|
|
98
|
+
const ts = half.reduce((s, e) => s + (e.tokensSaved ?? 0), 0);
|
|
99
|
+
const tt = half.reduce((s, e) => s + (e.tokensEstimated ?? 0), 0);
|
|
100
|
+
return (ts + tt) > 0 ? ts / (ts + tt) : 0;
|
|
101
|
+
};
|
|
102
|
+
let trend = 'stable';
|
|
103
|
+
if (all.length >= 4) {
|
|
104
|
+
const delta = halfSavings(second) - halfSavings(first);
|
|
105
|
+
if (delta > 0.05) trend = 'improving';
|
|
106
|
+
else if (delta < -0.05) trend = 'degrading';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
period: `${days} days`,
|
|
111
|
+
totalCost, totalTokens, totalActions: all.length,
|
|
112
|
+
cacheHits, tokensSaved, costSaved, savingsRate,
|
|
113
|
+
byTier, byModel, trend,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatCostReport(summary) {
|
|
118
|
+
const {
|
|
119
|
+
period, totalCost, totalTokens, totalActions,
|
|
120
|
+
cacheHits, tokensSaved, costSaved, savingsRate,
|
|
121
|
+
byTier, byModel, trend,
|
|
122
|
+
} = summary;
|
|
123
|
+
|
|
124
|
+
const lines = [`COST EFFICIENCY (${period})`];
|
|
125
|
+
|
|
126
|
+
const fmtK = (n) => n >= 1000 ? `${Math.round(n / 1000)}K` : String(Math.round(n));
|
|
127
|
+
const fmtD = (n) => `~$${n.toFixed(2)}`;
|
|
128
|
+
|
|
129
|
+
lines.push(` Total: ${fmtD(totalCost)} (${fmtK(totalTokens)} tokens, ${totalActions} actions)`);
|
|
130
|
+
|
|
131
|
+
if (cacheHits > 0) {
|
|
132
|
+
const pct = Math.round(savingsRate * 100);
|
|
133
|
+
lines.push(` Saved: ${fmtD(costSaved)} (${fmtK(tokensSaved)} tokens from ${cacheHits} cache hits)`);
|
|
134
|
+
lines.push(` Savings rate: ${pct}%`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tierOrder = ['recall', 'quick', 'standard', 'deep', 'ultra'];
|
|
138
|
+
const tierKeys = [...new Set([...tierOrder, ...Object.keys(byTier)])].filter(k => byTier[k]);
|
|
139
|
+
if (tierKeys.length > 0) {
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(' Tier breakdown:');
|
|
142
|
+
for (const tier of tierKeys) {
|
|
143
|
+
const t = byTier[tier];
|
|
144
|
+
const isRecall = tier === 'recall' && (t.cost < 0.001 || t.tokens === 0);
|
|
145
|
+
const costStr = isRecall ? '$0.00 (cache hits!)' : fmtD(t.cost);
|
|
146
|
+
lines.push(` ${tier.padEnd(10)}${String(t.count).padStart(4)} actions ${costStr}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const trendIcon = trend === 'improving' ? '↗' : trend === 'degrading' ? '↘' : '→';
|
|
151
|
+
lines.push('');
|
|
152
|
+
if (trend !== 'stable') {
|
|
153
|
+
const pct = Math.round(Math.abs(savingsRate) * 100);
|
|
154
|
+
lines.push(` Trend: ${trendIcon} ${trend} (savings rate ${trend === 'improving' ? 'up' : 'down'} vs last half)`);
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(` Trend: ${trendIcon} stable`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getEfficiencyScore(cwd = process.cwd()) {
|
|
163
|
+
const summary = getCostSummary(cwd, 7);
|
|
164
|
+
if (summary.totalActions === 0) return 50;
|
|
165
|
+
|
|
166
|
+
const TIER_WEIGHTS = { recall: 0, quick: 1, standard: 2, deep: 4, ultra: 6 };
|
|
167
|
+
const totalTierCost = Object.entries(summary.byTier).reduce((s, [tier, v]) => {
|
|
168
|
+
return s + (TIER_WEIGHTS[tier] ?? 2) * v.count;
|
|
169
|
+
}, 0);
|
|
170
|
+
const maxPossible = summary.totalActions * (TIER_WEIGHTS['ultra'] ?? 6);
|
|
171
|
+
const tierScore = maxPossible > 0 ? 1 - (totalTierCost / maxPossible) : 0.5;
|
|
172
|
+
|
|
173
|
+
const cacheScore = summary.savingsRate;
|
|
174
|
+
const trendBonus = summary.trend === 'improving' ? 10 : summary.trend === 'degrading' ? -10 : 0;
|
|
175
|
+
|
|
176
|
+
const raw = Math.round(
|
|
177
|
+
tierScore * 40 +
|
|
178
|
+
cacheScore * 40 +
|
|
179
|
+
20 +
|
|
180
|
+
trendBonus
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return Math.max(1, Math.min(100, raw));
|
|
184
|
+
}
|
package/src/decide.mjs
CHANGED
|
@@ -682,10 +682,10 @@ function applyCriticalRiskFloor(model, provider, available, risk) {
|
|
|
682
682
|
|
|
683
683
|
/**
|
|
684
684
|
* Main routing decision function.
|
|
685
|
-
* @param {{ profile: object, detection: object, cwd?: string }} input
|
|
685
|
+
* @param {{ profile: object, detection: object, cwd?: string, thinkResult?: object }} input
|
|
686
686
|
* @returns {object} Routing decision
|
|
687
687
|
*/
|
|
688
|
-
export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
688
|
+
export function decideRoute({ profile = {}, detection = {}, cwd, thinkResult } = {}) {
|
|
689
689
|
const available = getAvailableModels(profile);
|
|
690
690
|
|
|
691
691
|
// Resolve active work style
|
|
@@ -786,6 +786,51 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
|
786
786
|
// Apply profile mode bias (cost-saver / quality-first / preferences) using patched profile
|
|
787
787
|
model = applyProfileBias(model, profileWithEffectiveBias, provider, available[provider], detection.tier);
|
|
788
788
|
|
|
789
|
+
// Think-engine tier hint: use as a HINT to allow cheaper model when think-engine
|
|
790
|
+
// classifies the task as recall/quick. Never escalate — only downgrade when safe to do so.
|
|
791
|
+
let thinkTier = null;
|
|
792
|
+
try {
|
|
793
|
+
if (thinkResult?.tier) thinkTier = thinkResult.tier;
|
|
794
|
+
} catch (e) {}
|
|
795
|
+
|
|
796
|
+
if (thinkTier && !isHighStakes) {
|
|
797
|
+
const claudeRankAsc = ['haiku', 'sonnet', 'opus'];
|
|
798
|
+
const openaiRankAsc = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o4-mini', 'o3'];
|
|
799
|
+
|
|
800
|
+
if (thinkTier === 'recall' && provider === 'claude') {
|
|
801
|
+
// recall → haiku is fine if available
|
|
802
|
+
const target = 'haiku';
|
|
803
|
+
const currentIdx = claudeRankAsc.indexOf(model);
|
|
804
|
+
const targetIdx = claudeRankAsc.indexOf(target);
|
|
805
|
+
if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
|
|
806
|
+
model = target;
|
|
807
|
+
}
|
|
808
|
+
} else if (thinkTier === 'recall' && provider === 'openai') {
|
|
809
|
+
const target = 'gpt-4o-mini';
|
|
810
|
+
const currentIdx = openaiRankAsc.indexOf(model);
|
|
811
|
+
const targetIdx = openaiRankAsc.indexOf(target);
|
|
812
|
+
if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
|
|
813
|
+
model = target;
|
|
814
|
+
}
|
|
815
|
+
} else if (thinkTier === 'quick' && provider === 'claude') {
|
|
816
|
+
// quick → sonnet is sufficient
|
|
817
|
+
const target = 'sonnet';
|
|
818
|
+
const currentIdx = claudeRankAsc.indexOf(model);
|
|
819
|
+
const targetIdx = claudeRankAsc.indexOf(target);
|
|
820
|
+
if (targetIdx !== -1 && targetIdx < currentIdx && available.claude.includes(target)) {
|
|
821
|
+
model = target;
|
|
822
|
+
}
|
|
823
|
+
} else if (thinkTier === 'quick' && provider === 'openai') {
|
|
824
|
+
const target = 'gpt-4o';
|
|
825
|
+
const currentIdx = openaiRankAsc.indexOf(model);
|
|
826
|
+
const targetIdx = openaiRankAsc.indexOf(target);
|
|
827
|
+
if (targetIdx !== -1 && targetIdx < currentIdx && available.openai.includes(target)) {
|
|
828
|
+
model = target;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// 'standard', 'deep', 'ultra' — leave model unchanged; existing routing already picked correctly
|
|
832
|
+
}
|
|
833
|
+
|
|
789
834
|
// Safety floor: critical-risk tasks must never use haiku/gpt-4.1-mini even in cost-saver mode
|
|
790
835
|
model = applyCriticalRiskFloor(model, provider, available[provider], detection.risk);
|
|
791
836
|
|
package/src/pipeline.mjs
CHANGED
|
@@ -72,6 +72,10 @@ export function createPipelineRun(trigger = '', prompt = '') {
|
|
|
72
72
|
environment: null, // from scanEnvironment
|
|
73
73
|
modelSuggestion: null, // from suggestModel
|
|
74
74
|
|
|
75
|
+
// Think-engine fields
|
|
76
|
+
thinkResult: null, // from think-engine
|
|
77
|
+
decisionPreflight: null, // from lookupDecision
|
|
78
|
+
|
|
75
79
|
completedAt: null,
|
|
76
80
|
};
|
|
77
81
|
}
|
|
@@ -385,7 +389,7 @@ export function buildExecutionPlan(contextPack, trigger, options = {}) {
|
|
|
385
389
|
effort: depthToEffort[reasoningDepth] ?? detection.effort,
|
|
386
390
|
};
|
|
387
391
|
|
|
388
|
-
const decision = decideRoute({ profile, detection: detectionWithDepth, cwd: contextPack.cwd });
|
|
392
|
+
const decision = decideRoute({ profile, detection: detectionWithDepth, cwd: contextPack.cwd, thinkResult: options.thinkResult });
|
|
389
393
|
|
|
390
394
|
// Resolve full model ID for display (mirrors dispatch.mjs CLAUDE_MODEL_IDS)
|
|
391
395
|
const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
|
@@ -727,6 +731,34 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
727
731
|
// awareness not available
|
|
728
732
|
}
|
|
729
733
|
|
|
734
|
+
// Knowledge preflight — check if we already know the answer
|
|
735
|
+
try {
|
|
736
|
+
const { lookupDecision, triageQuestion } = await import('./think-engine.mjs');
|
|
737
|
+
const cwd = options.cwd || process.cwd();
|
|
738
|
+
|
|
739
|
+
run.decisionPreflight = lookupDecision(prompt, options.tags || [], cwd);
|
|
740
|
+
|
|
741
|
+
// If exact reuse found, we can short-circuit
|
|
742
|
+
if (run.decisionPreflight.recommendation === 'reuse' && run.decisionPreflight.candidates[0]) {
|
|
743
|
+
// Add cached decision info to situation brief
|
|
744
|
+
if (run.situationBrief) {
|
|
745
|
+
run.situationBrief += '\nCACHED DECISION: Found prior decision with ' +
|
|
746
|
+
Math.round(run.decisionPreflight.candidates[0].relevance * 100) + '% relevance';
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Triage to determine thinking tier
|
|
751
|
+
const triage = triageQuestion(prompt, run.projectBrief, run.decisionPreflight);
|
|
752
|
+
run.thinkResult = { tier: triage.recommendedTier, estimatedTokens: triage.estimatedTokens, triage };
|
|
753
|
+
|
|
754
|
+
// Add to situation brief
|
|
755
|
+
if (run.situationBrief) {
|
|
756
|
+
run.situationBrief += '\nTHINK TIER: ' + triage.recommendedTier + ' (' + triage.estimatedTokens + ' tokens est.)';
|
|
757
|
+
}
|
|
758
|
+
} catch (e) {
|
|
759
|
+
// think-engine not available
|
|
760
|
+
}
|
|
761
|
+
|
|
730
762
|
// Prompt intelligence
|
|
731
763
|
try {
|
|
732
764
|
const { analyzePrompt, enrichPrompt, shouldBlock, getBlockReason } = await import('./prompt-intel.mjs');
|
|
@@ -793,7 +825,7 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
793
825
|
|
|
794
826
|
// ── Phase 2: Plan ─────────────────────────────────────────────────────────
|
|
795
827
|
|
|
796
|
-
run.plan = buildExecutionPlan(run.context, trigger, { forceDepth, forceChallenger });
|
|
828
|
+
run.plan = buildExecutionPlan(run.context, trigger, { forceDepth, forceChallenger, thinkResult: run.thinkResult });
|
|
797
829
|
|
|
798
830
|
// Model intelligence
|
|
799
831
|
try {
|
|
@@ -956,6 +988,23 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
956
988
|
return { success: false, gateFailure: 'outcome', reason: run.gates.outcome.reason, run };
|
|
957
989
|
}
|
|
958
990
|
|
|
991
|
+
// Persist decision for future recall
|
|
992
|
+
if (run.result && !run.result?.error) {
|
|
993
|
+
try {
|
|
994
|
+
const { persistDecision } = await import('./think-engine.mjs');
|
|
995
|
+
const cwd = options.cwd || process.cwd();
|
|
996
|
+
persistDecision(
|
|
997
|
+
prompt,
|
|
998
|
+
typeof run.result === 'string' ? run.result : JSON.stringify(run.result).slice(0, 1000),
|
|
999
|
+
run.thinkResult?.tier || 'standard',
|
|
1000
|
+
{ tags: options.tags || [], projectBrief: run.projectBrief },
|
|
1001
|
+
cwd
|
|
1002
|
+
);
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
// persist failed — non-blocking
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
959
1008
|
} catch (err) {
|
|
960
1009
|
log(`[pipeline] error in pipeline step: ${err.message}`);
|
|
961
1010
|
run.result = { status: 'error', error: err.message };
|
|
@@ -977,6 +1026,8 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
977
1026
|
promptAnalysis: run.promptAnalysis,
|
|
978
1027
|
environment: run.environment,
|
|
979
1028
|
modelSuggestion: run.modelSuggestion,
|
|
1029
|
+
thinkResult: run.thinkResult,
|
|
1030
|
+
decisionPreflight: run.decisionPreflight,
|
|
980
1031
|
// Legacy compatibility
|
|
981
1032
|
plan: run.plan,
|
|
982
1033
|
result: run.result,
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// think-engine.mjs — Adaptive thinking ladder: recall → triage → tier decision.
|
|
2
|
+
// Replaces fixed "always dual-brain" with knowledge preflight + heuristic classification.
|
|
3
|
+
// Zero network calls. All matching is keyword-based.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const DOCS_DIR = '.dual-brain';
|
|
9
|
+
const DECISIONS_FILE = 'decisions.jsonl';
|
|
10
|
+
|
|
11
|
+
const STOP_WORDS = new Set([
|
|
12
|
+
'a','an','the','and','or','but','in','on','at','to','for','of','with',
|
|
13
|
+
'by','from','is','it','its','be','as','are','was','were','been','has',
|
|
14
|
+
'have','had','do','does','did','will','would','could','should','may',
|
|
15
|
+
'might','shall','can','this','that','these','those','i','we','you',
|
|
16
|
+
'he','she','they','my','our','your','his','her','their','what','how',
|
|
17
|
+
'when','where','why','which','who','all','any','more','most','also',
|
|
18
|
+
'not','no','so','if','then','than','into','up','out','about','just',
|
|
19
|
+
'after','before','between','through','during','each','get','use',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const HARD_ESCALATION_KEYWORDS = [
|
|
23
|
+
'auth','credential','secret','token','security','migration','billing',
|
|
24
|
+
'payment','deploy production','delete','drop','force push','routing logic',
|
|
25
|
+
'dispatcher','pipeline gate',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const TIER_TOKENS = {
|
|
29
|
+
recall: 0,
|
|
30
|
+
quick: 2000,
|
|
31
|
+
standard: 8000,
|
|
32
|
+
deep: 20000,
|
|
33
|
+
ultra: 50000,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const TIER_COST = {
|
|
37
|
+
recall: 'zero',
|
|
38
|
+
quick: 'minimal',
|
|
39
|
+
standard: 'moderate',
|
|
40
|
+
deep: 'significant',
|
|
41
|
+
ultra: 'heavy',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function normalizeIntent(text) {
|
|
45
|
+
if (!text || typeof text !== 'string') return [];
|
|
46
|
+
return text
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
49
|
+
.split(/\s+/)
|
|
50
|
+
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decisionsPath(cwd) {
|
|
54
|
+
return join(cwd, DOCS_DIR, DECISIONS_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readDecisions(cwd) {
|
|
58
|
+
const path = decisionsPath(cwd);
|
|
59
|
+
if (!existsSync(path)) return [];
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(path, 'utf8');
|
|
62
|
+
return raw
|
|
63
|
+
.split('\n')
|
|
64
|
+
.filter(l => l.trim())
|
|
65
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getFreshness(timestamp) {
|
|
73
|
+
if (!timestamp) return 'stale';
|
|
74
|
+
const ageMs = Date.now() - new Date(timestamp).getTime();
|
|
75
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
76
|
+
if (ageDays < 7) return 'current';
|
|
77
|
+
if (ageDays < 30) return 'aging';
|
|
78
|
+
return 'stale';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function keywordOverlap(kwA, kwB) {
|
|
82
|
+
if (!kwA.length || !kwB.length) return 0;
|
|
83
|
+
const setA = new Set(kwA);
|
|
84
|
+
const matches = kwB.filter(w => setA.has(w)).length;
|
|
85
|
+
return matches / Math.max(kwA.length, kwB.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getApplicability(relevance, freshness) {
|
|
89
|
+
if (relevance > 0.8 && freshness === 'current') return 'exact_reuse';
|
|
90
|
+
if (relevance > 0.8 && freshness === 'aging') return 'reuse_with_validation';
|
|
91
|
+
if (relevance > 0.8 && freshness === 'stale') return 'stale';
|
|
92
|
+
if (relevance >= 0.4) return 'related_precedent';
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function lookupDecision(intent, tags = [], cwd = process.cwd()) {
|
|
97
|
+
const queryKw = normalizeIntent(intent);
|
|
98
|
+
const queryTags = tags.map(t => t.toLowerCase());
|
|
99
|
+
const decisions = readDecisions(cwd);
|
|
100
|
+
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (const dec of decisions) {
|
|
103
|
+
const decKw = dec.normalizedIntent
|
|
104
|
+
? dec.normalizedIntent.split(' ').filter(Boolean)
|
|
105
|
+
: normalizeIntent(dec.question || dec.decision || '');
|
|
106
|
+
|
|
107
|
+
let relevance = keywordOverlap(queryKw, decKw);
|
|
108
|
+
|
|
109
|
+
const decTags = (dec.tags || []).map(t => t.toLowerCase());
|
|
110
|
+
const tagMatch = queryTags.some(t => decTags.includes(t));
|
|
111
|
+
if (tagMatch) relevance = Math.min(1, relevance + 0.15);
|
|
112
|
+
|
|
113
|
+
if (relevance < 0.4) continue;
|
|
114
|
+
|
|
115
|
+
const freshness = getFreshness(dec.timestamp);
|
|
116
|
+
const applicability = getApplicability(relevance, freshness);
|
|
117
|
+
if (!applicability) continue;
|
|
118
|
+
|
|
119
|
+
candidates.push({ decision: dec, relevance, freshness, applicability });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
candidates.sort((a, b) => b.relevance - a.relevance);
|
|
123
|
+
|
|
124
|
+
const highRelevance = candidates.filter(c => c.relevance > 0.8);
|
|
125
|
+
let recommendation = 'new_thinking_needed';
|
|
126
|
+
|
|
127
|
+
if (highRelevance.length > 1) {
|
|
128
|
+
const decisions_set = highRelevance.map(c =>
|
|
129
|
+
normalizeIntent(typeof c.decision.decision === 'string' ? c.decision.decision : JSON.stringify(c.decision.decision)).join(' ')
|
|
130
|
+
);
|
|
131
|
+
const pairOverlap = keywordOverlap(
|
|
132
|
+
normalizeIntent(decisions_set[0]),
|
|
133
|
+
normalizeIntent(decisions_set[1])
|
|
134
|
+
);
|
|
135
|
+
if (pairOverlap < 0.3) {
|
|
136
|
+
for (const c of highRelevance) c.applicability = 'conflicting';
|
|
137
|
+
recommendation = 'new_thinking_needed';
|
|
138
|
+
} else if (candidates[0]?.applicability === 'exact_reuse') {
|
|
139
|
+
recommendation = 'reuse';
|
|
140
|
+
} else {
|
|
141
|
+
recommendation = 'validate';
|
|
142
|
+
}
|
|
143
|
+
} else if (candidates[0]?.applicability === 'exact_reuse') {
|
|
144
|
+
recommendation = 'reuse';
|
|
145
|
+
} else if (candidates[0]?.applicability === 'reuse_with_validation') {
|
|
146
|
+
recommendation = 'validate';
|
|
147
|
+
} else if (candidates.length > 0) {
|
|
148
|
+
recommendation = 'new_thinking_needed';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
found: candidates.length > 0,
|
|
153
|
+
candidates: candidates.slice(0, 5),
|
|
154
|
+
recommendation,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function detectRisk(question) {
|
|
159
|
+
const q = question.toLowerCase();
|
|
160
|
+
const critical = ['auth','credential','secret','token','security','billing','payment','force push','drop table','delete production'];
|
|
161
|
+
const high = ['migration','deploy production','routing logic','dispatcher','pipeline gate','delete','drop'];
|
|
162
|
+
const low = ['readme','doc','comment','explain','list','show','what is','how does'];
|
|
163
|
+
|
|
164
|
+
if (critical.some(k => q.includes(k))) return 'critical';
|
|
165
|
+
if (high.some(k => q.includes(k))) return 'high';
|
|
166
|
+
if (low.some(k => q.includes(k))) return 'low';
|
|
167
|
+
return 'medium';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function detectComplexity(question) {
|
|
171
|
+
const wordCount = question.trim().split(/\s+/).length;
|
|
172
|
+
const hasMultiStep = /and then|then also|first.*then|step \d|multiple|several|across|all/i.test(question);
|
|
173
|
+
const hasComparison = /vs|versus|compare|difference|between|trade.?off/i.test(question);
|
|
174
|
+
|
|
175
|
+
if (wordCount > 80 || (hasMultiStep && hasComparison)) return 'complex';
|
|
176
|
+
if (wordCount > 30 || hasMultiStep || hasComparison) return 'moderate';
|
|
177
|
+
return 'simple';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function detectNovelty(preflight) {
|
|
181
|
+
if (!preflight || !preflight.found) return 'novel';
|
|
182
|
+
if (preflight.recommendation === 'reuse') return 'known';
|
|
183
|
+
if (preflight.candidates?.some(c => c.applicability === 'related_precedent' || c.applicability === 'reuse_with_validation')) {
|
|
184
|
+
return 'variation';
|
|
185
|
+
}
|
|
186
|
+
return 'novel';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasHardEscalation(question) {
|
|
190
|
+
const q = question.toLowerCase();
|
|
191
|
+
return HARD_ESCALATION_KEYWORDS.some(k => q.includes(k));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function triageQuestion(question, projectBrief, preflight) {
|
|
195
|
+
const risk = detectRisk(question);
|
|
196
|
+
const complexity = detectComplexity(question);
|
|
197
|
+
const novelty = detectNovelty(preflight);
|
|
198
|
+
const hardEscalation = hasHardEscalation(question);
|
|
199
|
+
|
|
200
|
+
let recommendedTier;
|
|
201
|
+
let reason;
|
|
202
|
+
|
|
203
|
+
if (preflight?.recommendation === 'reuse') {
|
|
204
|
+
recommendedTier = 'recall';
|
|
205
|
+
reason = 'exact match found in decision log';
|
|
206
|
+
} else if (hardEscalation || risk === 'critical') {
|
|
207
|
+
recommendedTier = 'ultra';
|
|
208
|
+
reason = hardEscalation
|
|
209
|
+
? `hard escalation keyword detected`
|
|
210
|
+
: 'critical risk requires maximum deliberation';
|
|
211
|
+
} else if (preflight?.candidates?.some(c => c.applicability === 'conflicting')) {
|
|
212
|
+
recommendedTier = 'ultra';
|
|
213
|
+
reason = 'conflicting prior decisions require reconciliation';
|
|
214
|
+
} else if (risk === 'high' && (novelty === 'novel' || complexity === 'complex')) {
|
|
215
|
+
recommendedTier = 'deep';
|
|
216
|
+
reason = `high risk + ${novelty === 'novel' ? 'novel question' : 'complex scope'}`;
|
|
217
|
+
} else if (novelty === 'novel' && (risk === 'medium' || complexity === 'complex')) {
|
|
218
|
+
recommendedTier = 'standard';
|
|
219
|
+
reason = 'novel question with non-trivial risk or complexity';
|
|
220
|
+
} else if (novelty === 'variation' && risk === 'low') {
|
|
221
|
+
recommendedTier = 'quick';
|
|
222
|
+
reason = 'similar precedent found, low risk variation';
|
|
223
|
+
} else if (preflight?.candidates?.length > 0 && novelty !== 'novel') {
|
|
224
|
+
recommendedTier = 'quick';
|
|
225
|
+
reason = 'related precedent available, minor adaptation needed';
|
|
226
|
+
} else if (novelty === 'novel' && risk === 'low' && complexity === 'simple') {
|
|
227
|
+
recommendedTier = 'quick';
|
|
228
|
+
reason = 'novel but simple and low risk';
|
|
229
|
+
} else {
|
|
230
|
+
recommendedTier = 'standard';
|
|
231
|
+
reason = 'default tier for unclassified novel questions';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const riskRank = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
235
|
+
const tierRank = { recall: 0, quick: 1, standard: 2, deep: 3, ultra: 4 };
|
|
236
|
+
const minTierForRisk = { low: 'recall', medium: 'quick', high: 'deep', critical: 'ultra' };
|
|
237
|
+
const riskFloor = minTierForRisk[risk] ?? 'quick';
|
|
238
|
+
if (tierRank[recommendedTier] < tierRank[riskFloor]) {
|
|
239
|
+
recommendedTier = riskFloor;
|
|
240
|
+
reason += ` (escalated to ${riskFloor} by risk floor)`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const confidenceBase = novelty === 'known' ? 0.9
|
|
244
|
+
: novelty === 'variation' ? 0.75
|
|
245
|
+
: 0.6;
|
|
246
|
+
const confidence = Math.max(0.3, confidenceBase - (risk === 'critical' ? 0.2 : 0));
|
|
247
|
+
|
|
248
|
+
const estimatedTokens = TIER_TOKENS[recommendedTier] ?? 0;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
novelty,
|
|
252
|
+
risk,
|
|
253
|
+
complexity,
|
|
254
|
+
confidence,
|
|
255
|
+
recommendedTier,
|
|
256
|
+
reason,
|
|
257
|
+
estimatedTokens,
|
|
258
|
+
hardEscalation,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function think(question, options = {}, cwd = process.cwd()) {
|
|
263
|
+
const result = {
|
|
264
|
+
question,
|
|
265
|
+
startedAt: Date.now(),
|
|
266
|
+
tier: null,
|
|
267
|
+
phases: [],
|
|
268
|
+
answer: null,
|
|
269
|
+
tokensUsed: 0,
|
|
270
|
+
cost: 'minimal',
|
|
271
|
+
fromCache: false,
|
|
272
|
+
decision: null,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (!options.skipRecall) {
|
|
276
|
+
const preflight = lookupDecision(question, options.tags || [], cwd);
|
|
277
|
+
result.phases.push({ phase: 'recall', ...preflight });
|
|
278
|
+
|
|
279
|
+
if (preflight.recommendation === 'reuse' && preflight.candidates[0]) {
|
|
280
|
+
result.tier = 'recall';
|
|
281
|
+
result.answer = preflight.candidates[0].decision;
|
|
282
|
+
result.fromCache = true;
|
|
283
|
+
result.cost = 'zero';
|
|
284
|
+
result.tokensUsed = 0;
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const recallPhase = result.phases[0] ?? null;
|
|
290
|
+
const triage = triageQuestion(question, options.projectBrief, recallPhase);
|
|
291
|
+
result.phases.push({ phase: 'triage', ...triage });
|
|
292
|
+
result.tier = options.forceLevel || triage.recommendedTier;
|
|
293
|
+
|
|
294
|
+
result.tokensUsed = TIER_TOKENS[result.tier] ?? triage.estimatedTokens;
|
|
295
|
+
result.cost = TIER_COST[result.tier] ?? 'moderate';
|
|
296
|
+
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function persistDecision(question, answer, tier, options = {}, cwd = process.cwd()) {
|
|
301
|
+
const dir = join(cwd, DOCS_DIR);
|
|
302
|
+
if (!existsSync(dir)) {
|
|
303
|
+
mkdirSync(dir, { recursive: true });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const kw = normalizeIntent(question);
|
|
307
|
+
const normalizedIntent = kw.join(' ');
|
|
308
|
+
|
|
309
|
+
const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer);
|
|
310
|
+
const sentences = answerText.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
311
|
+
const rationale = sentences.slice(0, 3).map(s => s.trim()).filter(Boolean);
|
|
312
|
+
|
|
313
|
+
const autoTags = [];
|
|
314
|
+
const q = question.toLowerCase();
|
|
315
|
+
if (/auth|security|credential|secret|token/.test(q)) autoTags.push('security');
|
|
316
|
+
if (/migration|migrate|upgrade/.test(q)) autoTags.push('migration');
|
|
317
|
+
if (/architecture|design|structure|pattern/.test(q)) autoTags.push('architecture');
|
|
318
|
+
if (/test|spec|coverage/.test(q)) autoTags.push('testing');
|
|
319
|
+
if (/deploy|release|publish|production/.test(q)) autoTags.push('deployment');
|
|
320
|
+
if (/routing|dispatch|pipeline/.test(q)) autoTags.push('routing');
|
|
321
|
+
|
|
322
|
+
const tags = [...new Set([...(options.tags || []), ...autoTags])];
|
|
323
|
+
|
|
324
|
+
const contextSpecific = /this session|right now|current branch|today|temporary|one.?off/i.test(answerText);
|
|
325
|
+
const reusable = !contextSpecific;
|
|
326
|
+
|
|
327
|
+
const tokensUsed = options.tokensUsed ?? TIER_TOKENS[tier] ?? 0;
|
|
328
|
+
|
|
329
|
+
const now = new Date();
|
|
330
|
+
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
331
|
+
|
|
332
|
+
const confScore = options.confidence ?? (
|
|
333
|
+
tier === 'ultra' || tier === 'deep' ? 'high'
|
|
334
|
+
: tier === 'standard' ? 'medium'
|
|
335
|
+
: 'low'
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const entry = {
|
|
339
|
+
id: `dec_${Date.now()}`,
|
|
340
|
+
timestamp: now.toISOString(),
|
|
341
|
+
question,
|
|
342
|
+
normalizedIntent,
|
|
343
|
+
decision: answerText,
|
|
344
|
+
rationale,
|
|
345
|
+
tags,
|
|
346
|
+
confidence: typeof confScore === 'string' ? confScore : (confScore > 0.7 ? 'high' : confScore > 0.4 ? 'medium' : 'low'),
|
|
347
|
+
tier,
|
|
348
|
+
tokensUsed,
|
|
349
|
+
expiresAt,
|
|
350
|
+
reusable,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
appendFileSync(join(dir, DECISIONS_FILE), JSON.stringify(entry) + '\n');
|
|
354
|
+
return entry;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function getThinkingStats(cwd = process.cwd()) {
|
|
358
|
+
const decisions = readDecisions(cwd);
|
|
359
|
+
if (!decisions.length) {
|
|
360
|
+
return {
|
|
361
|
+
totalDecisions: 0,
|
|
362
|
+
cacheHits: 0,
|
|
363
|
+
cacheHitRate: 0,
|
|
364
|
+
tierDistribution: { recall: 0, quick: 0, standard: 0, deep: 0, ultra: 0 },
|
|
365
|
+
totalTokensSaved: 0,
|
|
366
|
+
avgTier: 'none',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const tierDist = { recall: 0, quick: 0, standard: 0, deep: 0, ultra: 0 };
|
|
371
|
+
let cacheHits = 0;
|
|
372
|
+
let totalTokensSaved = 0;
|
|
373
|
+
const tierCounts = {};
|
|
374
|
+
|
|
375
|
+
for (const dec of decisions) {
|
|
376
|
+
const t = dec.tier ?? 'standard';
|
|
377
|
+
if (tierDist[t] !== undefined) tierDist[t]++;
|
|
378
|
+
tierCounts[t] = (tierCounts[t] ?? 0) + 1;
|
|
379
|
+
|
|
380
|
+
if (t === 'recall') {
|
|
381
|
+
cacheHits++;
|
|
382
|
+
totalTokensSaved += TIER_TOKENS.standard;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const cacheHitRate = decisions.length > 0 ? cacheHits / decisions.length : 0;
|
|
387
|
+
|
|
388
|
+
let maxCount = 0;
|
|
389
|
+
let avgTier = 'standard';
|
|
390
|
+
for (const [tier, count] of Object.entries(tierCounts)) {
|
|
391
|
+
if (count > maxCount) { maxCount = count; avgTier = tier; }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
totalDecisions: decisions.length,
|
|
396
|
+
cacheHits,
|
|
397
|
+
cacheHitRate: Math.round(cacheHitRate * 1000) / 1000,
|
|
398
|
+
tierDistribution: tierDist,
|
|
399
|
+
totalTokensSaved,
|
|
400
|
+
avgTier,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function formatThinkResult(result) {
|
|
405
|
+
const { tier, phases, cost, fromCache, tokensUsed } = result;
|
|
406
|
+
|
|
407
|
+
const tierLabel = tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : 'Unknown';
|
|
408
|
+
const tokenStr = tokensUsed > 0 ? `${(tokensUsed / 1000).toFixed(0)}K tokens estimated` : 'zero tokens';
|
|
409
|
+
|
|
410
|
+
const lines = [`THINKING: ${tierLabel} tier (${tokenStr})`];
|
|
411
|
+
|
|
412
|
+
for (const phase of phases ?? []) {
|
|
413
|
+
if (phase.phase === 'recall') {
|
|
414
|
+
const count = phase.candidates?.length ?? 0;
|
|
415
|
+
const found = count > 0
|
|
416
|
+
? `${count} related precedent${count === 1 ? '' : 's'} found`
|
|
417
|
+
: 'no prior decisions found';
|
|
418
|
+
lines.push(` Phase 1: Recall — ${found}`);
|
|
419
|
+
} else if (phase.phase === 'triage') {
|
|
420
|
+
lines.push(` Phase 2: Triage — ${phase.novelty ?? 'novel'} question, ${phase.risk ?? 'medium'} risk`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
lines.push(` Cost: ${cost ?? 'unknown'}`);
|
|
425
|
+
if (fromCache) lines.push(' Source: decision cache (no model call needed)');
|
|
426
|
+
|
|
427
|
+
return lines.join('\n');
|
|
428
|
+
}
|