sapper-iq 1.4.5 → 1.4.6

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 +579 -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.6",
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,29 @@ 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
+ systemPrompt: '', // Override the built-in consultant system prompt (empty = use default).
1175
+ }),
1146
1176
  prompt: Object.freeze({
1147
1177
  prepend: '',
1148
1178
  append: '',
@@ -1162,7 +1192,7 @@ RULES:
1162
1192
  5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`,
1163
1193
  nativeTools: `TOOLS:
1164
1194
  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.
1195
+ 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
1196
 
1167
1197
  PATCH TIPS:
1168
1198
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
@@ -1185,6 +1215,11 @@ EXTRA TOOL TIPS:
1185
1215
  - read_memory_notes reads the full markdown long-memory file.
1186
1216
  - open_url opens a URL in the default browser and always asks for approval.
1187
1217
 
1218
+ CONSULTANT (consult_expert):
1219
+ - 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).
1220
+ - 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).
1221
+ - 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.
1222
+
1188
1223
  SHELL TIPS:
1189
1224
  - run_shell may keep long-running commands in a background session depending on config.
1190
1225
  - If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
@@ -1217,6 +1252,7 @@ SHELL TIPS:
1217
1252
  - [TOOL:MEMORY_NOTE_READ][/TOOL] - Read markdown long memory file
1218
1253
  - [TOOL:OPEN]https://example.com[/TOOL] - Open a URL in the default browser (asks for approval)
1219
1254
  - [TOOL:SHELL]command[/TOOL] - Run shell command
1255
+ - [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
1256
 
1221
1257
  PATCH TIPS:
1222
1258
  - PREFER the LINE:number mode when you know which line to change. It is much more reliable than text matching.
@@ -1514,6 +1550,51 @@ function normalizeVoiceConfig(voiceConfig = {}) {
1514
1550
  };
1515
1551
  }
1516
1552
 
1553
+ function normalizeConsultantConfig(consultantConfig = {}) {
1554
+ if (typeof consultantConfig === 'boolean') {
1555
+ return { ...DEFAULT_CONFIG.consultant, enabled: consultantConfig };
1556
+ }
1557
+ if (typeof consultantConfig === 'string') {
1558
+ return { ...DEFAULT_CONFIG.consultant, enabled: normalizeBoolean(consultantConfig, DEFAULT_CONFIG.consultant.enabled) };
1559
+ }
1560
+ if (!consultantConfig || typeof consultantConfig !== 'object' || Array.isArray(consultantConfig)) {
1561
+ return { ...DEFAULT_CONFIG.consultant };
1562
+ }
1563
+ const D = DEFAULT_CONFIG.consultant;
1564
+ const toolsList = Array.isArray(consultantConfig.tools)
1565
+ ? consultantConfig.tools.map(t => String(t || '').trim()).filter(Boolean)
1566
+ : (typeof consultantConfig.tools === 'string'
1567
+ ? consultantConfig.tools.split(',').map(t => t.trim()).filter(Boolean)
1568
+ : [...D.tools]);
1569
+ return {
1570
+ enabled: normalizeBoolean(consultantConfig.enabled, D.enabled),
1571
+ model: typeof consultantConfig.model === 'string' && consultantConfig.model.trim()
1572
+ ? consultantConfig.model.trim()
1573
+ : D.model,
1574
+ contextLimit: normalizeContextLimit(consultantConfig.contextLimit),
1575
+ temperature: (() => {
1576
+ const v = Number(consultantConfig.temperature);
1577
+ return Number.isFinite(v) && v >= 0 && v <= 2 ? v : D.temperature;
1578
+ })(),
1579
+ thinking: normalizeThinkingMode(consultantConfig.thinking),
1580
+ requireSummary: normalizeBoolean(consultantConfig.requireSummary, D.requireSummary),
1581
+ requireQuestion: normalizeBoolean(consultantConfig.requireQuestion, D.requireQuestion),
1582
+ requireGoal: normalizeBoolean(consultantConfig.requireGoal, D.requireGoal),
1583
+ minSummaryWords: normalizeIntegerInRange(consultantConfig.minSummaryWords, D.minSummaryWords, 0, 500),
1584
+ maxFiles: normalizeIntegerInRange(consultantConfig.maxFiles, D.maxFiles, 0, 200),
1585
+ maxFileBytes: normalizeIntegerInRange(consultantConfig.maxFileBytes, D.maxFileBytes, 1024, 10000000),
1586
+ totalBytesLimit: normalizeIntegerInRange(consultantConfig.totalBytesLimit, D.totalBytesLimit, 1024, 50000000),
1587
+ toolRoundLimit: normalizeIntegerInRange(consultantConfig.toolRoundLimit, D.toolRoundLimit, 0, 200),
1588
+ timeoutMs: normalizeIntegerInRange(consultantConfig.timeoutMs, D.timeoutMs, 10000, 3600000),
1589
+ tools: toolsList.length ? toolsList : [...D.tools],
1590
+ saveTranscripts: normalizeBoolean(consultantConfig.saveTranscripts, D.saveTranscripts),
1591
+ transcriptDir: typeof consultantConfig.transcriptDir === 'string' && consultantConfig.transcriptDir.trim()
1592
+ ? consultantConfig.transcriptDir.trim()
1593
+ : D.transcriptDir,
1594
+ systemPrompt: typeof consultantConfig.systemPrompt === 'string' ? consultantConfig.systemPrompt : D.systemPrompt,
1595
+ };
1596
+ }
1597
+
1517
1598
  function normalizePromptText(value) {
1518
1599
  if (typeof value === 'string') return value;
1519
1600
  if (value === null || value === undefined) return '';
@@ -1559,6 +1640,7 @@ function normalizeConfig(config = {}) {
1559
1640
  ui: normalizeUIConfig(config.ui),
1560
1641
  chunking: normalizeChunkingConfig(config.chunking),
1561
1642
  voice: normalizeVoiceConfig(config.voice),
1643
+ consultant: normalizeConsultantConfig(config.consultant),
1562
1644
  prompt: normalizePromptConfig(config.prompt),
1563
1645
  };
1564
1646
  }
@@ -1684,6 +1766,13 @@ function renderConfigFile(config) {
1684
1766
  lines.push(' // Trigger from the chat with /voice record [seconds] or /voice file <path>.');
1685
1767
  appendConfigProperty(lines, 'voice', config.voice);
1686
1768
 
1769
+ lines.push('');
1770
+ lines.push(' // Consultant tool — deliberate second-model advisor.');
1771
+ lines.push(' // The main agent calls it via consult_expert / [TOOL:CONSULT] when stuck or before important changes.');
1772
+ lines.push(' // Every call must include a summary, question, goal, and attached file paths. Calls are read-only by default.');
1773
+ lines.push(' // Tip: pick a stronger reasoning model than your main model — that is the whole point.');
1774
+ appendConfigProperty(lines, 'consultant', config.consultant);
1775
+
1687
1776
  lines.push('');
1688
1777
  lines.push(' // Prompt customization');
1689
1778
  lines.push(' // prompt.system.* controls the assistant system prompt blocks');
@@ -1905,6 +1994,14 @@ function voiceEnabled() {
1905
1994
  return getVoiceConfig().enabled;
1906
1995
  }
1907
1996
 
1997
+ function getConsultantConfig() {
1998
+ return normalizeConsultantConfig(sapperConfig.consultant);
1999
+ }
2000
+
2001
+ function consultantEnabled() {
2002
+ return getConsultantConfig().enabled;
2003
+ }
2004
+
1908
2005
  function streamPhaseStatusEnabled() {
1909
2006
  return getStreamingConfig().showPhaseStatus;
1910
2007
  }
@@ -3053,6 +3150,7 @@ const COMMAND_GROUPS = Object.freeze([
3053
3150
  ['/shell', 'Inspect shell config and background sessions'],
3054
3151
  ['/shell read <id>', 'Read output from a tracked shell session'],
3055
3152
  ['/shell stop <id>', 'Stop a tracked shell session'],
3153
+ ['/consult', 'Show or configure the heavyweight consultant tool (separate model)'],
3056
3154
  ['/context', 'Inspect token usage, summary trigger, and model window'],
3057
3155
  ['/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'],
3058
3156
  ['/debug', 'Toggle regex and tool debug output'],
@@ -5572,6 +5670,337 @@ function keywordRecallMemory(query, embeddings, topK = 3) {
5572
5670
  .slice(0, topK);
5573
5671
  }
5574
5672
 
5673
+ // ─────────────────────────────────────────────────────────────────
5674
+ // CONSULTANT TOOL — deliberate second-model advisor
5675
+ // The main agent calls this when stuck or before an important change.
5676
+ // Read-only by default; runs in its own ollama session with its own
5677
+ // tool budget. All settings live under sapperConfig.consultant.
5678
+ // ─────────────────────────────────────────────────────────────────
5679
+
5680
+ 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.
5681
+
5682
+ Your job:
5683
+ - Read the context carefully. Do not restate the question back.
5684
+ - 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.
5685
+ - Return a precise, actionable recommendation.
5686
+
5687
+ 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.
5688
+
5689
+ Format your final answer EXACTLY as:
5690
+
5691
+ RECOMMENDATION
5692
+ <1-3 sentences \u2014 the bottom line>
5693
+
5694
+ REASONING
5695
+ <what evidence in the code/context supports the recommendation, referencing file:line>
5696
+
5697
+ RISKS
5698
+ <what could go wrong, edge cases to watch>
5699
+
5700
+ NEXT STEPS
5701
+ 1. <concrete action the calling agent should take>
5702
+ 2. <\u2026>
5703
+
5704
+ Be brief but specific. No preamble. No restating the question. Reference exact file paths and line numbers.`;
5705
+
5706
+ function buildConsultantNativeTools(allowedSet) {
5707
+ // Compact read-only tool defs the consultant can call.
5708
+ const defs = [
5709
+ { name: 'list_directory', mapsTo: 'LIST', def: { description: 'List the contents of a directory.', parameters: { type: 'object', properties: { path: { type: 'string' } } } } },
5710
+ { name: 'read_file', mapsTo: 'READ', def: { description: 'Read the full contents of a file.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
5711
+ { 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'] } } },
5712
+ { name: 'search_files', mapsTo: 'SEARCH', def: { description: 'Search for a pattern across project files.', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } },
5713
+ { name: 'grep', mapsTo: 'GREP', def: { description: 'Alias for search_files.', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } },
5714
+ { name: 'find', mapsTo: 'FIND', def: { description: 'Find files or directories by name.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } } },
5715
+ { 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'] } } },
5716
+ { 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'] } } },
5717
+ { 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'] } } },
5718
+ { name: 'cat', mapsTo: 'CAT', def: { description: 'Read the full contents of a file.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
5719
+ { name: 'pwd', mapsTo: 'PWD', def: { description: 'Show current tool working directory.', parameters: { type: 'object', properties: {} } } },
5720
+ { name: 'changes', mapsTo: 'CHANGES', def: { description: 'Show git status and diffs.', parameters: { type: 'object', properties: { path: { type: 'string' } } } } },
5721
+ { name: 'fetch_web', mapsTo: 'FETCH', def: { description: 'Fetch a web page and return readable text.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } },
5722
+ { name: 'recall_memory', mapsTo: 'MEMORY', def: { description: 'Search saved conversation memory.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
5723
+ { name: 'search_memory_notes', mapsTo: 'MEMORY', def: { description: 'Search markdown long-memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
5724
+ { name: 'read_memory_notes', mapsTo: 'MEMORY', def: { description: 'Read markdown long-memory file.', parameters: { type: 'object', properties: {} } } },
5725
+ ];
5726
+ return defs
5727
+ .filter(t => allowedSet.has(t.mapsTo))
5728
+ .map(t => ({ type: 'function', function: { name: t.name, description: t.def.description, parameters: t.def.parameters } }));
5729
+ }
5730
+
5731
+ async function runConsultantToolCall(fnName, args) {
5732
+ const a = args || {};
5733
+ switch (fnName) {
5734
+ case 'list_directory': return tools.list(a.path ?? '.');
5735
+ case 'read_file': return tools.read(a.path);
5736
+ case 'read_chunk': return tools.read_chunk(a.path, a.start, a.end, a.context);
5737
+ case 'search_files':
5738
+ case 'grep': return await tools.search(a.pattern);
5739
+ case 'find': return tools.find(a.pattern, a.path ?? '.');
5740
+ case 'regex_search': return tools.regex(a.pattern, a.include ?? '', a.path ?? '.');
5741
+ case 'head': return tools.head(a.path, a.lines);
5742
+ case 'tail': return tools.tail(a.path, a.lines);
5743
+ case 'cat': return tools.cat(a.path);
5744
+ case 'pwd': return tools.pwd();
5745
+ case 'changes': return await tools.changes(a.path);
5746
+ case 'fetch_web': return await tools.fetch_web(a.url);
5747
+ case 'recall_memory': return await tools.recall_memory(a.query);
5748
+ case 'search_memory_notes': return await tools.search_memory_notes(a.query);
5749
+ case 'read_memory_notes': return await tools.read_memory_notes();
5750
+ default: return `Consultant tool not available: ${fnName}`;
5751
+ }
5752
+ }
5753
+
5754
+ function parseConsultantFilesArg(filesArg) {
5755
+ if (!filesArg) return [];
5756
+ if (Array.isArray(filesArg)) return filesArg.map(s => String(s).trim()).filter(Boolean);
5757
+ return String(filesArg).split(/[,\n]/).map(s => s.trim()).filter(Boolean);
5758
+ }
5759
+
5760
+ function attachConsultantFiles(fileEntries, cfg) {
5761
+ const attachments = [];
5762
+ let totalBytes = 0;
5763
+ for (const entry of fileEntries.slice(0, cfg.maxFiles)) {
5764
+ // Parse "path:start-end" or "path#L20-50"
5765
+ const m = String(entry).match(/^(.+?)(?:[:#](?:L)?(\d+)\s*[-:]\s*(\d+))?$/);
5766
+ if (!m) continue;
5767
+ const filePath = m[1].trim();
5768
+ const start = m[2] ? parseInt(m[2], 10) : null;
5769
+ const end = m[3] ? parseInt(m[3], 10) : null;
5770
+ try {
5771
+ const abs = resolveToolPath(filePath);
5772
+ const stat = fs.statSync(abs);
5773
+ if (!stat.isFile()) {
5774
+ attachments.push({ path: filePath, error: 'not a regular file' });
5775
+ continue;
5776
+ }
5777
+ if (stat.size > cfg.maxFileBytes && !start) {
5778
+ attachments.push({ path: filePath, error: `file too large (${stat.size} bytes > ${cfg.maxFileBytes}). Pass a line range as ${filePath}:start-end.` });
5779
+ continue;
5780
+ }
5781
+ const raw = fs.readFileSync(abs, 'utf8');
5782
+ let content;
5783
+ let header;
5784
+ if (start) {
5785
+ const lines = raw.split('\n');
5786
+ const s = Math.max(1, start);
5787
+ const e = Math.min(lines.length, end || start + 100);
5788
+ const gutter = String(e).length;
5789
+ content = lines.slice(s - 1, e).map((l, i) => `${String(s + i).padStart(gutter, ' ')} | ${l}`).join('\n');
5790
+ header = `${filePath} (lines ${s}-${e} of ${lines.length})`;
5791
+ } else {
5792
+ content = raw;
5793
+ header = `${filePath} (full file, ${stat.size} bytes)`;
5794
+ }
5795
+ if (totalBytes + content.length > cfg.totalBytesLimit) {
5796
+ attachments.push({ path: filePath, error: 'skipped \u2014 total byte limit reached' });
5797
+ break;
5798
+ }
5799
+ attachments.push({ path: header, content });
5800
+ totalBytes += content.length;
5801
+ } catch (err) {
5802
+ attachments.push({ path: filePath, error: err.message });
5803
+ }
5804
+ }
5805
+ return { attachments, totalBytes };
5806
+ }
5807
+
5808
+ function saveConsultantTranscript(cfg, payload) {
5809
+ if (!cfg.saveTranscripts) return null;
5810
+ try {
5811
+ const dir = isAbsolute(cfg.transcriptDir)
5812
+ ? cfg.transcriptDir
5813
+ : pathResolve(getToolWorkingDirectory(), cfg.transcriptDir);
5814
+ fs.mkdirSync(dir, { recursive: true });
5815
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
5816
+ const file = join(dir, `consult-${stamp}.md`);
5817
+ fs.writeFileSync(file, payload);
5818
+ return file;
5819
+ } catch { return null; }
5820
+ }
5821
+
5822
+ async function consultExpert({ summary, question, goal, attempts, hints, files } = {}) {
5823
+ const cfg = getConsultantConfig();
5824
+ if (!cfg.enabled) {
5825
+ return 'Consultant tool is disabled (consultant.enabled = false in .sapper/config.json). Enable it before calling consult_expert.';
5826
+ }
5827
+
5828
+ // ── Gating: refuse drive-by calls ──
5829
+ const errors = [];
5830
+ const summaryText = String(summary || '').trim();
5831
+ const summaryWords = summaryText ? summaryText.split(/\s+/).length : 0;
5832
+ if (cfg.requireSummary && summaryWords < cfg.minSummaryWords) {
5833
+ 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`);
5834
+ }
5835
+ if (cfg.requireQuestion && !String(question || '').trim()) {
5836
+ errors.push("'question' is required and must be specific (e.g. 'should I refactor X into Y, or is there a simpler way?')");
5837
+ }
5838
+ if (cfg.requireGoal && !String(goal || '').trim()) {
5839
+ errors.push("'goal' is required \u2014 describe the important thing you are trying to accomplish or about to change");
5840
+ }
5841
+ if (errors.length) {
5842
+ 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.`;
5843
+ }
5844
+
5845
+ const fileEntries = parseConsultantFilesArg(files);
5846
+ const { attachments, totalBytes } = attachConsultantFiles(fileEntries, cfg);
5847
+
5848
+ // ── Build the consultation request ──
5849
+ let userPrompt = '';
5850
+ userPrompt += `# Consultation Request\n\n`;
5851
+ userPrompt += `## Goal\n${goal || '(not provided)'}\n\n`;
5852
+ userPrompt += `## Specific question\n${question}\n\n`;
5853
+ userPrompt += `## Summary of context and current understanding\n${summaryText}\n\n`;
5854
+ if (attempts) userPrompt += `## What has been tried\n${String(attempts).trim()}\n\n`;
5855
+ if (hints) userPrompt += `## Hints / suspected causes\n${String(hints).trim()}\n\n`;
5856
+ userPrompt += `## Working directory\n${getToolWorkingDirectory()}\n\n`;
5857
+ if (attachments.length > 0) {
5858
+ userPrompt += `## Attached files (${attachments.length}, ~${formatBytes(totalBytes)})\n\n`;
5859
+ for (const a of attachments) {
5860
+ if (a.error) {
5861
+ userPrompt += `### ${a.path}\n_Error attaching: ${a.error}_\n\n`;
5862
+ } else {
5863
+ userPrompt += `### ${a.path}\n\`\`\`\n${a.content}\n\`\`\`\n\n`;
5864
+ }
5865
+ }
5866
+ } else {
5867
+ userPrompt += `## Attached files\n_(none \u2014 the calling agent did not attach files; rely on your tools to read what you need)_\n\n`;
5868
+ }
5869
+ 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.`;
5870
+
5871
+ const systemPrompt = (cfg.systemPrompt && cfg.systemPrompt.trim())
5872
+ ? cfg.systemPrompt
5873
+ : DEFAULT_CONSULTANT_SYSTEM_PROMPT;
5874
+
5875
+ const consultMessages = [
5876
+ { role: 'system', content: systemPrompt },
5877
+ { role: 'user', content: userPrompt },
5878
+ ];
5879
+
5880
+ // ── Detect consultant capabilities & build tool defs ──
5881
+ let useTools = false;
5882
+ try {
5883
+ const info = await ollama.show({ model: cfg.model });
5884
+ useTools = !!(info?.capabilities && info.capabilities.includes('tools'));
5885
+ } catch (err) {
5886
+ 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.`;
5887
+ }
5888
+
5889
+ const allowedSet = new Set((cfg.tools || []).map(normalizeToolName));
5890
+ const consultantNativeTools = useTools ? buildConsultantNativeTools(allowedSet) : [];
5891
+
5892
+ // ── Run the consultation loop ──
5893
+ const startedAt = Date.now();
5894
+ const deadline = startedAt + cfg.timeoutMs;
5895
+ console.log();
5896
+ console.log(box(
5897
+ `${keyValue('model', chalk.white(cfg.model), 11)}\n` +
5898
+ `${keyValue('files', chalk.white(`${attachments.length} attached`), 11)} ${UI.slate('\u00b7')} ${chalk.white(formatBytes(totalBytes))}\n` +
5899
+ `${keyValue('tools', chalk.white(useTools ? `native (${consultantNativeTools.length} read-only)` : 'one-shot (no native tools)'), 11)}\n` +
5900
+ `${keyValue('rounds', chalk.white(`max ${cfg.toolRoundLimit}`), 11)} ${UI.slate('\u00b7')} ${UI.slate(`timeout ${Math.round(cfg.timeoutMs / 1000)}s`)}`,
5901
+ 'Consulting expert', 'magenta'
5902
+ ));
5903
+
5904
+ const consultantSpinner = ora(chalk.magenta(`Consultant (${cfg.model.split(':')[0]}) is thinking...`)).start();
5905
+ let finalAnswer = '';
5906
+ let rounds = 0;
5907
+ try {
5908
+ while (true) {
5909
+ if (Date.now() > deadline) {
5910
+ consultantSpinner.stop();
5911
+ return `Consultant aborted: hit timeout of ${Math.round(cfg.timeoutMs / 1000)}s before finishing.\n\nPartial answer:\n${finalAnswer || '(none produced yet)'}`;
5912
+ }
5913
+
5914
+ const chatOpts = {
5915
+ model: cfg.model,
5916
+ messages: consultMessages,
5917
+ stream: false,
5918
+ options: {
5919
+ temperature: cfg.temperature,
5920
+ ...(cfg.contextLimit ? { num_ctx: cfg.contextLimit } : {}),
5921
+ },
5922
+ };
5923
+ if (cfg.thinking === 'on') chatOpts.think = true;
5924
+ if (useTools && consultantNativeTools.length) chatOpts.tools = consultantNativeTools;
5925
+
5926
+ let resp;
5927
+ try {
5928
+ resp = await ollama.chat(chatOpts);
5929
+ } catch (err) {
5930
+ const msg = err?.message || String(err);
5931
+ if (/does not support thinking/i.test(msg) && chatOpts.think) {
5932
+ delete chatOpts.think;
5933
+ resp = await ollama.chat(chatOpts);
5934
+ } else {
5935
+ throw err;
5936
+ }
5937
+ }
5938
+
5939
+ const msg = resp?.message || {};
5940
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
5941
+ finalAnswer = msg.content || finalAnswer;
5942
+
5943
+ if (toolCalls.length === 0 || rounds >= cfg.toolRoundLimit) {
5944
+ break;
5945
+ }
5946
+
5947
+ // Push assistant message with tool_calls
5948
+ consultMessages.push({ role: 'assistant', content: msg.content || '', tool_calls: toolCalls });
5949
+ rounds++;
5950
+ consultantSpinner.text = chalk.magenta(`Consultant running tool round ${rounds}/${cfg.toolRoundLimit}...`);
5951
+
5952
+ for (const tc of toolCalls) {
5953
+ const fn = tc.function || {};
5954
+ const args = fn.arguments || {};
5955
+ const mapped = normalizeToolName(fn.name || '');
5956
+ if (!allowedSet.has(mapped)) {
5957
+ consultMessages.push({ role: 'tool', tool_name: fn.name, content: `Tool ${fn.name} is not allowed for the consultant (read-only restriction).` });
5958
+ continue;
5959
+ }
5960
+ let toolResult;
5961
+ try {
5962
+ toolResult = await runConsultantToolCall(fn.name, args);
5963
+ } catch (err) {
5964
+ toolResult = `Error: ${err.message}`;
5965
+ }
5966
+ consultMessages.push({
5967
+ role: 'tool',
5968
+ tool_name: fn.name,
5969
+ content: truncateToolText(String(toolResult ?? ''), 16000),
5970
+ });
5971
+ }
5972
+ }
5973
+ } catch (err) {
5974
+ consultantSpinner.stop();
5975
+ return `Error during consultation: ${err.message}`;
5976
+ }
5977
+ consultantSpinner.stop();
5978
+
5979
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
5980
+ const answer = (finalAnswer || '').trim() || '(consultant returned no content)';
5981
+
5982
+ // Save transcript for audit
5983
+ const transcriptBody = [
5984
+ `# Consultation Transcript`,
5985
+ `- timestamp: ${new Date().toISOString()}`,
5986
+ `- model: ${cfg.model}`,
5987
+ `- duration: ${elapsed}s`,
5988
+ `- tool rounds: ${rounds}`,
5989
+ `- files attached: ${attachments.length}`,
5990
+ ``,
5991
+ `## Request`,
5992
+ userPrompt,
5993
+ ``,
5994
+ `## Final answer`,
5995
+ answer,
5996
+ ``,
5997
+ ].join('\n');
5998
+ const transcriptPath = saveConsultantTranscript(cfg, transcriptBody);
5999
+
6000
+ const header = `Consultation complete (${cfg.model.split(':')[0]}, ${elapsed}s, ${rounds} tool round${rounds === 1 ? '' : 's'}${transcriptPath ? `, saved: ${relative(getToolWorkingDirectory(), transcriptPath) || transcriptPath}` : ''}).`;
6001
+ return `${header}\n\n${answer}`;
6002
+ }
6003
+
5575
6004
  const tools = {
5576
6005
  read: (path) => {
5577
6006
  const trimmedPath = typeof path === 'string' ? path.trim() : '';
@@ -6122,7 +6551,8 @@ const tools = {
6122
6551
  }
6123
6552
  });
6124
6553
  });
6125
- }
6554
+ },
6555
+ consult: async (args) => consultExpert(args || {})
6126
6556
  };
6127
6557
 
6128
6558
  async function checkForUpdates() {
@@ -6715,6 +7145,25 @@ async function runSapper() {
6715
7145
  required: ['command']
6716
7146
  }
6717
7147
  }
7148
+ },
7149
+ {
7150
+ type: 'function',
7151
+ function: {
7152
+ name: 'consult_expert',
7153
+ 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.',
7154
+ parameters: {
7155
+ type: 'object',
7156
+ properties: {
7157
+ goal: { type: 'string', description: 'What important thing you are trying to accomplish or about to change.' },
7158
+ question: { type: 'string', description: 'The specific question or decision you need help with.' },
7159
+ 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.' },
7160
+ attempts: { type: 'string', description: 'Optional: what you have already tried and why it did not work.' },
7161
+ hints: { type: 'string', description: 'Optional: suspected causes or constraints worth flagging.' },
7162
+ 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"].' }
7163
+ },
7164
+ required: ['goal', 'question', 'summary']
7165
+ }
7166
+ }
6718
7167
  }
6719
7168
  ];
6720
7169
 
@@ -7747,6 +8196,103 @@ async function runSapper() {
7747
8196
  }
7748
8197
  }
7749
8198
 
8199
+ if (input.toLowerCase() === '/consult' || input.toLowerCase().startsWith('/consult ')) {
8200
+ const arg = input.substring(8).trim();
8201
+ const cfg = getConsultantConfig();
8202
+ const updateConsultant = (patch) => {
8203
+ sapperConfig.consultant = { ...getConsultantConfig(), ...patch };
8204
+ saveConfig(sapperConfig);
8205
+ };
8206
+
8207
+ if (!arg || ['status', 'show'].includes(arg.toLowerCase())) {
8208
+ const lines = [
8209
+ `enabled ${chalk.white(cfg.enabled ? 'on' : 'off')}`,
8210
+ `model ${chalk.white(cfg.model)}`,
8211
+ `tools ${UI.slate(cfg.tools.join(', '))}`,
8212
+ `gating ${UI.slate(`summary>=${cfg.minSummaryWords}w \u00b7 question ${cfg.requireQuestion ? 'required' : 'optional'} \u00b7 goal ${cfg.requireGoal ? 'required' : 'optional'}`)}`,
8213
+ `limits ${UI.slate(`${cfg.maxFiles} files \u00b7 ${formatBytes(cfg.maxFileBytes)}/file \u00b7 ${formatBytes(cfg.totalBytesLimit)} total`)}`,
8214
+ `loop ${UI.slate(`${cfg.toolRoundLimit} tool rounds \u00b7 timeout ${Math.round(cfg.timeoutMs/1000)}s \u00b7 thinking ${cfg.thinking} \u00b7 temp ${cfg.temperature}`)}`,
8215
+ `transcripts ${chalk.white(cfg.saveTranscripts ? 'on' : 'off')} ${UI.slate('\u2192')} ${UI.slate(cfg.transcriptDir)}`,
8216
+ '',
8217
+ UI.slate('Usage: /consult model <name> | /consult on|off | /consult tools <a,b,c> | /consult minwords <N>'),
8218
+ UI.slate(' /consult rounds <N> | /consult timeout <secs> | /consult thinking <auto|on|off> | /consult temp <0..2>'),
8219
+ UI.slate(' /consult transcripts <on|off> | /consult reset'),
8220
+ ];
8221
+ console.log();
8222
+ console.log(box(lines.join('\n'), 'Consultant', 'magenta'));
8223
+ continue;
8224
+ }
8225
+
8226
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
8227
+ const subcommand = subcommandRaw.toLowerCase();
8228
+ const value = rest.join(' ').trim();
8229
+
8230
+ if (subcommand === 'reset' || subcommand === 'default') {
8231
+ sapperConfig.consultant = { ...DEFAULT_CONFIG.consultant };
8232
+ saveConfig(sapperConfig);
8233
+ console.log(chalk.green('Consultant settings reset to defaults.'));
8234
+ continue;
8235
+ }
8236
+ if (['on', 'true', 'yes', 'enable', 'enabled'].includes(subcommand)) { updateConsultant({ enabled: true }); console.log(chalk.green('Consultant enabled.')); continue; }
8237
+ if (['off', 'false', 'no', 'disable', 'disabled'].includes(subcommand)) { updateConsultant({ enabled: false }); console.log(chalk.yellow('Consultant disabled.')); continue; }
8238
+ if (subcommand === 'model') {
8239
+ if (!value) { console.log(chalk.yellow('Usage: /consult model <name>')); continue; }
8240
+ updateConsultant({ model: value });
8241
+ console.log(chalk.green(`Consultant model set to ${value}.`));
8242
+ continue;
8243
+ }
8244
+ if (subcommand === 'tools') {
8245
+ const list = value.split(/[,\s]+/).map(t => t.trim()).filter(Boolean);
8246
+ if (!list.length) { console.log(chalk.yellow('Usage: /consult tools read,list,grep,...')); continue; }
8247
+ updateConsultant({ tools: list });
8248
+ console.log(chalk.green(`Consultant tools: ${list.join(', ')}`));
8249
+ continue;
8250
+ }
8251
+ if (subcommand === 'minwords' || subcommand === 'minsummary') {
8252
+ const n = parseInt(value, 10);
8253
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult minwords <N>')); continue; }
8254
+ updateConsultant({ minSummaryWords: n });
8255
+ console.log(chalk.green(`Min summary words: ${n}`));
8256
+ continue;
8257
+ }
8258
+ if (subcommand === 'rounds') {
8259
+ const n = parseInt(value, 10);
8260
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult rounds <N>')); continue; }
8261
+ updateConsultant({ toolRoundLimit: n });
8262
+ console.log(chalk.green(`Consultant tool rounds: ${n}`));
8263
+ continue;
8264
+ }
8265
+ if (subcommand === 'timeout') {
8266
+ const n = parseInt(value, 10);
8267
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult timeout <seconds>')); continue; }
8268
+ updateConsultant({ timeoutMs: n * 1000 });
8269
+ console.log(chalk.green(`Consultant timeout: ${n}s`));
8270
+ continue;
8271
+ }
8272
+ if (subcommand === 'thinking') {
8273
+ updateConsultant({ thinking: value });
8274
+ console.log(chalk.green(`Consultant thinking: ${getConsultantConfig().thinking}`));
8275
+ continue;
8276
+ }
8277
+ if (subcommand === 'temp' || subcommand === 'temperature') {
8278
+ const n = Number(value);
8279
+ if (!Number.isFinite(n)) { console.log(chalk.yellow('Usage: /consult temp <0..2>')); continue; }
8280
+ updateConsultant({ temperature: n });
8281
+ console.log(chalk.green(`Consultant temperature: ${getConsultantConfig().temperature}`));
8282
+ continue;
8283
+ }
8284
+ if (subcommand === 'transcripts' || subcommand === 'transcript') {
8285
+ if (['on', 'true', 'yes'].includes(value.toLowerCase())) { updateConsultant({ saveTranscripts: true }); console.log(chalk.green('Consultant transcripts: on')); continue; }
8286
+ if (['off', 'false', 'no'].includes(value.toLowerCase())) { updateConsultant({ saveTranscripts: false }); console.log(chalk.yellow('Consultant transcripts: off')); continue; }
8287
+ console.log(chalk.yellow('Usage: /consult transcripts on|off'));
8288
+ continue;
8289
+ }
8290
+
8291
+ console.log(chalk.yellow(`Unknown consult option: ${subcommand}`));
8292
+ 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 reset'));
8293
+ continue;
8294
+ }
8295
+
7750
8296
  if (input.toLowerCase().startsWith('/ui')) {
7751
8297
  const arg = input.substring(3).trim();
7752
8298
  const currentUI = getUIConfig();
@@ -9082,7 +9628,8 @@ async function runSapper() {
9082
9628
  write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
9083
9629
  ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
9084
9630
  pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
9085
- fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
9631
+ fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL',
9632
+ consult_expert: 'CONSULT'
9086
9633
  };
9087
9634
 
9088
9635
  showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
@@ -9210,6 +9757,10 @@ async function runSapper() {
9210
9757
  result = await tools.shell(args.command);
9211
9758
  logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
9212
9759
  break;
9760
+ case 'consult_expert':
9761
+ result = await tools.consult(args);
9762
+ 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 });
9763
+ break;
9213
9764
  default:
9214
9765
  result = `Unknown tool: ${fn.name}`;
9215
9766
  toolSuccess = false;
@@ -9476,9 +10027,32 @@ async function runSapper() {
9476
10027
  const approved = !result.includes('blocked');
9477
10028
  logEntry('shell', { command: path, duration: Date.now() - toolStart, userApproved: approved, exitCode: result.match(/code (\d+)/)?.[1] ?? null });
9478
10029
  }
10030
+ else if (type.toLowerCase() === 'consult') {
10031
+ // Text-marker form: [TOOL:CONSULT]goal:::question:::summary:::file1,file2[/TOOL]
10032
+ // Or pass a single JSON blob in `path`: {"goal":"...","question":"...","summary":"...","files":["..."]}
10033
+ let consultArgs = null;
10034
+ const raw = (content && content.trim()) ? `${path}:::${content}` : String(path || '');
10035
+ const trimmedRaw = raw.trim();
10036
+ if (trimmedRaw.startsWith('{')) {
10037
+ try { consultArgs = JSON.parse(trimmedRaw); } catch { consultArgs = null; }
10038
+ }
10039
+ if (!consultArgs) {
10040
+ const parts = trimmedRaw.split(/\s*:::\s*/);
10041
+ consultArgs = {
10042
+ goal: parts[0] || '',
10043
+ question: parts[1] || '',
10044
+ summary: parts[2] || '',
10045
+ attempts: parts[3] || '',
10046
+ hints: parts[4] || '',
10047
+ files: parts[5] ? parts[5].split(/[,\n]/).map(s => s.trim()).filter(Boolean) : [],
10048
+ };
10049
+ }
10050
+ result = await tools.consult(consultArgs);
10051
+ logEntry('tool', { toolType: 'CONSULT', path: (consultArgs.question || 'consult').slice(0, 80), duration: Date.now() - toolStart, success: !String(result).startsWith('Error'), resultSize: result?.length });
10052
+ }
9479
10053
 
9480
10054
  // 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())) {
10055
+ 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
10056
  logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
9483
10057
  }
9484
10058