dual-brain 0.1.22 → 0.2.0
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 +676 -265
- package/package.json +16 -2
- package/src/awareness.mjs +343 -0
- package/src/calibration.mjs +148 -0
- package/src/cost-tracker.mjs +184 -0
- package/src/decide.mjs +162 -10
- package/src/dispatch.mjs +40 -2
- package/src/doctor.mjs +716 -1
- package/src/fx.mjs +276 -0
- package/src/intelligence.mjs +423 -0
- package/src/ledger.mjs +196 -0
- package/src/living-docs.mjs +210 -0
- package/src/models.mjs +363 -0
- package/src/pipeline.mjs +367 -8
- package/src/prompt-intel.mjs +325 -0
- package/src/think-engine.mjs +428 -0
|
@@ -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
|
+
}
|