dual-brain 7.1.2 → 7.1.4

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.
@@ -1,468 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * context-guard.mjs — Keep the head (Opus) context window clean.
4
- *
5
- * The head orchestrates work but must never bloat its context with raw agent
6
- * output, large code blocks, or verbose analysis. This module provides helpers
7
- * to compress, summarize, and route information appropriately.
8
- *
9
- * Exports:
10
- * compressAgentResult(result, maxLength?) — strip noise, return tight summary
11
- * buildHandoff(fromAgent, toAgent, ctx) — minimal inter-agent payload
12
- * estimateContextCost(message) — token estimate + routing hint
13
- * formatHeadUpdate(agentType, taskId, result) — 1-line head-visible status
14
- * shouldDelegate(description, ctxSize) — inline vs delegate decision
15
- * buildAgentPipeline(intent, risk, cplx) — ordered agent type list
16
- *
17
- * CLI: node hooks/context-guard.mjs --estimate "some long text here"
18
- */
19
-
20
- import { classifyTask, INTENTS } from './task-classifier.mjs';
21
-
22
- // ─── Constants ────────────────────────────────────────────────────────────────
23
-
24
- // Rough heuristic: average English token is ~4 chars (GPT/Claude tokenizers)
25
- const CHARS_PER_TOKEN = 4;
26
-
27
- // Context thresholds (in tokens)
28
- const INLINE_LIMIT = 500; // small enough to paste into head context
29
- const SUMMARIZE_LIMIT = 2000; // compress before showing to head
30
- // anything above SUMMARIZE_LIMIT → delegate entirely
31
-
32
- // Patterns to strip from raw agent output
33
- const STRIP_PATTERNS = [
34
- /```[\s\S]*?```/g, // fenced code blocks
35
- /`[^`\n]{10,}`/g, // long inline code
36
- /^\s*(at\s+\S+\s+\(.*\).*$)/gm, // stack trace lines
37
- /^\s*Error:\s+.+\n(\s{2,}.+\n)*/gm, // error + indented detail
38
- /\n{3,}/g, // triple+ blank lines → double
39
- /^\s*\d+\s*[|│]\s*/gm, // line-number prefixes from cat -n style output
40
- /^(DEBUG|TRACE|VERBOSE):.+$/gim, // debug log lines
41
- ];
42
-
43
- // Words that signal a blocker/failure in free-form output
44
- const BLOCKER_PATTERNS = /\b(error|fail(?:ed|ure)?|exception|blocked?|cannot|could not|unable|missing|not found|refused|rejected|timeout|abort)\b/i;
45
-
46
- // ─── Helpers ──────────────────────────────────────────────────────────────────
47
-
48
- /**
49
- * Strip noise from raw text: code blocks, stack traces, debug lines, etc.
50
- */
51
- function stripNoise(text) {
52
- let out = String(text || '');
53
- for (const pattern of STRIP_PATTERNS) {
54
- out = out.replace(pattern, pattern.source === '\\n{3,}' ? '\n\n' : ' ');
55
- }
56
- return out.trim();
57
- }
58
-
59
- /**
60
- * Extract first N sentences from cleaned text.
61
- */
62
- function firstSentences(text, n = 2) {
63
- const sentences = text
64
- .split(/(?<=[.!?])\s+/)
65
- .map(s => s.trim())
66
- .filter(s => s.length > 5);
67
- return sentences.slice(0, n).join(' ');
68
- }
69
-
70
- /**
71
- * Detect outcome (success / fail / partial) from raw text.
72
- */
73
- function detectOutcome(text) {
74
- const lower = text.toLowerCase();
75
- if (/\b(success(?:fully)?|completed?|done|all tests pass|no issues|lgtm)\b/.test(lower)) return 'success';
76
- if (BLOCKER_PATTERNS.test(lower)) return 'fail';
77
- return 'partial';
78
- }
79
-
80
- /**
81
- * Extract file paths mentioned in text (basic heuristic).
82
- */
83
- function extractMentionedFiles(text) {
84
- const matches = text.match(/\b[\w./\-]+\.\w{2,6}\b/g) || [];
85
- // Filter out noise (URLs, version strings, etc.)
86
- return [...new Set(matches.filter(f =>
87
- !f.startsWith('http') && f.includes('/') || /\.(mjs|ts|js|json|md|py|go|rs|sh|yaml|yml|toml)$/.test(f)
88
- ))].slice(0, 10);
89
- }
90
-
91
- /**
92
- * Extract key decisions from text — lines starting with decision verbs or
93
- * "chose / decided / picked / will use" patterns.
94
- */
95
- function extractKeyDecisions(text) {
96
- const decisionRe = /^.{0,40}(chose|decided|picked|will use|using|switched|moved to|adopted|recommended|selected).{0,120}/im;
97
- const bullets = text.match(/^[-*•]\s+.{10,100}/gm) || [];
98
- const inline = text.match(decisionRe) || [];
99
- return [...inline.map(s => s.trim()), ...bullets.map(s => s.replace(/^[-*•]\s+/, ''))].slice(0, 5);
100
- }
101
-
102
- /**
103
- * Find open questions in the text.
104
- */
105
- function extractOpenQuestions(text) {
106
- return (text.match(/[A-Z][^?.!]*\?/g) || [])
107
- .map(q => q.trim())
108
- .filter(q => q.length > 15 && q.length < 150)
109
- .slice(0, 3);
110
- }
111
-
112
- // ─── Core Functions ───────────────────────────────────────────────────────────
113
-
114
- /**
115
- * Compress a raw agent result to at most `maxLength` characters.
116
- * Strips code blocks, stack traces, and verbose explanations.
117
- * Returns an object rather than a string so callers get structured data too.
118
- *
119
- * @param {string|object} result Raw agent output (string or object with .output)
120
- * @param {number} maxLength Character cap for the summary field (default 300)
121
- * @returns {{ outcome, summary, filesAffected, keyDecisions, blockers, originalLength }}
122
- */
123
- function compressAgentResult(result, maxLength = 300) {
124
- const raw = typeof result === 'string'
125
- ? result
126
- : (result?.output ?? result?.message ?? result?.text ?? JSON.stringify(result));
127
-
128
- const originalLength = raw.length;
129
- const cleaned = stripNoise(raw);
130
- const outcome = detectOutcome(raw);
131
- const filesAffected = extractMentionedFiles(raw);
132
- const keyDecisions = extractKeyDecisions(cleaned);
133
-
134
- // Blocker extraction: grab the first matching sentence
135
- const blockerMatch = raw.match(new RegExp(BLOCKER_PATTERNS.source + '.{0,200}', 'i'));
136
- const blockers = blockerMatch ? [blockerMatch[0].slice(0, 120).trim()] : [];
137
-
138
- // Summary: take the first 2 sentences of cleaned text, then truncate
139
- let summary = firstSentences(cleaned, 2);
140
- if (!summary && cleaned.length > 0) summary = cleaned.slice(0, 150);
141
- if (summary.length > maxLength) summary = summary.slice(0, maxLength - 1) + '…';
142
-
143
- return { outcome, summary, filesAffected, keyDecisions, blockers, originalLength };
144
- }
145
-
146
- /**
147
- * Build a minimal handoff payload from one agent to the next.
148
- * Only carries what the next agent actually needs — not the full prior output.
149
- *
150
- * @param {string} fromAgent e.g. 'researcher', 'planner', 'worker'
151
- * @param {string} toAgent e.g. 'worker', 'reviewer', 'tester'
152
- * @param {object} context Raw output or structured result from fromAgent
153
- * @returns {{ summary, keyDecisions, filesAffected, constraints, openQuestions }}
154
- */
155
- function buildHandoff(fromAgent, toAgent, context) {
156
- const compressed = compressAgentResult(context, 400);
157
-
158
- // Derive constraints: things the next agent must respect
159
- const raw = typeof context === 'string' ? context : JSON.stringify(context);
160
- const constraintRe = /\b(must|should|cannot|don't|do not|never|always|required?|constraint)\b.{5,100}/gi;
161
- const constraints = (raw.match(constraintRe) || [])
162
- .map(s => s.trim().slice(0, 100))
163
- .slice(0, 4);
164
-
165
- return {
166
- from: fromAgent,
167
- to: toAgent,
168
- summary: compressed.summary,
169
- keyDecisions: compressed.keyDecisions,
170
- filesAffected: compressed.filesAffected,
171
- constraints,
172
- openQuestions: extractOpenQuestions(raw),
173
- outcome: compressed.outcome,
174
- blockers: compressed.blockers,
175
- };
176
- }
177
-
178
- /**
179
- * Estimate how many tokens a message would add to context.
180
- * Uses ~4 chars/token heuristic (close enough for routing decisions).
181
- *
182
- * @param {string} message
183
- * @returns {{ tokens, isHeavy, recommendation: 'inline'|'summarize'|'delegate' }}
184
- */
185
- function estimateContextCost(message) {
186
- const text = typeof message === 'string' ? message : JSON.stringify(message ?? '');
187
- const tokens = Math.ceil(text.length / CHARS_PER_TOKEN);
188
- const isHeavy = tokens > INLINE_LIMIT;
189
-
190
- let recommendation;
191
- if (tokens <= INLINE_LIMIT) {
192
- recommendation = 'inline';
193
- } else if (tokens <= SUMMARIZE_LIMIT) {
194
- recommendation = 'summarize';
195
- } else {
196
- recommendation = 'delegate';
197
- }
198
-
199
- return { tokens, chars: text.length, isHeavy, recommendation };
200
- }
201
-
202
- /**
203
- * Format a single-line status update for the head's context.
204
- * The head sees this — nothing more — when an agent finishes.
205
- *
206
- * Examples:
207
- * "worker:task-3 completed — 2 files changed, tests pass"
208
- * "brainstorm:task-1 done — 5 ideas, top pick: DAG scheduler"
209
- * "debugger:task-2 failed — TypeError in auth.mjs line 42"
210
- *
211
- * @param {string} agentType e.g. 'worker', 'brainstorm', 'debugger'
212
- * @param {string} taskId e.g. 'task-3'
213
- * @param {string|object} result Raw agent output
214
- * @returns {string}
215
- */
216
- function formatHeadUpdate(agentType, taskId, result) {
217
- const { outcome, summary, filesAffected, blockers } = compressAgentResult(result, 120);
218
-
219
- const prefix = `${agentType}:${taskId}`;
220
- const status = outcome === 'success' ? 'completed'
221
- : outcome === 'fail' ? 'failed'
222
- : 'partial';
223
-
224
- // Build a tight detail string
225
- const parts = [];
226
-
227
- if (filesAffected.length > 0) {
228
- parts.push(`${filesAffected.length} file${filesAffected.length > 1 ? 's' : ''} changed`);
229
- }
230
-
231
- if (outcome === 'fail' && blockers.length > 0) {
232
- parts.push(blockers[0].slice(0, 80));
233
- } else if (summary) {
234
- // Use summary but keep it very tight
235
- const tight = summary.replace(/\n/g, ' ').slice(0, 80);
236
- parts.push(tight);
237
- }
238
-
239
- const detail = parts.join(', ');
240
- return `${prefix} ${status}${detail ? ' — ' + detail : ''}`;
241
- }
242
-
243
- /**
244
- * Decide whether the head should handle a task inline or delegate it.
245
- *
246
- * Small read-only lookups under 100 chars are cheap enough to inline.
247
- * Anything involving analysis, code reading, or multi-step reasoning
248
- * should be delegated to preserve the head's context budget.
249
- *
250
- * @param {string} description Task description
251
- * @param {number} currentContextSize Estimated current context size in tokens
252
- * @returns {{ delegate: boolean, reason: string, recommendation: 'inline'|'delegate' }}
253
- */
254
- function shouldDelegate(description, currentContextSize = 0) {
255
- const desc = String(description || '');
256
-
257
- // Short read-only queries are cheap
258
- const isShort = desc.length < 100;
259
- const readOnlyRe = /\b(what|where|list|show|find|which|how many|does|is|are|check)\b/i;
260
- const isReadOnly = readOnlyRe.test(desc);
261
-
262
- // Signals that demand delegation
263
- const analysisRe = /\b(analyze|analyse|read|review|refactor|implement|write|build|fix|debug|test|compare|explain|design)\b/i;
264
- const multiStepRe = /\b(and (also|then)|also|then|after (that|which)|step \d|first .* then)\b/i;
265
- const isAnalytic = analysisRe.test(desc);
266
- const isMultiStep = multiStepRe.test(desc);
267
-
268
- // Context pressure: if head context is already large, be more aggressive
269
- const contextHeavy = currentContextSize > 4000; // tokens
270
-
271
- // Decision logic
272
- if (isAnalytic || isMultiStep || contextHeavy) {
273
- const reasons = [];
274
- if (isAnalytic) reasons.push('requires analysis/code work');
275
- if (isMultiStep) reasons.push('multi-step');
276
- if (contextHeavy) reasons.push(`context at ${currentContextSize} tokens`);
277
- return { delegate: true, recommendation: 'delegate', reason: reasons.join(', ') };
278
- }
279
-
280
- if (isShort && isReadOnly) {
281
- return { delegate: false, recommendation: 'inline', reason: 'short read-only query' };
282
- }
283
-
284
- // Default: delegate anything ambiguous to protect head context
285
- return { delegate: true, recommendation: 'delegate', reason: 'ambiguous — defaulting to delegate for safety' };
286
- }
287
-
288
- /**
289
- * Given a task profile, return an ordered list of agent types to run.
290
- * Agents are named by role, not model. The orchestrator maps roles to models.
291
- *
292
- * Pipeline examples:
293
- * refactor auth module → ['analyst', 'research', 'planner', 'worker', 'reviewer']
294
- * what's the best approach for X → ['brainstorm']
295
- * fix this bug → ['debugger', 'worker', 'tester']
296
- * add comprehensive tests → ['research', 'tester', 'reviewer']
297
- *
298
- * @param {string} intent From task-classifier INTENTS keys
299
- * @param {string} risk 'low' | 'medium' | 'high' | 'critical'
300
- * @param {string} complexity 'trivial' | 'simple' | 'moderate' | 'complex'
301
- * @returns {string[]} Ordered agent type names
302
- */
303
- function buildAgentPipeline(intent, risk, complexity) {
304
- const LEVEL_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
305
- const riskLevel = LEVEL_ORDER[risk] ?? 0;
306
- const cplxLevel = { trivial: 0, simple: 1, moderate: 2, complex: 3 }[complexity] ?? 1;
307
-
308
- const isCritical = riskLevel >= 3;
309
- const isComplex = cplxLevel >= 2;
310
- const isHighRisk = riskLevel >= 2;
311
- const needsReview = isHighRisk || isComplex || isCritical;
312
-
313
- let pipeline;
314
-
315
- switch (intent) {
316
- // ── Pure thinking / ideation ──
317
- case 'architecture':
318
- case 'planning':
319
- case 'compare':
320
- pipeline = ['brainstorm', 'planner'];
321
- break;
322
-
323
- // ── Explain / document: look up then write ──
324
- case 'explain':
325
- case 'document':
326
- pipeline = ['research', 'worker'];
327
- break;
328
-
329
- // ── Search / format: lightweight, no review needed ──
330
- case 'search':
331
- pipeline = ['research'];
332
- break;
333
-
334
- case 'format':
335
- pipeline = ['worker'];
336
- break;
337
-
338
- // ── Debug: diagnose → fix → verify ──
339
- case 'debug':
340
- pipeline = ['debugger', 'worker', 'tester'];
341
- break;
342
-
343
- // ── Test: understand existing code, write tests, review coverage ──
344
- case 'test':
345
- pipeline = ['research', 'tester', 'reviewer'];
346
- break;
347
-
348
- // ── Review / audit: read → assess ──
349
- case 'review':
350
- case 'security':
351
- pipeline = ['research', 'reviewer'];
352
- break;
353
-
354
- // ── Refactor: plan before touching anything ──
355
- case 'refactor':
356
- pipeline = ['research', 'planner', 'worker', 'reviewer'];
357
- break;
358
-
359
- // ── Default edit: search → implement ──
360
- case 'edit':
361
- default:
362
- pipeline = isComplex
363
- ? ['research', 'planner', 'worker']
364
- : ['worker'];
365
- break;
366
- }
367
-
368
- // Prepend 'analyst' for complex or critical work (up-front risk analysis)
369
- if (isCritical || (isComplex && needsReview)) {
370
- if (pipeline[0] !== 'analyst') pipeline.unshift('analyst');
371
- }
372
-
373
- // Append 'reviewer' for high-risk / complex work (if not already present)
374
- if (needsReview && !pipeline.includes('reviewer')) {
375
- pipeline.push('reviewer');
376
- }
377
-
378
- return pipeline;
379
- }
380
-
381
- // ─── CLI ──────────────────────────────────────────────────────────────────────
382
-
383
- if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
384
- const args = process.argv.slice(2);
385
- const flag = args[0];
386
- const value = args.slice(1).join(' ') || args[0];
387
-
388
- if (flag === '--estimate') {
389
- const text = args.slice(1).join(' ');
390
- if (!text) {
391
- console.error('Usage: node hooks/context-guard.mjs --estimate "some text"');
392
- process.exit(1);
393
- }
394
- const result = estimateContextCost(text);
395
- console.log(JSON.stringify(result, null, 2));
396
-
397
- } else if (flag === '--compress') {
398
- const text = args.slice(1).join(' ');
399
- if (!text) {
400
- console.error('Usage: node hooks/context-guard.mjs --compress "agent output..."');
401
- process.exit(1);
402
- }
403
- const result = compressAgentResult(text);
404
- console.log(JSON.stringify(result, null, 2));
405
-
406
- } else if (flag === '--pipeline') {
407
- // node hooks/context-guard.mjs --pipeline "refactor auth" [--risk high] [--complexity complex]
408
- const descParts = [];
409
- let risk = 'medium';
410
- let complexity = 'moderate';
411
-
412
- for (let i = 1; i < args.length; i++) {
413
- if (args[i] === '--risk') { risk = args[++i]; }
414
- else if (args[i] === '--complexity') { complexity = args[++i]; }
415
- else descParts.push(args[i]);
416
- }
417
-
418
- const description = descParts.join(' ');
419
- if (!description && descParts.length === 0) {
420
- console.error('Usage: node hooks/context-guard.mjs --pipeline "description" [--risk medium] [--complexity moderate]');
421
- process.exit(1);
422
- }
423
-
424
- // If a full description was given, derive intent from task-classifier
425
- let intent = 'edit';
426
- if (description) {
427
- const profile = classifyTask(description);
428
- intent = profile.intent;
429
- risk = profile.risk;
430
- complexity = profile.complexity;
431
- }
432
-
433
- const pipeline = buildAgentPipeline(intent, risk, complexity);
434
- console.log(JSON.stringify({ intent, risk, complexity, pipeline }, null, 2));
435
-
436
- } else if (flag === '--delegate') {
437
- const desc = args.slice(1).join(' ');
438
- const ctxArg = args.find(a => a.startsWith('--context='));
439
- const ctxSize = ctxArg ? parseInt(ctxArg.replace('--context=', ''), 10) : 0;
440
- const result = shouldDelegate(desc, ctxSize);
441
- console.log(JSON.stringify(result, null, 2));
442
-
443
- } else {
444
- console.log([
445
- 'context-guard.mjs — Head context management tools',
446
- '',
447
- 'Usage:',
448
- ' node hooks/context-guard.mjs --estimate "text" # token estimate + routing hint',
449
- ' node hooks/context-guard.mjs --compress "agent output..." # compress to head-safe summary',
450
- ' node hooks/context-guard.mjs --pipeline "task description" # build agent pipeline',
451
- ' node hooks/context-guard.mjs --delegate "task description" # inline vs delegate',
452
- '',
453
- 'Exports: compressAgentResult, buildHandoff, estimateContextCost,',
454
- ' formatHeadUpdate, shouldDelegate, buildAgentPipeline',
455
- ].join('\n'));
456
- }
457
- }
458
-
459
- // ─── Exports ──────────────────────────────────────────────────────────────────
460
-
461
- export {
462
- compressAgentResult,
463
- buildHandoff,
464
- estimateContextCost,
465
- formatHeadUpdate,
466
- shouldDelegate,
467
- buildAgentPipeline,
468
- };