sapper-iq 1.4.5 → 1.4.7

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper.mjs +648 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
package/sapper.mjs CHANGED
@@ -7,7 +7,7 @@ import chalk from 'chalk';
7
7
  import ora from 'ora';
8
8
  import readline from 'readline';
9
9
  import { fileURLToPath } from 'url';
10
- import { dirname, join, isAbsolute, resolve as pathResolve, extname } from 'path';
10
+ import { dirname, join, isAbsolute, resolve as pathResolve, relative, extname } from 'path';
11
11
  import { marked } from 'marked';
12
12
  import { markedTerminal } from 'marked-terminal';
13
13
  import { highlight as highlightCode } from 'cli-highlight';
@@ -650,6 +650,12 @@ const TOOL_NAME_MAP = {
650
650
  'open': 'OPEN',
651
651
  'browser': 'OPEN',
652
652
  'open_url': 'OPEN',
653
+ 'consult': 'CONSULT',
654
+ 'consultant': 'CONSULT',
655
+ 'consult_expert': 'CONSULT',
656
+ 'ask_expert': 'CONSULT',
657
+ 'expert': 'CONSULT',
658
+ 'advisor': 'CONSULT',
653
659
  'todo': 'LIST', // alias — list tasks
654
660
  };
655
661
 
@@ -676,6 +682,7 @@ const TOOL_ALLOWED_BY = {
676
682
  MEMORY: ['MEMORY'],
677
683
  OPEN: ['OPEN', 'SHELL'],
678
684
  SHELL: ['SHELL'],
685
+ CONSULT: ['CONSULT'],
679
686
  };
680
687
 
681
688
  function normalizeToolName(toolName = '') {
@@ -1143,6 +1150,30 @@ const DEFAULT_CONFIG = Object.freeze({
1143
1150
  archive: true, // Save every recording (audio + transcript) to disk
1144
1151
  archiveDir: '.sapper/voice', // Folder for archive (relative to cwd or absolute). A YYYY-MM-DD subfolder is auto-created.
1145
1152
  }),
1153
+ consultant: Object.freeze({
1154
+ // Heavyweight second-model advisor. The main agent must NOT call it casually:
1155
+ // every invocation requires a written summary + a specific question + supporting files.
1156
+ // Use it when the agent is stuck or about to make an important / hard-to-reverse change.
1157
+ enabled: true,
1158
+ model: 'juilpark/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-heretic:q4_k_m', // Default consultant model — change to any model you have via `ollama list`.
1159
+ contextLimit: null, // Tokens to give the consultant (null = use model default).
1160
+ temperature: 0.2, // Lower = more deliberate.
1161
+ thinking: 'on', // 'on' | 'off' | 'auto' — reasoning models should stay on.
1162
+ requireSummary: true, // Reject calls without a real summary.
1163
+ requireQuestion: true, // Reject calls without a concrete question.
1164
+ requireGoal: true, // Reject calls without a goal (what important thing is happening).
1165
+ minSummaryWords: 25, // Gate against drive-by calls.
1166
+ maxFiles: 20, // Max file attachments per consultation.
1167
+ maxFileBytes: 200000, // Per-file byte cap before truncation.
1168
+ totalBytesLimit: 800000, // Total bytes across all attached files.
1169
+ toolRoundLimit: 12, // How many tool rounds the consultant may run on its own.
1170
+ timeoutMs: 600000, // Hard wall-clock cap on a single consultation (10 min).
1171
+ tools: ['read', 'read_chunk', 'list', 'search', 'grep', 'find', 'regex', 'head', 'tail', 'cat', 'pwd', 'changes', 'fetch_web', 'recall_memory', 'search_memory_notes', 'read_memory_notes'], // Read-only subset by default — no write / patch / shell / open / mkdir / rmdir.
1172
+ saveTranscripts: true, // Persist every consultation to disk for audit.
1173
+ transcriptDir: '.sapper/consultations', // Where transcripts are written.
1174
+ verbose: true, // Print full request, each tool call/result, and final answer to the terminal during the consultation.
1175
+ systemPrompt: '', // Override the built-in consultant system prompt (empty = use default).
1176
+ }),
1146
1177
  prompt: Object.freeze({
1147
1178
  prepend: '',
1148
1179
  append: '',
@@ -1162,7 +1193,7 @@ RULES:
1162
1193
  5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`,
1163
1194
  nativeTools: `TOOLS:
1164
1195
  You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
1165
- Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, regex_search, read_chunk, pwd, cd, rmdir, changes, fetch_web, recall_memory, save_memory_note, search_memory_notes, read_memory_notes, open_url, run_shell.
1196
+ Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, regex_search, read_chunk, pwd, cd, rmdir, changes, fetch_web, recall_memory, save_memory_note, search_memory_notes, read_memory_notes, open_url, run_shell, consult_expert.
1166
1197
 
1167
1198
  PATCH TIPS:
1168
1199
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
@@ -1185,6 +1216,11 @@ EXTRA TOOL TIPS:
1185
1216
  - read_memory_notes reads the full markdown long-memory file.
1186
1217
  - open_url opens a URL in the default browser and always asks for approval.
1187
1218
 
1219
+ CONSULTANT (consult_expert):
1220
+ - consult_expert calls a separate, more capable model for advice. Use it ONLY when you are stuck after a real attempt OR before making an important / hard-to-reverse change (e.g. large refactor, schema change, security-sensitive code).
1221
+ - DO NOT call it casually. Every call requires: goal (what important thing you're doing), question (specific decision you need help with), summary (≥25 words of what you've explored and why you're stuck), and files (paths the consultant should read — use "path:start-end" for line ranges on large files).
1222
+ - The consultant is read-only. It will use its own tools to verify facts and will return a RECOMMENDATION / REASONING / RISKS / NEXT STEPS answer that you then act on.
1223
+
1188
1224
  SHELL TIPS:
1189
1225
  - run_shell may keep long-running commands in a background session depending on config.
1190
1226
  - If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
@@ -1217,6 +1253,7 @@ SHELL TIPS:
1217
1253
  - [TOOL:MEMORY_NOTE_READ][/TOOL] - Read markdown long memory file
1218
1254
  - [TOOL:OPEN]https://example.com[/TOOL] - Open a URL in the default browser (asks for approval)
1219
1255
  - [TOOL:SHELL]command[/TOOL] - Run shell command
1256
+ - [TOOL:CONSULT]goal:::question:::summary (>=25 words):::attempts:::hints:::file1,file2:start-end[/TOOL] - Call the heavyweight consultant model for advice. ONLY when stuck or before an important change. Or pass a JSON blob: [TOOL:CONSULT]{"goal":"...","question":"...","summary":"...","files":["src/x.ts:40-120"]}[/TOOL]
1220
1257
 
1221
1258
  PATCH TIPS:
1222
1259
  - PREFER the LINE:number mode when you know which line to change. It is much more reliable than text matching.
@@ -1514,6 +1551,52 @@ function normalizeVoiceConfig(voiceConfig = {}) {
1514
1551
  };
1515
1552
  }
1516
1553
 
1554
+ function normalizeConsultantConfig(consultantConfig = {}) {
1555
+ if (typeof consultantConfig === 'boolean') {
1556
+ return { ...DEFAULT_CONFIG.consultant, enabled: consultantConfig };
1557
+ }
1558
+ if (typeof consultantConfig === 'string') {
1559
+ return { ...DEFAULT_CONFIG.consultant, enabled: normalizeBoolean(consultantConfig, DEFAULT_CONFIG.consultant.enabled) };
1560
+ }
1561
+ if (!consultantConfig || typeof consultantConfig !== 'object' || Array.isArray(consultantConfig)) {
1562
+ return { ...DEFAULT_CONFIG.consultant };
1563
+ }
1564
+ const D = DEFAULT_CONFIG.consultant;
1565
+ const toolsList = Array.isArray(consultantConfig.tools)
1566
+ ? consultantConfig.tools.map(t => String(t || '').trim()).filter(Boolean)
1567
+ : (typeof consultantConfig.tools === 'string'
1568
+ ? consultantConfig.tools.split(',').map(t => t.trim()).filter(Boolean)
1569
+ : [...D.tools]);
1570
+ return {
1571
+ enabled: normalizeBoolean(consultantConfig.enabled, D.enabled),
1572
+ model: typeof consultantConfig.model === 'string' && consultantConfig.model.trim()
1573
+ ? consultantConfig.model.trim()
1574
+ : D.model,
1575
+ contextLimit: normalizeContextLimit(consultantConfig.contextLimit),
1576
+ temperature: (() => {
1577
+ const v = Number(consultantConfig.temperature);
1578
+ return Number.isFinite(v) && v >= 0 && v <= 2 ? v : D.temperature;
1579
+ })(),
1580
+ thinking: normalizeThinkingMode(consultantConfig.thinking),
1581
+ requireSummary: normalizeBoolean(consultantConfig.requireSummary, D.requireSummary),
1582
+ requireQuestion: normalizeBoolean(consultantConfig.requireQuestion, D.requireQuestion),
1583
+ requireGoal: normalizeBoolean(consultantConfig.requireGoal, D.requireGoal),
1584
+ minSummaryWords: normalizeIntegerInRange(consultantConfig.minSummaryWords, D.minSummaryWords, 0, 500),
1585
+ maxFiles: normalizeIntegerInRange(consultantConfig.maxFiles, D.maxFiles, 0, 200),
1586
+ maxFileBytes: normalizeIntegerInRange(consultantConfig.maxFileBytes, D.maxFileBytes, 1024, 10000000),
1587
+ totalBytesLimit: normalizeIntegerInRange(consultantConfig.totalBytesLimit, D.totalBytesLimit, 1024, 50000000),
1588
+ toolRoundLimit: normalizeIntegerInRange(consultantConfig.toolRoundLimit, D.toolRoundLimit, 0, 200),
1589
+ timeoutMs: normalizeIntegerInRange(consultantConfig.timeoutMs, D.timeoutMs, 10000, 3600000),
1590
+ tools: toolsList.length ? toolsList : [...D.tools],
1591
+ saveTranscripts: normalizeBoolean(consultantConfig.saveTranscripts, D.saveTranscripts),
1592
+ transcriptDir: typeof consultantConfig.transcriptDir === 'string' && consultantConfig.transcriptDir.trim()
1593
+ ? consultantConfig.transcriptDir.trim()
1594
+ : D.transcriptDir,
1595
+ verbose: normalizeBoolean(consultantConfig.verbose, D.verbose),
1596
+ systemPrompt: typeof consultantConfig.systemPrompt === 'string' ? consultantConfig.systemPrompt : D.systemPrompt,
1597
+ };
1598
+ }
1599
+
1517
1600
  function normalizePromptText(value) {
1518
1601
  if (typeof value === 'string') return value;
1519
1602
  if (value === null || value === undefined) return '';
@@ -1559,6 +1642,7 @@ function normalizeConfig(config = {}) {
1559
1642
  ui: normalizeUIConfig(config.ui),
1560
1643
  chunking: normalizeChunkingConfig(config.chunking),
1561
1644
  voice: normalizeVoiceConfig(config.voice),
1645
+ consultant: normalizeConsultantConfig(config.consultant),
1562
1646
  prompt: normalizePromptConfig(config.prompt),
1563
1647
  };
1564
1648
  }
@@ -1684,6 +1768,13 @@ function renderConfigFile(config) {
1684
1768
  lines.push(' // Trigger from the chat with /voice record [seconds] or /voice file <path>.');
1685
1769
  appendConfigProperty(lines, 'voice', config.voice);
1686
1770
 
1771
+ lines.push('');
1772
+ lines.push(' // Consultant tool — deliberate second-model advisor.');
1773
+ lines.push(' // The main agent calls it via consult_expert / [TOOL:CONSULT] when stuck or before important changes.');
1774
+ lines.push(' // Every call must include a summary, question, goal, and attached file paths. Calls are read-only by default.');
1775
+ lines.push(' // Tip: pick a stronger reasoning model than your main model — that is the whole point.');
1776
+ appendConfigProperty(lines, 'consultant', config.consultant);
1777
+
1687
1778
  lines.push('');
1688
1779
  lines.push(' // Prompt customization');
1689
1780
  lines.push(' // prompt.system.* controls the assistant system prompt blocks');
@@ -1905,6 +1996,14 @@ function voiceEnabled() {
1905
1996
  return getVoiceConfig().enabled;
1906
1997
  }
1907
1998
 
1999
+ function getConsultantConfig() {
2000
+ return normalizeConsultantConfig(sapperConfig.consultant);
2001
+ }
2002
+
2003
+ function consultantEnabled() {
2004
+ return getConsultantConfig().enabled;
2005
+ }
2006
+
1908
2007
  function streamPhaseStatusEnabled() {
1909
2008
  return getStreamingConfig().showPhaseStatus;
1910
2009
  }
@@ -3053,6 +3152,7 @@ const COMMAND_GROUPS = Object.freeze([
3053
3152
  ['/shell', 'Inspect shell config and background sessions'],
3054
3153
  ['/shell read <id>', 'Read output from a tracked shell session'],
3055
3154
  ['/shell stop <id>', 'Stop a tracked shell session'],
3155
+ ['/consult', 'Show or configure the heavyweight consultant tool (separate model)'],
3056
3156
  ['/context', 'Inspect token usage, summary trigger, and model window'],
3057
3157
  ['/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'],
3058
3158
  ['/debug', 'Toggle regex and tool debug output'],
@@ -5572,6 +5672,399 @@ function keywordRecallMemory(query, embeddings, topK = 3) {
5572
5672
  .slice(0, topK);
5573
5673
  }
5574
5674
 
5675
+ // ─────────────────────────────────────────────────────────────────
5676
+ // CONSULTANT TOOL — deliberate second-model advisor
5677
+ // The main agent calls this when stuck or before an important change.
5678
+ // Read-only by default; runs in its own ollama session with its own
5679
+ // tool budget. All settings live under sapperConfig.consultant.
5680
+ // ─────────────────────────────────────────────────────────────────
5681
+
5682
+ const DEFAULT_CONSULTANT_SYSTEM_PROMPT = `You are a senior consulting engineer brought in to advise on a stuck or high-stakes situation. A junior agent has called you with a summary, attached files, and a specific question.
5683
+
5684
+ Your job:
5685
+ - Read the context carefully. Do not restate the question back.
5686
+ - Use your read-only tools (read_file, list_directory, search_files, regex_search, read_chunk, head, tail, cat, pwd, changes, fetch_web, recall_memory, search_memory_notes, read_memory_notes) to verify facts in the codebase before answering. Never guess.
5687
+ - Return a precise, actionable recommendation.
5688
+
5689
+ You are READ-ONLY: you cannot write, patch, run shell commands, open URLs, or modify state. Your only outputs are tool reads and your final advice.
5690
+
5691
+ Format your final answer EXACTLY as:
5692
+
5693
+ RECOMMENDATION
5694
+ <1-3 sentences \u2014 the bottom line>
5695
+
5696
+ REASONING
5697
+ <what evidence in the code/context supports the recommendation, referencing file:line>
5698
+
5699
+ RISKS
5700
+ <what could go wrong, edge cases to watch>
5701
+
5702
+ NEXT STEPS
5703
+ 1. <concrete action the calling agent should take>
5704
+ 2. <\u2026>
5705
+
5706
+ Be brief but specific. No preamble. No restating the question. Reference exact file paths and line numbers.`;
5707
+
5708
+ function buildConsultantNativeTools(allowedSet) {
5709
+ // Compact read-only tool defs the consultant can call.
5710
+ const defs = [
5711
+ { name: 'list_directory', mapsTo: 'LIST', def: { description: 'List the contents of a directory.', parameters: { type: 'object', properties: { path: { type: 'string' } } } } },
5712
+ { name: 'read_file', mapsTo: 'READ', def: { description: 'Read the full contents of a file.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
5713
+ { name: 'read_chunk', mapsTo: 'READ_CHUNK', def: { description: 'Read a specific line range from a file.', parameters: { type: 'object', properties: { path: { type: 'string' }, start: { type: 'number' }, end: { type: 'number' }, context: { type: 'number' } }, required: ['path', 'start'] } } },
5714
+ { name: 'search_files', mapsTo: 'SEARCH', def: { description: 'Search for a pattern across project files.', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } },
5715
+ { name: 'grep', mapsTo: 'GREP', def: { description: 'Alias for search_files.', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } },
5716
+ { name: 'find', mapsTo: 'FIND', def: { description: 'Find files or directories by name.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } } },
5717
+ { name: 'regex_search', mapsTo: 'REGEX', def: { description: 'Regex search across source code.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, include: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } } },
5718
+ { name: 'head', mapsTo: 'HEAD', def: { description: 'Read first N lines of a file.', parameters: { type: 'object', properties: { path: { type: 'string' }, lines: { type: 'number' } }, required: ['path'] } } },
5719
+ { name: 'tail', mapsTo: 'TAIL', def: { description: 'Read last N lines of a file.', parameters: { type: 'object', properties: { path: { type: 'string' }, lines: { type: 'number' } }, required: ['path'] } } },
5720
+ { name: 'cat', mapsTo: 'CAT', def: { description: 'Read the full contents of a file.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
5721
+ { name: 'pwd', mapsTo: 'PWD', def: { description: 'Show current tool working directory.', parameters: { type: 'object', properties: {} } } },
5722
+ { name: 'changes', mapsTo: 'CHANGES', def: { description: 'Show git status and diffs.', parameters: { type: 'object', properties: { path: { type: 'string' } } } } },
5723
+ { name: 'fetch_web', mapsTo: 'FETCH', def: { description: 'Fetch a web page and return readable text.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } },
5724
+ { name: 'recall_memory', mapsTo: 'MEMORY', def: { description: 'Search saved conversation memory.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
5725
+ { name: 'search_memory_notes', mapsTo: 'MEMORY', def: { description: 'Search markdown long-memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
5726
+ { name: 'read_memory_notes', mapsTo: 'MEMORY', def: { description: 'Read markdown long-memory file.', parameters: { type: 'object', properties: {} } } },
5727
+ ];
5728
+ return defs
5729
+ .filter(t => allowedSet.has(t.mapsTo))
5730
+ .map(t => ({ type: 'function', function: { name: t.name, description: t.def.description, parameters: t.def.parameters } }));
5731
+ }
5732
+
5733
+ async function runConsultantToolCall(fnName, args) {
5734
+ const a = args || {};
5735
+ switch (fnName) {
5736
+ case 'list_directory': return tools.list(a.path ?? '.');
5737
+ case 'read_file': return tools.read(a.path);
5738
+ case 'read_chunk': return tools.read_chunk(a.path, a.start, a.end, a.context);
5739
+ case 'search_files':
5740
+ case 'grep': return await tools.search(a.pattern);
5741
+ case 'find': return tools.find(a.pattern, a.path ?? '.');
5742
+ case 'regex_search': return tools.regex(a.pattern, a.include ?? '', a.path ?? '.');
5743
+ case 'head': return tools.head(a.path, a.lines);
5744
+ case 'tail': return tools.tail(a.path, a.lines);
5745
+ case 'cat': return tools.cat(a.path);
5746
+ case 'pwd': return tools.pwd();
5747
+ case 'changes': return await tools.changes(a.path);
5748
+ case 'fetch_web': return await tools.fetch_web(a.url);
5749
+ case 'recall_memory': return await tools.recall_memory(a.query);
5750
+ case 'search_memory_notes': return await tools.search_memory_notes(a.query);
5751
+ case 'read_memory_notes': return await tools.read_memory_notes();
5752
+ default: return `Consultant tool not available: ${fnName}`;
5753
+ }
5754
+ }
5755
+
5756
+ function parseConsultantFilesArg(filesArg) {
5757
+ if (!filesArg) return [];
5758
+ if (Array.isArray(filesArg)) return filesArg.map(s => String(s).trim()).filter(Boolean);
5759
+ return String(filesArg).split(/[,\n]/).map(s => s.trim()).filter(Boolean);
5760
+ }
5761
+
5762
+ function attachConsultantFiles(fileEntries, cfg) {
5763
+ const attachments = [];
5764
+ let totalBytes = 0;
5765
+ for (const entry of fileEntries.slice(0, cfg.maxFiles)) {
5766
+ // Parse "path:start-end" or "path#L20-50"
5767
+ const m = String(entry).match(/^(.+?)(?:[:#](?:L)?(\d+)\s*[-:]\s*(\d+))?$/);
5768
+ if (!m) continue;
5769
+ const filePath = m[1].trim();
5770
+ const start = m[2] ? parseInt(m[2], 10) : null;
5771
+ const end = m[3] ? parseInt(m[3], 10) : null;
5772
+ try {
5773
+ const abs = resolveToolPath(filePath);
5774
+ const stat = fs.statSync(abs);
5775
+ if (!stat.isFile()) {
5776
+ attachments.push({ path: filePath, error: 'not a regular file' });
5777
+ continue;
5778
+ }
5779
+ if (stat.size > cfg.maxFileBytes && !start) {
5780
+ attachments.push({ path: filePath, error: `file too large (${stat.size} bytes > ${cfg.maxFileBytes}). Pass a line range as ${filePath}:start-end.` });
5781
+ continue;
5782
+ }
5783
+ const raw = fs.readFileSync(abs, 'utf8');
5784
+ let content;
5785
+ let header;
5786
+ if (start) {
5787
+ const lines = raw.split('\n');
5788
+ const s = Math.max(1, start);
5789
+ const e = Math.min(lines.length, end || start + 100);
5790
+ const gutter = String(e).length;
5791
+ content = lines.slice(s - 1, e).map((l, i) => `${String(s + i).padStart(gutter, ' ')} | ${l}`).join('\n');
5792
+ header = `${filePath} (lines ${s}-${e} of ${lines.length})`;
5793
+ } else {
5794
+ content = raw;
5795
+ header = `${filePath} (full file, ${stat.size} bytes)`;
5796
+ }
5797
+ if (totalBytes + content.length > cfg.totalBytesLimit) {
5798
+ attachments.push({ path: filePath, error: 'skipped \u2014 total byte limit reached' });
5799
+ break;
5800
+ }
5801
+ attachments.push({ path: header, content });
5802
+ totalBytes += content.length;
5803
+ } catch (err) {
5804
+ attachments.push({ path: filePath, error: err.message });
5805
+ }
5806
+ }
5807
+ return { attachments, totalBytes };
5808
+ }
5809
+
5810
+ function saveConsultantTranscript(cfg, payload) {
5811
+ if (!cfg.saveTranscripts) return null;
5812
+ try {
5813
+ const dir = isAbsolute(cfg.transcriptDir)
5814
+ ? cfg.transcriptDir
5815
+ : pathResolve(getToolWorkingDirectory(), cfg.transcriptDir);
5816
+ fs.mkdirSync(dir, { recursive: true });
5817
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
5818
+ const file = join(dir, `consult-${stamp}.md`);
5819
+ fs.writeFileSync(file, payload);
5820
+ return file;
5821
+ } catch { return null; }
5822
+ }
5823
+
5824
+ async function consultExpert({ summary, question, goal, attempts, hints, files } = {}) {
5825
+ const cfg = getConsultantConfig();
5826
+ if (!cfg.enabled) {
5827
+ return 'Consultant tool is disabled (consultant.enabled = false in .sapper/config.json). Enable it before calling consult_expert.';
5828
+ }
5829
+
5830
+ // ── Gating: refuse drive-by calls ──
5831
+ const errors = [];
5832
+ const summaryText = String(summary || '').trim();
5833
+ const summaryWords = summaryText ? summaryText.split(/\s+/).length : 0;
5834
+ if (cfg.requireSummary && summaryWords < cfg.minSummaryWords) {
5835
+ errors.push(`'summary' must be at least ${cfg.minSummaryWords} words (got ${summaryWords}). Write a real summary of what you have explored, what you understand, and what is blocking you`);
5836
+ }
5837
+ if (cfg.requireQuestion && !String(question || '').trim()) {
5838
+ errors.push("'question' is required and must be specific (e.g. 'should I refactor X into Y, or is there a simpler way?')");
5839
+ }
5840
+ if (cfg.requireGoal && !String(goal || '').trim()) {
5841
+ errors.push("'goal' is required \u2014 describe the important thing you are trying to accomplish or about to change");
5842
+ }
5843
+ if (errors.length) {
5844
+ return `Error invoking consultant: ${errors.join('; ')}.\n\nThe consultant is a deliberate, heavyweight advisor. Before calling it, gather context: read the relevant files, run searches, then call consult_expert with summary, question, goal, attempts, and files.`;
5845
+ }
5846
+
5847
+ const fileEntries = parseConsultantFilesArg(files);
5848
+ const { attachments, totalBytes } = attachConsultantFiles(fileEntries, cfg);
5849
+
5850
+ // ── Build the consultation request ──
5851
+ let userPrompt = '';
5852
+ userPrompt += `# Consultation Request\n\n`;
5853
+ userPrompt += `## Goal\n${goal || '(not provided)'}\n\n`;
5854
+ userPrompt += `## Specific question\n${question}\n\n`;
5855
+ userPrompt += `## Summary of context and current understanding\n${summaryText}\n\n`;
5856
+ if (attempts) userPrompt += `## What has been tried\n${String(attempts).trim()}\n\n`;
5857
+ if (hints) userPrompt += `## Hints / suspected causes\n${String(hints).trim()}\n\n`;
5858
+ userPrompt += `## Working directory\n${getToolWorkingDirectory()}\n\n`;
5859
+ if (attachments.length > 0) {
5860
+ userPrompt += `## Attached files (${attachments.length}, ~${formatBytes(totalBytes)})\n\n`;
5861
+ for (const a of attachments) {
5862
+ if (a.error) {
5863
+ userPrompt += `### ${a.path}\n_Error attaching: ${a.error}_\n\n`;
5864
+ } else {
5865
+ userPrompt += `### ${a.path}\n\`\`\`\n${a.content}\n\`\`\`\n\n`;
5866
+ }
5867
+ }
5868
+ } else {
5869
+ userPrompt += `## Attached files\n_(none \u2014 the calling agent did not attach files; rely on your tools to read what you need)_\n\n`;
5870
+ }
5871
+ userPrompt += `Analyze carefully. Use your tools to verify anything in the codebase you need to check. Return a precise RECOMMENDATION / REASONING / RISKS / NEXT STEPS answer.`;
5872
+
5873
+ const systemPrompt = (cfg.systemPrompt && cfg.systemPrompt.trim())
5874
+ ? cfg.systemPrompt
5875
+ : DEFAULT_CONSULTANT_SYSTEM_PROMPT;
5876
+
5877
+ const consultMessages = [
5878
+ { role: 'system', content: systemPrompt },
5879
+ { role: 'user', content: userPrompt },
5880
+ ];
5881
+
5882
+ // ── Detect consultant capabilities & build tool defs ──
5883
+ let useTools = false;
5884
+ try {
5885
+ const info = await ollama.show({ model: cfg.model });
5886
+ useTools = !!(info?.capabilities && info.capabilities.includes('tools'));
5887
+ } catch (err) {
5888
+ return `Error invoking consultant: cannot reach model '${cfg.model}' (${err.message}). Pull it with \`ollama pull ${cfg.model}\` or change consultant.model in .sapper/config.json.`;
5889
+ }
5890
+
5891
+ const allowedSet = new Set((cfg.tools || []).map(normalizeToolName));
5892
+ const consultantNativeTools = useTools ? buildConsultantNativeTools(allowedSet) : [];
5893
+
5894
+ // ── Run the consultation loop ──
5895
+ const startedAt = Date.now();
5896
+ const deadline = startedAt + cfg.timeoutMs;
5897
+ console.log();
5898
+ console.log(box(
5899
+ `${keyValue('model', chalk.white(cfg.model), 11)}\n` +
5900
+ `${keyValue('files', chalk.white(`${attachments.length} attached`), 11)} ${UI.slate('\u00b7')} ${chalk.white(formatBytes(totalBytes))}\n` +
5901
+ `${keyValue('tools', chalk.white(useTools ? `native (${consultantNativeTools.length} read-only)` : 'one-shot (no native tools)'), 11)}\n` +
5902
+ `${keyValue('rounds', chalk.white(`max ${cfg.toolRoundLimit}`), 11)} ${UI.slate('\u00b7')} ${UI.slate(`timeout ${Math.round(cfg.timeoutMs / 1000)}s`)}`,
5903
+ 'Consulting expert', 'magenta'
5904
+ ));
5905
+
5906
+ const verbose = cfg.verbose !== false;
5907
+ const dim = chalk.gray;
5908
+ const accent = chalk.hex('#b7b9ff'); // magenta tone
5909
+ const log = (msg) => { if (verbose) console.log(msg); };
5910
+ const logBlock = (label, body, max = 600) => {
5911
+ if (!verbose) return;
5912
+ const text = String(body ?? '').trim();
5913
+ if (!text) { console.log(`${accent('[consult]')} ${dim(label + ': (empty)')}`); return; }
5914
+ const truncated = text.length > max ? text.slice(0, max) + dim(`\n... (${text.length - max} more chars)`) : text;
5915
+ console.log(`${accent('[consult]')} ${chalk.white(label)}:\n${dim(' ' + truncated.split('\\n').join('\n '))}`);
5916
+ };
5917
+
5918
+ // Show what we are about to send to the consultant
5919
+ log(`${accent('[consult]')} ${chalk.white('request')} ${dim('to')} ${chalk.white(cfg.model)}`);
5920
+ logBlock('goal', goal);
5921
+ logBlock('question', question);
5922
+ logBlock('summary', summaryText, 800);
5923
+ if (attempts) logBlock('attempts', attempts);
5924
+ if (hints) logBlock('hints', hints);
5925
+ if (attachments.length > 0) {
5926
+ log(`${accent('[consult]')} ${chalk.white('files')} ${dim(`(${attachments.length}, ${formatBytes(totalBytes)})`)}:`);
5927
+ for (const a of attachments) {
5928
+ log(` ${a.error ? chalk.red('!') : chalk.green('+')} ${chalk.white(a.path)}${a.error ? dim(` -- ${a.error}`) : ''}`);
5929
+ }
5930
+ } else {
5931
+ log(`${accent('[consult]')} ${dim('files: none attached \u2014 consultant must rely on its own tools')}`);
5932
+ }
5933
+
5934
+ const consultantSpinner = ora(chalk.magenta(`Consultant (${cfg.model.split(':')[0]}) is thinking...`)).start();
5935
+ let finalAnswer = '';
5936
+ let rounds = 0;
5937
+ try {
5938
+ while (true) {
5939
+ if (Date.now() > deadline) {
5940
+ consultantSpinner.stop();
5941
+ log(`${chalk.red('[consult]')} timeout hit at ${Math.round((Date.now() - startedAt) / 1000)}s`);
5942
+ return `Consultant aborted: hit timeout of ${Math.round(cfg.timeoutMs / 1000)}s before finishing.\n\nPartial answer:\n${finalAnswer || '(none produced yet)'}`;
5943
+ }
5944
+
5945
+ const chatOpts = {
5946
+ model: cfg.model,
5947
+ messages: consultMessages,
5948
+ stream: false,
5949
+ options: {
5950
+ temperature: cfg.temperature,
5951
+ ...(cfg.contextLimit ? { num_ctx: cfg.contextLimit } : {}),
5952
+ },
5953
+ };
5954
+ if (cfg.thinking === 'on') chatOpts.think = true;
5955
+ if (useTools && consultantNativeTools.length) chatOpts.tools = consultantNativeTools;
5956
+
5957
+ const roundStart = Date.now();
5958
+ let resp;
5959
+ try {
5960
+ resp = await ollama.chat(chatOpts);
5961
+ } catch (err) {
5962
+ const errMsg = err?.message || String(err);
5963
+ if (/does not support thinking/i.test(errMsg) && chatOpts.think) {
5964
+ delete chatOpts.think;
5965
+ resp = await ollama.chat(chatOpts);
5966
+ } else {
5967
+ throw err;
5968
+ }
5969
+ }
5970
+
5971
+ const msg = resp?.message || {};
5972
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
5973
+ const replyContent = msg.content || '';
5974
+ const replyThinking = msg.thinking || '';
5975
+ finalAnswer = replyContent || finalAnswer;
5976
+
5977
+ consultantSpinner.stop();
5978
+ if (replyThinking) logBlock(`round ${rounds} thinking (${Math.round((Date.now() - roundStart) / 1000)}s)`, replyThinking, 400);
5979
+ if (replyContent) logBlock(`round ${rounds} content`, replyContent, 1200);
5980
+ if (toolCalls.length > 0) {
5981
+ log(`${accent('[consult]')} ${chalk.white(`round ${rounds} \u2192 ${toolCalls.length} tool call${toolCalls.length === 1 ? '' : 's'}`)}`);
5982
+ }
5983
+
5984
+ if (toolCalls.length === 0 || rounds >= cfg.toolRoundLimit) {
5985
+ break;
5986
+ }
5987
+
5988
+ // Push assistant message with tool_calls
5989
+ consultMessages.push({ role: 'assistant', content: msg.content || '', tool_calls: toolCalls });
5990
+ rounds++;
5991
+ consultantSpinner.start(chalk.magenta(`Consultant running tool round ${rounds}/${cfg.toolRoundLimit}...`));
5992
+
5993
+ for (const tc of toolCalls) {
5994
+ const fn = tc.function || {};
5995
+ const args = fn.arguments || {};
5996
+ const mapped = normalizeToolName(fn.name || '');
5997
+ const argPreview = (() => {
5998
+ try {
5999
+ const keys = Object.keys(args || {});
6000
+ if (keys.length === 0) return '';
6001
+ return keys.map(k => `${k}=${ellipsis(String(args[k] ?? ''), 60)}`).join(', ');
6002
+ } catch { return ''; }
6003
+ })();
6004
+ consultantSpinner.stop();
6005
+ log(` ${accent('\u2192')} ${chalk.white(fn.name)}${argPreview ? dim('(' + argPreview + ')') : ''}`);
6006
+ if (!allowedSet.has(mapped)) {
6007
+ log(` ${chalk.red('blocked')} ${dim('not in allowed tools')}`);
6008
+ consultMessages.push({ role: 'tool', tool_name: fn.name, content: `Tool ${fn.name} is not allowed for the consultant (read-only restriction).` });
6009
+ consultantSpinner.start(chalk.magenta(`Consultant running tool round ${rounds}/${cfg.toolRoundLimit}...`));
6010
+ continue;
6011
+ }
6012
+ let toolResult;
6013
+ const tStart = Date.now();
6014
+ try {
6015
+ toolResult = await runConsultantToolCall(fn.name, args);
6016
+ } catch (err) {
6017
+ toolResult = `Error: ${err.message}`;
6018
+ }
6019
+ const tMs = Date.now() - tStart;
6020
+ const resultStr = String(toolResult ?? '');
6021
+ const isErr = /^error/i.test(resultStr.trim());
6022
+ log(` ${isErr ? chalk.red('err') : chalk.green('ok')} ${dim(`${tMs}ms, ${resultStr.length} chars`)}: ${dim(ellipsis(resultStr.replace(/\s+/g, ' '), 200))}`);
6023
+ consultMessages.push({
6024
+ role: 'tool',
6025
+ tool_name: fn.name,
6026
+ content: truncateToolText(resultStr, 16000),
6027
+ });
6028
+ consultantSpinner.start(chalk.magenta(`Consultant running tool round ${rounds}/${cfg.toolRoundLimit}...`));
6029
+ }
6030
+ }
6031
+ } catch (err) {
6032
+ consultantSpinner.stop();
6033
+ log(`${chalk.red('[consult]')} error during consultation: ${err.message}`);
6034
+ return `Error during consultation: ${err.message}`;
6035
+ }
6036
+ consultantSpinner.stop();
6037
+
6038
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
6039
+ const answer = (finalAnswer || '').trim() || '(consultant returned no content)';
6040
+
6041
+ // Final summary box
6042
+ log('');
6043
+ log(`${accent('[consult]')} ${chalk.white('final answer')} ${dim(`(${elapsed}s, ${rounds} tool round${rounds === 1 ? '' : 's'})`)}:`);
6044
+ logBlock('answer', answer, 2000);
6045
+
6046
+ // Save transcript for audit
6047
+ const transcriptBody = [
6048
+ `# Consultation Transcript`,
6049
+ `- timestamp: ${new Date().toISOString()}`,
6050
+ `- model: ${cfg.model}`,
6051
+ `- duration: ${elapsed}s`,
6052
+ `- tool rounds: ${rounds}`,
6053
+ `- files attached: ${attachments.length}`,
6054
+ ``,
6055
+ `## Request`,
6056
+ userPrompt,
6057
+ ``,
6058
+ `## Final answer`,
6059
+ answer,
6060
+ ``,
6061
+ ].join('\n');
6062
+ const transcriptPath = saveConsultantTranscript(cfg, transcriptBody);
6063
+
6064
+ const header = `Consultation complete (${cfg.model.split(':')[0]}, ${elapsed}s, ${rounds} tool round${rounds === 1 ? '' : 's'}${transcriptPath ? `, saved: ${relative(getToolWorkingDirectory(), transcriptPath) || transcriptPath}` : ''}).`;
6065
+ return `${header}\n\n${answer}`;
6066
+ }
6067
+
5575
6068
  const tools = {
5576
6069
  read: (path) => {
5577
6070
  const trimmedPath = typeof path === 'string' ? path.trim() : '';
@@ -6122,7 +6615,8 @@ const tools = {
6122
6615
  }
6123
6616
  });
6124
6617
  });
6125
- }
6618
+ },
6619
+ consult: async (args) => consultExpert(args || {})
6126
6620
  };
6127
6621
 
6128
6622
  async function checkForUpdates() {
@@ -6715,6 +7209,25 @@ async function runSapper() {
6715
7209
  required: ['command']
6716
7210
  }
6717
7211
  }
7212
+ },
7213
+ {
7214
+ type: 'function',
7215
+ function: {
7216
+ name: 'consult_expert',
7217
+ description: 'Call a heavyweight second-model consultant for advice when you are stuck or before making an important/hard-to-reverse change. The consultant is read-only and runs with its own tools. DO NOT call casually: every invocation requires a written summary, a specific question, a goal, optionally what you have tried, and the file paths the consultant needs. The consultant will read those files (line ranges supported via "path:start-end"), verify facts with its own tools, and return RECOMMENDATION / REASONING / RISKS / NEXT STEPS. Use sparingly.',
7218
+ parameters: {
7219
+ type: 'object',
7220
+ properties: {
7221
+ goal: { type: 'string', description: 'What important thing you are trying to accomplish or about to change.' },
7222
+ question: { type: 'string', description: 'The specific question or decision you need help with.' },
7223
+ summary: { type: 'string', description: 'A real summary (at least 25 words) of what you have explored, what you understand, and what is blocking you. Drive-by calls will be rejected.' },
7224
+ attempts: { type: 'string', description: 'Optional: what you have already tried and why it did not work.' },
7225
+ hints: { type: 'string', description: 'Optional: suspected causes or constraints worth flagging.' },
7226
+ files: { type: 'array', items: { type: 'string' }, description: 'File paths to attach. Use "path:start-end" to attach a line range only (recommended for large files). Examples: ["src/router.ts", "src/handlers/auth.ts:40-120"].' }
7227
+ },
7228
+ required: ['goal', 'question', 'summary']
7229
+ }
7230
+ }
6718
7231
  }
6719
7232
  ];
6720
7233
 
@@ -7747,6 +8260,108 @@ async function runSapper() {
7747
8260
  }
7748
8261
  }
7749
8262
 
8263
+ if (input.toLowerCase() === '/consult' || input.toLowerCase().startsWith('/consult ')) {
8264
+ const arg = input.substring(8).trim();
8265
+ const cfg = getConsultantConfig();
8266
+ const updateConsultant = (patch) => {
8267
+ sapperConfig.consultant = { ...getConsultantConfig(), ...patch };
8268
+ saveConfig(sapperConfig);
8269
+ };
8270
+
8271
+ if (!arg || ['status', 'show'].includes(arg.toLowerCase())) {
8272
+ const lines = [
8273
+ `enabled ${chalk.white(cfg.enabled ? 'on' : 'off')}`,
8274
+ `model ${chalk.white(cfg.model)}`,
8275
+ `tools ${UI.slate(cfg.tools.join(', '))}`,
8276
+ `gating ${UI.slate(`summary>=${cfg.minSummaryWords}w \u00b7 question ${cfg.requireQuestion ? 'required' : 'optional'} \u00b7 goal ${cfg.requireGoal ? 'required' : 'optional'}`)}`,
8277
+ `limits ${UI.slate(`${cfg.maxFiles} files \u00b7 ${formatBytes(cfg.maxFileBytes)}/file \u00b7 ${formatBytes(cfg.totalBytesLimit)} total`)}`,
8278
+ `loop ${UI.slate(`${cfg.toolRoundLimit} tool rounds \u00b7 timeout ${Math.round(cfg.timeoutMs/1000)}s \u00b7 thinking ${cfg.thinking} \u00b7 temp ${cfg.temperature}`)}`,
8279
+ `transcripts ${chalk.white(cfg.saveTranscripts ? 'on' : 'off')} ${UI.slate('\u2192')} ${UI.slate(cfg.transcriptDir)}`,
8280
+ '',
8281
+ UI.slate('Usage: /consult model <name> | /consult on|off | /consult tools <a,b,c> | /consult minwords <N>'),
8282
+ UI.slate(' /consult rounds <N> | /consult timeout <secs> | /consult thinking <auto|on|off> | /consult temp <0..2>'),
8283
+ UI.slate(' /consult transcripts <on|off> | /consult reset'),
8284
+ ];
8285
+ console.log();
8286
+ console.log(box(lines.join('\n'), 'Consultant', 'magenta'));
8287
+ continue;
8288
+ }
8289
+
8290
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
8291
+ const subcommand = subcommandRaw.toLowerCase();
8292
+ const value = rest.join(' ').trim();
8293
+
8294
+ if (subcommand === 'reset' || subcommand === 'default') {
8295
+ sapperConfig.consultant = { ...DEFAULT_CONFIG.consultant };
8296
+ saveConfig(sapperConfig);
8297
+ console.log(chalk.green('Consultant settings reset to defaults.'));
8298
+ continue;
8299
+ }
8300
+ if (['on', 'true', 'yes', 'enable', 'enabled'].includes(subcommand)) { updateConsultant({ enabled: true }); console.log(chalk.green('Consultant enabled.')); continue; }
8301
+ if (['off', 'false', 'no', 'disable', 'disabled'].includes(subcommand)) { updateConsultant({ enabled: false }); console.log(chalk.yellow('Consultant disabled.')); continue; }
8302
+ if (subcommand === 'model') {
8303
+ if (!value) { console.log(chalk.yellow('Usage: /consult model <name>')); continue; }
8304
+ updateConsultant({ model: value });
8305
+ console.log(chalk.green(`Consultant model set to ${value}.`));
8306
+ continue;
8307
+ }
8308
+ if (subcommand === 'tools') {
8309
+ const list = value.split(/[,\s]+/).map(t => t.trim()).filter(Boolean);
8310
+ if (!list.length) { console.log(chalk.yellow('Usage: /consult tools read,list,grep,...')); continue; }
8311
+ updateConsultant({ tools: list });
8312
+ console.log(chalk.green(`Consultant tools: ${list.join(', ')}`));
8313
+ continue;
8314
+ }
8315
+ if (subcommand === 'minwords' || subcommand === 'minsummary') {
8316
+ const n = parseInt(value, 10);
8317
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult minwords <N>')); continue; }
8318
+ updateConsultant({ minSummaryWords: n });
8319
+ console.log(chalk.green(`Min summary words: ${n}`));
8320
+ continue;
8321
+ }
8322
+ if (subcommand === 'rounds') {
8323
+ const n = parseInt(value, 10);
8324
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult rounds <N>')); continue; }
8325
+ updateConsultant({ toolRoundLimit: n });
8326
+ console.log(chalk.green(`Consultant tool rounds: ${n}`));
8327
+ continue;
8328
+ }
8329
+ if (subcommand === 'timeout') {
8330
+ const n = parseInt(value, 10);
8331
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult timeout <seconds>')); continue; }
8332
+ updateConsultant({ timeoutMs: n * 1000 });
8333
+ console.log(chalk.green(`Consultant timeout: ${n}s`));
8334
+ continue;
8335
+ }
8336
+ if (subcommand === 'thinking') {
8337
+ updateConsultant({ thinking: value });
8338
+ console.log(chalk.green(`Consultant thinking: ${getConsultantConfig().thinking}`));
8339
+ continue;
8340
+ }
8341
+ if (subcommand === 'temp' || subcommand === 'temperature') {
8342
+ const n = Number(value);
8343
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult temp <0..2>')); continue; }
8344
+ updateConsultant({ temperature: n });
8345
+ console.log(chalk.green(`Consultant temperature: ${getConsultantConfig().temperature}`));
8346
+ continue;
8347
+ }
8348
+ if (subcommand === 'transcripts' || subcommand === 'transcript') {
8349
+ if (['on', 'true', 'yes'].includes(value.toLowerCase())) { updateConsultant({ saveTranscripts: true }); console.log(chalk.green('Consultant transcripts: on')); continue; }
8350
+ if (['off', 'false', 'no'].includes(value.toLowerCase())) { updateConsultant({ saveTranscripts: false }); console.log(chalk.yellow('Consultant transcripts: off')); continue; } console.log(chalk.yellow('Usage: /consult transcripts on|off'));
8351
+ continue;
8352
+ }
8353
+ if (subcommand === 'verbose' || subcommand === 'log' || subcommand === 'logging') {
8354
+ if (['on', 'true', 'yes', '1'].includes(value.toLowerCase())) { updateConsultant({ verbose: true }); console.log(chalk.green('Consultant verbose logging: on')); continue; }
8355
+ if (['off', 'false', 'no', '0'].includes(value.toLowerCase())) { updateConsultant({ verbose: false }); console.log(chalk.yellow('Consultant verbose logging: off')); continue; }
8356
+ console.log(chalk.yellow('Usage: /consult verbose on|off'));
8357
+ continue;
8358
+ }
8359
+
8360
+ console.log(chalk.yellow(`Unknown consult option: ${subcommand}`));
8361
+ console.log(chalk.gray(' Usage: /consult | /consult model <name> | /consult on|off | /consult tools <a,b,c> | /consult minwords <N> | /consult rounds <N> | /consult timeout <secs> | /consult thinking <auto|on|off> | /consult temp <0..2> | /consult transcripts on|off | /consult verbose on|off | /consult reset'));
8362
+ continue;
8363
+ }
8364
+
7750
8365
  if (input.toLowerCase().startsWith('/ui')) {
7751
8366
  const arg = input.substring(3).trim();
7752
8367
  const currentUI = getUIConfig();
@@ -9082,7 +9697,8 @@ async function runSapper() {
9082
9697
  write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
9083
9698
  ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
9084
9699
  pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
9085
- fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
9700
+ fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL',
9701
+ consult_expert: 'CONSULT'
9086
9702
  };
9087
9703
 
9088
9704
  showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
@@ -9210,6 +9826,10 @@ async function runSapper() {
9210
9826
  result = await tools.shell(args.command);
9211
9827
  logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
9212
9828
  break;
9829
+ case 'consult_expert':
9830
+ result = await tools.consult(args);
9831
+ logEntry('tool', { toolType: 'CONSULT', path: (args && args.question ? String(args.question).slice(0, 80) : 'consult'), duration: Date.now() - toolStart, success: !String(result).startsWith('Error'), resultSize: result?.length });
9832
+ break;
9213
9833
  default:
9214
9834
  result = `Unknown tool: ${fn.name}`;
9215
9835
  toolSuccess = false;
@@ -9476,9 +10096,32 @@ async function runSapper() {
9476
10096
  const approved = !result.includes('blocked');
9477
10097
  logEntry('shell', { command: path, duration: Date.now() - toolStart, userApproved: approved, exitCode: result.match(/code (\d+)/)?.[1] ?? null });
9478
10098
  }
10099
+ else if (type.toLowerCase() === 'consult') {
10100
+ // Text-marker form: [TOOL:CONSULT]goal:::question:::summary:::file1,file2[/TOOL]
10101
+ // Or pass a single JSON blob in `path`: {"goal":"...","question":"...","summary":"...","files":["..."]}
10102
+ let consultArgs = null;
10103
+ const raw = (content && content.trim()) ? `${path}:::${content}` : String(path || '');
10104
+ const trimmedRaw = raw.trim();
10105
+ if (trimmedRaw.startsWith('{')) {
10106
+ try { consultArgs = JSON.parse(trimmedRaw); } catch { consultArgs = null; }
10107
+ }
10108
+ if (!consultArgs) {
10109
+ const parts = trimmedRaw.split(/\s*:::\s*/);
10110
+ consultArgs = {
10111
+ goal: parts[0] || '',
10112
+ question: parts[1] || '',
10113
+ summary: parts[2] || '',
10114
+ attempts: parts[3] || '',
10115
+ hints: parts[4] || '',
10116
+ files: parts[5] ? parts[5].split(/[,\n]/).map(s => s.trim()).filter(Boolean) : [],
10117
+ };
10118
+ }
10119
+ result = await tools.consult(consultArgs);
10120
+ logEntry('tool', { toolType: 'CONSULT', path: (consultArgs.question || 'consult').slice(0, 80), duration: Date.now() - toolStart, success: !String(result).startsWith('Error'), resultSize: result?.length });
10121
+ }
9479
10122
 
9480
10123
  // Log tool execution (for non-shell, non-file specific ones)
9481
- if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'regex', 'chunk', 'read_chunk', 'changes', 'fetch', 'memory', 'memory_note_save', 'memory_note_search', 'memory_note_read', 'open', 'shell'].includes(type.toLowerCase())) {
10124
+ if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'regex', 'chunk', 'read_chunk', 'changes', 'fetch', 'memory', 'memory_note_save', 'memory_note_search', 'memory_note_read', 'open', 'shell', 'consult'].includes(type.toLowerCase())) {
9482
10125
  logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
9483
10126
  }
9484
10127