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.
- package/package.json +1 -1
- package/sapper.mjs +579 -5
package/package.json
CHANGED
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
|
|