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.
@@ -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.29",
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
+ }