sapper-iq 1.1.37 → 1.1.38

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/sapper.mjs CHANGED
@@ -818,7 +818,10 @@ function buildSystemPrompt(agentContent = null, skillContents = []) {
818
818
  const now = new Date();
819
819
  const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
820
820
  const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
821
- let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
821
+ const promptConfig = getPromptConfig();
822
+ const promptPrepend = promptConfig.prepend.trim();
823
+ const promptAppend = promptConfig.append.trim();
824
+ const corePrompt = promptConfig.coreOverride.trim() || `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
822
825
  You can help with ANY task - coding, writing, research, planning, analysis, and more.
823
826
  Adapt your personality and expertise based on the active agent role and loaded skills.
824
827
 
@@ -830,6 +833,9 @@ RULES:
830
833
  3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
831
834
  4. VERIFY: After making changes, verify they work (run tests, check output, etc).
832
835
  5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`;
836
+ let prompt = promptPrepend
837
+ ? `${wrapPromptCustomizationBlock('CUSTOM PROMPT PREPEND', promptPrepend, false)}\n\n${corePrompt}`
838
+ : corePrompt;
833
839
 
834
840
  if (_useNativeToolsFlag) {
835
841
  prompt += `
@@ -841,7 +847,12 @@ Available tools: list_directory, read_file, search_files, write_file, patch_file
841
847
  PATCH TIPS:
842
848
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
843
849
  - Always read_file first to see exact content before using patch_file.
844
- - If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.`;
850
+ - If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.
851
+
852
+ SHELL TIPS:
853
+ - run_shell may keep long-running commands in a background session depending on config.
854
+ - If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
855
+ - Use run_shell command "__shell_list__" to list sessions and "__shell_stop__ <session_id>" to stop one.`;
845
856
  } else {
846
857
  prompt += `
847
858
 
@@ -859,6 +870,11 @@ PATCH TIPS:
859
870
  - Always READ the file first to see exact content before using PATCH.
860
871
  - If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
861
872
 
873
+ SHELL TIPS:
874
+ - Long-running commands may be moved to a background shell session depending on config.
875
+ - If shell output mentions a session id, inspect more output with [TOOL:SHELL]__shell_read__ <session_id>[/TOOL].
876
+ - Use [TOOL:SHELL]__shell_list__[/TOOL] to list sessions and [TOOL:SHELL]__shell_stop__ <session_id>[/TOOL] to stop one.
877
+
862
878
  You MUST use the [TOOL:...][/TOOL] syntax above to perform actions. This is how you interact with the filesystem and shell - there is no other way. When you want to read a file, output [TOOL:READ]path[/TOOL] in your response. When you want to list a directory, output [TOOL:LIST].[/TOOL]. Always actually use the tools - do not just describe what you would do.
863
879
  Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
864
880
  }
@@ -894,6 +910,10 @@ FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to u
894
910
  prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
895
911
  }
896
912
 
913
+ if (promptAppend) {
914
+ prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
915
+ }
916
+
897
917
  return prompt;
898
918
  }
899
919
 
@@ -902,20 +922,204 @@ let currentAgent = null; // null = default Sapper, or agent name string
902
922
  let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
903
923
  let loadedSkills = []; // array of skill names currently loaded
904
924
 
905
- // Load config (settings like autoAttach)
925
+ const DEFAULT_CONFIG = Object.freeze({
926
+ autoAttach: true,
927
+ contextLimit: null,
928
+ toolRoundLimit: 40,
929
+ summaryPhases: true,
930
+ summarizeTriggerPercent: 65,
931
+ shell: Object.freeze({
932
+ streamToModel: true,
933
+ backgroundMode: 'auto',
934
+ backgroundAfterSeconds: 8,
935
+ outputChunkChars: 4000,
936
+ }),
937
+ streaming: Object.freeze({
938
+ showPhaseStatus: true,
939
+ showHeartbeat: true,
940
+ idleNoticeSeconds: 4,
941
+ }),
942
+ thinking: Object.freeze({
943
+ mode: 'auto',
944
+ }),
945
+ prompt: Object.freeze({
946
+ prepend: '',
947
+ append: '',
948
+ coreOverride: '',
949
+ }),
950
+ });
951
+
952
+ function normalizeBoolean(value, fallback) {
953
+ if (typeof value === 'boolean') return value;
954
+ if (typeof value === 'string') {
955
+ const normalized = value.trim().toLowerCase();
956
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
957
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false;
958
+ }
959
+ return fallback;
960
+ }
961
+
962
+ function normalizeContextLimit(value) {
963
+ const parsed = Number(value);
964
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : null;
965
+ }
966
+
967
+ function normalizeSummarizeTriggerPercent(value) {
968
+ let parsed = Number(value);
969
+ if (!Number.isFinite(parsed)) return DEFAULT_CONFIG.summarizeTriggerPercent;
970
+ if (parsed > 0 && parsed <= 1) parsed *= 100;
971
+ return Math.max(40, Math.min(90, Math.round(parsed)));
972
+ }
973
+
974
+ function normalizeToolRoundLimit(value) {
975
+ return normalizeIntegerInRange(value, DEFAULT_CONFIG.toolRoundLimit, 1, 200);
976
+ }
977
+
978
+ function normalizeThinkingMode(value) {
979
+ if (typeof value === 'boolean') return value ? 'on' : 'off';
980
+ const normalized = String(value ?? '').trim().toLowerCase();
981
+ if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
982
+ if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
983
+ return 'auto';
984
+ }
985
+
986
+ function normalizeShellBackgroundMode(value) {
987
+ if (typeof value === 'boolean') return value ? 'on' : 'off';
988
+ const normalized = String(value ?? '').trim().toLowerCase();
989
+ if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
990
+ if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
991
+ return 'auto';
992
+ }
993
+
994
+ function normalizeThinkingConfig(thinkingConfig = {}) {
995
+ if (typeof thinkingConfig === 'boolean' || typeof thinkingConfig === 'string') {
996
+ return { mode: normalizeThinkingMode(thinkingConfig) };
997
+ }
998
+
999
+ if (!thinkingConfig || typeof thinkingConfig !== 'object' || Array.isArray(thinkingConfig)) {
1000
+ return { ...DEFAULT_CONFIG.thinking };
1001
+ }
1002
+
1003
+ return {
1004
+ mode: normalizeThinkingMode(thinkingConfig.mode),
1005
+ };
1006
+ }
1007
+
1008
+ function normalizeShellConfig(shellConfig = {}) {
1009
+ if (typeof shellConfig === 'boolean' || typeof shellConfig === 'string') {
1010
+ return {
1011
+ ...DEFAULT_CONFIG.shell,
1012
+ backgroundMode: normalizeShellBackgroundMode(shellConfig),
1013
+ };
1014
+ }
1015
+
1016
+ if (!shellConfig || typeof shellConfig !== 'object' || Array.isArray(shellConfig)) {
1017
+ return { ...DEFAULT_CONFIG.shell };
1018
+ }
1019
+
1020
+ return {
1021
+ streamToModel: normalizeBoolean(shellConfig.streamToModel, DEFAULT_CONFIG.shell.streamToModel),
1022
+ backgroundMode: normalizeShellBackgroundMode(shellConfig.backgroundMode),
1023
+ backgroundAfterSeconds: normalizeIntegerInRange(shellConfig.backgroundAfterSeconds, DEFAULT_CONFIG.shell.backgroundAfterSeconds, 2, 120),
1024
+ outputChunkChars: normalizeIntegerInRange(shellConfig.outputChunkChars, DEFAULT_CONFIG.shell.outputChunkChars, 400, 12000),
1025
+ };
1026
+ }
1027
+
1028
+ function normalizeIntegerInRange(value, fallback, min, max) {
1029
+ const parsed = Number(value);
1030
+ if (!Number.isFinite(parsed)) return fallback;
1031
+ return Math.max(min, Math.min(max, Math.round(parsed)));
1032
+ }
1033
+
1034
+ function normalizeStreamingConfig(streamingConfig = {}) {
1035
+ if (typeof streamingConfig === 'boolean') {
1036
+ return {
1037
+ ...DEFAULT_CONFIG.streaming,
1038
+ showPhaseStatus: streamingConfig,
1039
+ showHeartbeat: streamingConfig,
1040
+ };
1041
+ }
1042
+
1043
+ if (!streamingConfig || typeof streamingConfig !== 'object' || Array.isArray(streamingConfig)) {
1044
+ return { ...DEFAULT_CONFIG.streaming };
1045
+ }
1046
+
1047
+ return {
1048
+ showPhaseStatus: normalizeBoolean(streamingConfig.showPhaseStatus, DEFAULT_CONFIG.streaming.showPhaseStatus),
1049
+ showHeartbeat: normalizeBoolean(streamingConfig.showHeartbeat, DEFAULT_CONFIG.streaming.showHeartbeat),
1050
+ idleNoticeSeconds: normalizeIntegerInRange(streamingConfig.idleNoticeSeconds, DEFAULT_CONFIG.streaming.idleNoticeSeconds, 2, 60),
1051
+ };
1052
+ }
1053
+
1054
+ function normalizePromptText(value) {
1055
+ if (typeof value === 'string') return value;
1056
+ if (value === null || value === undefined) return '';
1057
+ return String(value);
1058
+ }
1059
+
1060
+ function normalizePromptConfig(promptConfig = {}) {
1061
+ if (!promptConfig || typeof promptConfig !== 'object' || Array.isArray(promptConfig)) {
1062
+ return {
1063
+ ...DEFAULT_CONFIG.prompt,
1064
+ append: normalizePromptText(promptConfig),
1065
+ };
1066
+ }
1067
+
1068
+ const coreOverride = promptConfig.coreOverride !== undefined
1069
+ ? promptConfig.coreOverride
1070
+ : promptConfig.override;
1071
+
1072
+ return {
1073
+ prepend: normalizePromptText(promptConfig.prepend),
1074
+ append: normalizePromptText(promptConfig.append),
1075
+ coreOverride: normalizePromptText(coreOverride),
1076
+ };
1077
+ }
1078
+
1079
+ function normalizeConfig(config = {}) {
1080
+ return {
1081
+ ...config,
1082
+ autoAttach: normalizeBoolean(config.autoAttach, DEFAULT_CONFIG.autoAttach),
1083
+ contextLimit: normalizeContextLimit(config.contextLimit),
1084
+ toolRoundLimit: normalizeToolRoundLimit(config.toolRoundLimit),
1085
+ summaryPhases: normalizeBoolean(config.summaryPhases, DEFAULT_CONFIG.summaryPhases),
1086
+ summarizeTriggerPercent: normalizeSummarizeTriggerPercent(config.summarizeTriggerPercent),
1087
+ shell: normalizeShellConfig(config.shell),
1088
+ streaming: normalizeStreamingConfig(config.streaming),
1089
+ thinking: normalizeThinkingConfig(config.thinking),
1090
+ prompt: normalizePromptConfig(config.prompt),
1091
+ };
1092
+ }
1093
+
1094
+ // Load config (settings like autoAttach and context summarization)
906
1095
  function loadConfig() {
907
1096
  try {
908
1097
  ensureSapperDir();
909
1098
  if (fs.existsSync(CONFIG_FILE)) {
910
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
1099
+ const rawConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
1100
+ const normalizedConfig = normalizeConfig(rawConfig);
1101
+ if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) {
1102
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
1103
+ }
1104
+ return normalizedConfig;
1105
+ }
1106
+ } catch (e) {}
1107
+
1108
+ const defaultConfig = normalizeConfig();
1109
+ try {
1110
+ ensureSapperDir();
1111
+ if (!fs.existsSync(CONFIG_FILE)) {
1112
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2));
911
1113
  }
912
1114
  } catch (e) {}
913
- return { autoAttach: true, contextLimit: null }; // Default: auto-attach ON, no custom context limit
1115
+ return defaultConfig;
914
1116
  }
915
1117
 
916
1118
  function saveConfig(config) {
917
1119
  ensureSapperDir();
918
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1120
+ const normalizedConfig = normalizeConfig(config);
1121
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
1122
+ sapperConfig = normalizedConfig;
919
1123
  }
920
1124
 
921
1125
  // Global config
@@ -929,6 +1133,202 @@ function effectiveContextLength() {
929
1133
  return modelContextLength;
930
1134
  }
931
1135
 
1136
+ const SUMMARY_PHASES = [
1137
+ 'Prepare summary request',
1138
+ 'Summarize older messages',
1139
+ 'Save compressed context',
1140
+ 'Resume your prompt',
1141
+ ];
1142
+
1143
+ function summaryPhasesEnabled() {
1144
+ return sapperConfig.summaryPhases !== false;
1145
+ }
1146
+
1147
+ function toolRoundLimit() {
1148
+ return normalizeToolRoundLimit(sapperConfig.toolRoundLimit);
1149
+ }
1150
+
1151
+ function getShellConfig() {
1152
+ return normalizeShellConfig(sapperConfig.shell);
1153
+ }
1154
+
1155
+ function shellStreamToModelEnabled() {
1156
+ return getShellConfig().streamToModel;
1157
+ }
1158
+
1159
+ function shellBackgroundMode() {
1160
+ return getShellConfig().backgroundMode;
1161
+ }
1162
+
1163
+ function shellBackgroundAfterSeconds() {
1164
+ return getShellConfig().backgroundAfterSeconds;
1165
+ }
1166
+
1167
+ function shellOutputChunkChars() {
1168
+ return getShellConfig().outputChunkChars;
1169
+ }
1170
+
1171
+ function summaryTriggerPercent() {
1172
+ return normalizeSummarizeTriggerPercent(sapperConfig.summarizeTriggerPercent);
1173
+ }
1174
+
1175
+ function summaryTokenThreshold(ctxLen) {
1176
+ return ctxLen ? Math.floor(ctxLen * (summaryTriggerPercent() / 100)) : 8000;
1177
+ }
1178
+
1179
+ function parseSummaryTriggerInput(value) {
1180
+ if (value === undefined || value === null) return null;
1181
+ const normalized = String(value).trim().replace(/%$/, '');
1182
+ if (!normalized) return null;
1183
+
1184
+ let parsed = Number(normalized);
1185
+ if (!Number.isFinite(parsed)) return null;
1186
+ if (parsed > 0 && parsed <= 1) parsed *= 100;
1187
+
1188
+ return Math.max(40, Math.min(90, Math.round(parsed)));
1189
+ }
1190
+
1191
+ function summaryPhaseText(stepNumber, detail = '') {
1192
+ const fallback = SUMMARY_PHASES[stepNumber - 1] || 'Context summarization';
1193
+ if (!summaryPhasesEnabled()) {
1194
+ return detail || fallback;
1195
+ }
1196
+ return detail
1197
+ ? `Step ${stepNumber}/${SUMMARY_PHASES.length} ${detail}`
1198
+ : `Step ${stepNumber}/${SUMMARY_PHASES.length} ${fallback}`;
1199
+ }
1200
+
1201
+ function renderSummaryPhaseList(activeStep = null) {
1202
+ return SUMMARY_PHASES
1203
+ .map((label, index) => {
1204
+ const stepNumber = index + 1;
1205
+ const line = `Step ${stepNumber}/${SUMMARY_PHASES.length} ${label}`;
1206
+ return activeStep === stepNumber ? chalk.cyan(line) : UI.slate(line);
1207
+ })
1208
+ .join('\n');
1209
+ }
1210
+
1211
+ function getPromptConfig() {
1212
+ return normalizePromptConfig(sapperConfig.prompt);
1213
+ }
1214
+
1215
+ function getThinkingConfig() {
1216
+ return normalizeThinkingConfig(sapperConfig.thinking);
1217
+ }
1218
+
1219
+ function getStreamingConfig() {
1220
+ return normalizeStreamingConfig(sapperConfig.streaming);
1221
+ }
1222
+
1223
+ function streamPhaseStatusEnabled() {
1224
+ return getStreamingConfig().showPhaseStatus;
1225
+ }
1226
+
1227
+ function streamHeartbeatEnabled() {
1228
+ return getStreamingConfig().showHeartbeat;
1229
+ }
1230
+
1231
+ function streamIdleNoticeSeconds() {
1232
+ return getStreamingConfig().idleNoticeSeconds;
1233
+ }
1234
+
1235
+ function thinkingMode() {
1236
+ return getThinkingConfig().mode;
1237
+ }
1238
+
1239
+ function normalizeThinkingInput(input = '') {
1240
+ let normalized = String(input ?? '').trim();
1241
+ if (normalized.startsWith('/') && normalized.includes(' ')) {
1242
+ normalized = normalized.substring(normalized.indexOf(' ') + 1).trim();
1243
+ }
1244
+ return normalized;
1245
+ }
1246
+
1247
+ function isSimplePrompt(input = '') {
1248
+ const normalized = normalizeThinkingInput(input).toLowerCase();
1249
+ if (!normalized) return true;
1250
+ if (normalized.includes('\n')) return false;
1251
+ if (/@|https?:\/\//.test(normalized)) return false;
1252
+ if (/[`{}[\]();<>]/.test(normalized)) return false;
1253
+ if (/^(hi|hello|hey|thanks|thank you|ok|okay|continue|go on|proceed|yes|no|y|n|cool|nice|bye|good morning|good evening)$/.test(normalized)) {
1254
+ return true;
1255
+ }
1256
+ if (/\b(analyze|debug|fix|implement|refactor|design|plan|optimi[sz]e|architect|investigate|review|build|create|generate|search|find|error|bug|test|compare|explain deeply)\b/.test(normalized)) {
1257
+ return false;
1258
+ }
1259
+ if (normalized.length <= 32) return true;
1260
+ return normalized.length <= 60 && normalized.split(/\s+/).length <= 8;
1261
+ }
1262
+
1263
+ function shouldUseThinkingForInput(input = '') {
1264
+ const mode = thinkingMode();
1265
+ if (mode === 'on') return true;
1266
+ if (mode === 'off') return false;
1267
+ return !isSimplePrompt(input);
1268
+ }
1269
+
1270
+ function isLikelyLongRunningCommand(command = '') {
1271
+ const normalized = String(command ?? '').trim().toLowerCase();
1272
+ if (!normalized) return false;
1273
+
1274
+ const patterns = [
1275
+ /\buvicorn\b/,
1276
+ /\bnpm\s+run\s+(dev|start|watch)\b/,
1277
+ /\bpnpm\s+(dev|start|watch)\b/,
1278
+ /\byarn\s+(dev|start|watch)\b/,
1279
+ /\bnext\s+dev\b/,
1280
+ /\bvite\b/,
1281
+ /\bnodemon\b/,
1282
+ /\bdocker\s+compose\s+up\b/,
1283
+ /\bwebpack(?:\s+serve|\s+--watch)?\b/,
1284
+ /\bpython\s+-m\s+http\.server\b/,
1285
+ /\btail\s+-f\b/,
1286
+ /\bserve\b/,
1287
+ /--reload\b/,
1288
+ /--watch\b/
1289
+ ];
1290
+
1291
+ return patterns.some(pattern => pattern.test(normalized));
1292
+ }
1293
+
1294
+ function shouldBackgroundShellCommand(command = '') {
1295
+ const mode = shellBackgroundMode();
1296
+ if (mode === 'off') return false;
1297
+ if (mode === 'on') return true;
1298
+ return isLikelyLongRunningCommand(command);
1299
+ }
1300
+
1301
+ function hasCustomPromptConfig() {
1302
+ const promptConfig = getPromptConfig();
1303
+ return Boolean(promptConfig.prepend.trim() || promptConfig.append.trim() || promptConfig.coreOverride.trim());
1304
+ }
1305
+
1306
+ function wrapPromptCustomizationBlock(title, content, leadingNewline = true) {
1307
+ const normalized = String(content ?? '').trim();
1308
+ if (!normalized) return '';
1309
+ const prefix = leadingNewline ? '\n\n' : '';
1310
+ return `${prefix}═══ ${title} ═══\n${normalized}\n═══ END ${title} ═══`;
1311
+ }
1312
+
1313
+ function resolveLoadedSkillContents() {
1314
+ const allSkills = loadSkills();
1315
+ return loadedSkills.map(skillName => allSkills[skillName]?.content || '').filter(Boolean);
1316
+ }
1317
+
1318
+ function resolveActiveAgentContent() {
1319
+ if (!currentAgent) return null;
1320
+ const allAgents = loadAgents();
1321
+ return allAgents[currentAgent]?.content || null;
1322
+ }
1323
+
1324
+ function refreshSystemPrompt(messages) {
1325
+ if (!Array.isArray(messages) || messages.length === 0) return;
1326
+ messages[0] = {
1327
+ role: 'system',
1328
+ content: buildSystemPrompt(resolveActiveAgentContent(), resolveLoadedSkillContents())
1329
+ };
1330
+ }
1331
+
932
1332
  // ═══════════════════════════════════════════════════════════════
933
1333
  // WORKSPACE GRAPH - Track file relationships and summaries
934
1334
  // ═══════════════════════════════════════════════════════════════
@@ -1467,9 +1867,9 @@ async function autoSummarizeContext(messages, model, force = false) {
1467
1867
  const estimatedTokens = estimateMessagesTokens(messages);
1468
1868
  const contextSize = JSON.stringify(messages).length;
1469
1869
 
1470
- // Summarize when we hit 75% of effective context window (leave room for response)
1870
+ // Summarize when we hit the configured share of the effective context window
1471
1871
  const ctxLen = effectiveContextLength();
1472
- const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
1872
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
1473
1873
  // Also keep the old byte-based check as a fallback
1474
1874
  const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
1475
1875
  (!ctxLen && contextSize > 32000);
@@ -1481,14 +1881,22 @@ async function autoSummarizeContext(messages, model, force = false) {
1481
1881
  : Math.round((contextSize / 32000) * 100);
1482
1882
 
1483
1883
  console.log();
1484
- console.log(box(
1485
- `Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})\n` +
1486
- `${chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`)}\n` +
1487
- `${chalk.cyan('Auto-summarizing to stay within context window...')}`,
1488
- '🧠 Context Window Management', 'cyan'
1489
- ));
1884
+ const summaryIntroLines = [
1885
+ `Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})`,
1886
+ chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`),
1887
+ chalk.cyan('Auto-summarizing to stay within context window before answering your prompt...'),
1888
+ chalk.gray(`Trigger: ${summaryTriggerPercent()}% of the active context window (${tokenThreshold.toLocaleString()} tokens)`),
1889
+ chalk.gray('This is an extra model call, so large contexts can pause here for a while.'),
1890
+ ];
1891
+ if (summaryPhasesEnabled()) {
1892
+ summaryIntroLines.push('');
1893
+ summaryIntroLines.push(renderSummaryPhaseList(1));
1894
+ }
1895
+ console.log(box(summaryIntroLines.join('\n'), '🧠 Context Window Management', 'cyan'));
1490
1896
 
1491
- const summarySpinner = ora('Summarizing conversation...').start();
1897
+ const summaryStart = Date.now();
1898
+ const elapsedSummaryTime = () => `${Math.max(0, Math.round((Date.now() - summaryStart) / 1000))}s`;
1899
+ const summarySpinner = ora(summaryPhaseText(1, 'Preparing summary request...')).start();
1492
1900
 
1493
1901
  // Separate: system prompt, messages to summarize, recent messages to keep
1494
1902
  const systemPrompt = messages[0];
@@ -1542,14 +1950,13 @@ async function autoSummarizeContext(messages, model, force = false) {
1542
1950
  })
1543
1951
  .join('\n\n');
1544
1952
 
1953
+ const conversationTokens = estimateTokens(conversationText);
1954
+ const conversationBytes = Buffer.byteLength(conversationText, 'utf8');
1955
+ summarySpinner.text = summaryPhaseText(1, `Preparing summary request from ${oldMessages.length} older messages (~${conversationTokens.toLocaleString()} tokens, ${formatBytes(conversationBytes)})`);
1956
+ let spinnerInterval = null;
1957
+
1545
1958
  try {
1546
- const summaryResponse = await ollama.chat({
1547
- model,
1548
- ...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
1549
- messages: [
1550
- {
1551
- role: 'system',
1552
- content: `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
1959
+ const summaryInstruction = `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
1553
1960
  - Key topics discussed and decisions made
1554
1961
  - Files that were read, created, or modified (with paths)
1555
1962
  - Important code changes or bugs found
@@ -1561,7 +1968,20 @@ async function autoSummarizeContext(messages, model, force = false) {
1561
1968
 
1562
1969
  CRITICAL: The AI assistant uses tools with syntax like [TOOL:READ]path[/TOOL]. Make sure to note which tools were used so the assistant remembers to keep using them after this summary.
1563
1970
 
1564
- Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`
1971
+ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`;
1972
+ const summaryInputTokens = estimateTokens(summaryInstruction) + estimateTokens(`Summarize this conversation:\n\n${conversationText}`);
1973
+ summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
1974
+ spinnerInterval = setInterval(() => {
1975
+ summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
1976
+ }, 1000);
1977
+
1978
+ const summaryResponse = await ollama.chat({
1979
+ model,
1980
+ ...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
1981
+ messages: [
1982
+ {
1983
+ role: 'system',
1984
+ content: summaryInstruction
1565
1985
  },
1566
1986
  {
1567
1987
  role: 'user',
@@ -1570,10 +1990,13 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1570
1990
  ],
1571
1991
  stream: false
1572
1992
  });
1993
+ clearInterval(spinnerInterval);
1994
+ spinnerInterval = null;
1573
1995
 
1574
1996
  const summary = summaryResponse.message.content;
1575
1997
 
1576
1998
  // Save old messages to embeddings before discarding
1999
+ summarySpinner.text = summaryPhaseText(3, `Saving compressed context and memory (${elapsedSummaryTime()} elapsed)`);
1577
2000
  const embeddings = loadEmbeddings();
1578
2001
  const textToEmbed = oldMessages
1579
2002
  .filter(m => m.role !== 'system')
@@ -1626,10 +2049,16 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1626
2049
  const newSize = JSON.stringify(newMessages).length;
1627
2050
  const newTokens = estimateMessagesTokens(newMessages);
1628
2051
  summarySpinner.stop();
2052
+ if (summaryPhasesEnabled()) {
2053
+ console.log(chalk.gray(` ${summaryPhaseText(4, 'Context ready. Returning to chat...')}`));
2054
+ }
1629
2055
  console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
1630
2056
  if (ctxLen) {
1631
2057
  const newPercent = Math.round((newTokens / ctxLen) * 100);
1632
2058
  console.log(chalk.gray(` 📊 Context window usage: ${newPercent}% of ${ctxLen.toLocaleString()} tokens`));
2059
+ if (newPercent >= 80) {
2060
+ console.log(chalk.yellow(' ⚠️ Context is still dense, so the next reply may still be slower than usual.'));
2061
+ }
1633
2062
  }
1634
2063
  if (embeddings.chunks.length > 0) {
1635
2064
  console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
@@ -1642,6 +2071,7 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1642
2071
 
1643
2072
  return newMessages;
1644
2073
  } catch (e) {
2074
+ if (spinnerInterval) clearInterval(spinnerInterval);
1645
2075
  summarySpinner.stop();
1646
2076
  console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
1647
2077
  console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
@@ -1788,7 +2218,54 @@ function promptShell(label, detail = '') {
1788
2218
  return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
1789
2219
  }
1790
2220
 
1791
- function confirmPrompt(label, type = 'warning') {
2221
+ function renderedTerminalLineCount(text = '', width = process.stdout.columns || 80) {
2222
+ const terminalColumns = Math.max(1, width || 80);
2223
+ return String(text ?? '')
2224
+ .split('\n')
2225
+ .reduce((count, line) => count + Math.max(1, Math.ceil(Math.max(1, visibleLength(line)) / terminalColumns)), 0);
2226
+ }
2227
+
2228
+ function clearPromptEcho(promptText, inputText = '') {
2229
+ const totalLines = renderedTerminalLineCount(`${promptText}${inputText}`);
2230
+ for (let index = 0; index < totalLines; index++) {
2231
+ process.stdout.write('\x1B[1A\x1B[2K');
2232
+ }
2233
+ process.stdout.write('\r');
2234
+ }
2235
+
2236
+ function streamPhaseMessage(message, type = 'neutral') {
2237
+ const colorFn = BADGE_STYLES[type] || UI.slate;
2238
+ return `${colorFn('[status]')} ${UI.slate(message)}`;
2239
+ }
2240
+
2241
+ function showStreamPhase(message, type = 'neutral') {
2242
+ if (!streamPhaseStatusEnabled()) return;
2243
+ console.log(streamPhaseMessage(message, type));
2244
+ }
2245
+
2246
+ function renderStreamingHeartbeat({
2247
+ genTokenCount = 0,
2248
+ genStartTime,
2249
+ lastVisibleActivityAt,
2250
+ stage = 'generating',
2251
+ }) {
2252
+ const elapsedSeconds = Math.max((Date.now() - genStartTime) / 1000, 0.1);
2253
+ const elapsed = elapsedSeconds.toFixed(1);
2254
+ const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
2255
+ const idleThreshold = streamIdleNoticeSeconds();
2256
+
2257
+ if (stage === 'waiting-first') {
2258
+ const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s` : '';
2259
+ process.stdout.write(`\r ${UI.slate(`Waiting for first model chunk... ${elapsed}s elapsed${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
2260
+ return;
2261
+ }
2262
+
2263
+ const tps = genTokenCount / elapsedSeconds;
2264
+ const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s for next chunk` : '';
2265
+ process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
2266
+ }
2267
+
2268
+ function confirmPrompt(label, type = 'warning', optionsLabel = '[y/N] ') {
1792
2269
  const colors = {
1793
2270
  info: UI.accent,
1794
2271
  success: UI.mint,
@@ -1798,7 +2275,246 @@ function confirmPrompt(label, type = 'warning') {
1798
2275
  neutral: UI.slate,
1799
2276
  };
1800
2277
  const colorFn = colors[type] || UI.gold;
1801
- return colorFn(`\n${label}? `) + UI.slate('[y/N] ');
2278
+ return colorFn(`\n${label}? `) + UI.slate(optionsLabel);
2279
+ }
2280
+
2281
+ function parseApprovalShortcut(input = '') {
2282
+ const trimmed = String(input ?? '').trim();
2283
+ if (!trimmed) return null;
2284
+
2285
+ const match = trimmed.match(/^(f|feedback|e|edit)\b(?:\s*[:=-]?\s*(.*))?$/i);
2286
+ if (!match) return null;
2287
+
2288
+ const command = match[1].toLowerCase();
2289
+ return {
2290
+ type: command.startsWith('e') ? 'edit' : 'feedback',
2291
+ detail: String(match[2] ?? '').trim(),
2292
+ };
2293
+ }
2294
+
2295
+ async function resolveApprovalInstruction(input, {
2296
+ feedbackPrompt = 'Feedback for Sapper: ',
2297
+ editPrompt = 'Edit instruction for Sapper: ',
2298
+ } = {}) {
2299
+ const shortcut = parseApprovalShortcut(input);
2300
+ if (!shortcut) return null;
2301
+
2302
+ let detail = shortcut.detail;
2303
+ if (!detail) {
2304
+ const promptLabel = shortcut.type === 'edit' ? editPrompt : feedbackPrompt;
2305
+ detail = String(await safeQuestion(chalk.cyan(promptLabel))).trim();
2306
+ }
2307
+
2308
+ return {
2309
+ type: shortcut.type,
2310
+ detail,
2311
+ };
2312
+ }
2313
+
2314
+ const shellSessions = new Map();
2315
+ let shellSessionCounter = 0;
2316
+ const SHELL_OUTPUT_BUFFER_MAX_CHARS = 50000;
2317
+
2318
+ function createShellSession(command, cwd, proc) {
2319
+ const id = `shell-${++shellSessionCounter}`;
2320
+ const session = {
2321
+ id,
2322
+ command,
2323
+ cwd,
2324
+ proc,
2325
+ startedAt: Date.now(),
2326
+ output: '',
2327
+ reportedOffset: 0,
2328
+ completed: false,
2329
+ backgrounded: false,
2330
+ exitCode: null,
2331
+ signal: null,
2332
+ error: null,
2333
+ liveEchoEnabled: true,
2334
+ };
2335
+ shellSessions.set(id, session);
2336
+ return session;
2337
+ }
2338
+
2339
+ function activeShellSessionCount() {
2340
+ return Array.from(shellSessions.values()).filter(session => !session.completed).length;
2341
+ }
2342
+
2343
+ function appendShellSessionOutput(session, text) {
2344
+ if (!session || !text) return;
2345
+ session.output += text;
2346
+ if (session.output.length > SHELL_OUTPUT_BUFFER_MAX_CHARS) {
2347
+ const overflow = session.output.length - SHELL_OUTPUT_BUFFER_MAX_CHARS;
2348
+ session.output = session.output.slice(overflow);
2349
+ session.reportedOffset = Math.max(0, session.reportedOffset - overflow);
2350
+ }
2351
+ }
2352
+
2353
+ function formatShellOutputChunk(text = '', emptyLabel = '(no output yet)') {
2354
+ const normalized = String(text ?? '').trim();
2355
+ if (!normalized) return emptyLabel;
2356
+ const maxChars = shellOutputChunkChars();
2357
+ if (normalized.length <= maxChars) return normalized;
2358
+ return `... (showing last ${maxChars.toLocaleString()} chars)\n${normalized.slice(-maxChars)}`;
2359
+ }
2360
+
2361
+ function shellSessionUsageHint(sessionId) {
2362
+ return `Use run_shell with command \"__shell_read__ ${sessionId}\" to inspect more output, \"__shell_list__\" to list sessions, or \"__shell_stop__ ${sessionId}\" to stop it.`;
2363
+ }
2364
+
2365
+ function buildShellSessionResult(session, {
2366
+ includeOutput = true,
2367
+ onlyNewOutput = false,
2368
+ markReported = false,
2369
+ backgroundHandoff = false,
2370
+ } = {}) {
2371
+ const relevantOutput = onlyNewOutput
2372
+ ? session.output.slice(session.reportedOffset)
2373
+ : session.output;
2374
+
2375
+ if (markReported) {
2376
+ session.reportedOffset = session.output.length;
2377
+ }
2378
+
2379
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - session.startedAt) / 1000));
2380
+ const statusLine = session.completed
2381
+ ? `Shell session ${session.id} completed in ${elapsedSeconds}s with exit code ${session.exitCode ?? 'unknown'}.`
2382
+ : `Shell session ${session.id} is still running in background after ${elapsedSeconds}s.`;
2383
+
2384
+ const lines = [
2385
+ statusLine,
2386
+ `Command: ${session.command}`,
2387
+ `Directory: ${session.cwd}`,
2388
+ ];
2389
+
2390
+ if (session.error) {
2391
+ lines.push(`Error: ${session.error}`);
2392
+ }
2393
+
2394
+ if (!session.completed || backgroundHandoff) {
2395
+ lines.push(shellSessionUsageHint(session.id));
2396
+ }
2397
+
2398
+ if (includeOutput) {
2399
+ lines.push('');
2400
+ lines.push(onlyNewOutput ? 'Output since last check:' : backgroundHandoff ? 'Initial streamed output:' : 'Captured output:');
2401
+ lines.push(formatShellOutputChunk(relevantOutput, onlyNewOutput ? '(no new output since last check)' : '(no output yet)'));
2402
+ }
2403
+
2404
+ return lines.join('\n');
2405
+ }
2406
+
2407
+ function parseShellSessionCommand(command = '') {
2408
+ const trimmed = String(command ?? '').trim();
2409
+ if (!trimmed.startsWith('__shell_')) return null;
2410
+
2411
+ const [directive, ...rest] = trimmed.split(/\s+/);
2412
+ const sessionId = rest.join(' ').trim();
2413
+
2414
+ if (directive === '__shell_list__') return { action: 'list' };
2415
+ if (directive === '__shell_read__') return { action: 'read', sessionId };
2416
+ if (directive === '__shell_stop__') return { action: 'stop', sessionId };
2417
+ return { action: 'unknown', directive };
2418
+ }
2419
+
2420
+ async function handleShellSessionCommand(command = '') {
2421
+ const parsed = parseShellSessionCommand(command);
2422
+ if (!parsed) return null;
2423
+
2424
+ if (parsed.action === 'unknown') {
2425
+ return `Unknown shell session command: ${parsed.directive}. Use __shell_list__, __shell_read__ <session_id>, or __shell_stop__ <session_id>.`;
2426
+ }
2427
+
2428
+ if (parsed.action === 'list') {
2429
+ const sessions = Array.from(shellSessions.values());
2430
+ if (sessions.length === 0) return 'No shell sessions are currently tracked.';
2431
+ return sessions.map(session => {
2432
+ const state = session.completed ? `done (exit ${session.exitCode ?? 'unknown'})` : 'running';
2433
+ return `${session.id} · ${state} · ${session.command}`;
2434
+ }).join('\n');
2435
+ }
2436
+
2437
+ if (!parsed.sessionId) {
2438
+ return 'Missing shell session id. Use __shell_read__ <session_id> or __shell_stop__ <session_id>.';
2439
+ }
2440
+
2441
+ const session = shellSessions.get(parsed.sessionId);
2442
+ if (!session) {
2443
+ return `Shell session not found: ${parsed.sessionId}. Use __shell_list__ to see available sessions.`;
2444
+ }
2445
+
2446
+ if (parsed.action === 'read') {
2447
+ return buildShellSessionResult(session, {
2448
+ includeOutput: true,
2449
+ onlyNewOutput: true,
2450
+ markReported: true,
2451
+ backgroundHandoff: !session.completed,
2452
+ });
2453
+ }
2454
+
2455
+ if (parsed.action === 'stop') {
2456
+ if (session.completed) {
2457
+ return buildShellSessionResult(session, {
2458
+ includeOutput: true,
2459
+ onlyNewOutput: false,
2460
+ markReported: true,
2461
+ });
2462
+ }
2463
+
2464
+ console.log();
2465
+ const confirmation = await safeQuestion(confirmPrompt(`Stop background shell session ${session.id}`, 'error', '[y/N] '));
2466
+ if (!['y', 'yes'].includes(String(confirmation ?? '').trim().toLowerCase())) {
2467
+ return `Stop request cancelled for shell session ${session.id}.`;
2468
+ }
2469
+
2470
+ try {
2471
+ session.proc.kill('SIGTERM');
2472
+ return `Sent SIGTERM to shell session ${session.id}. ${shellSessionUsageHint(session.id)}`;
2473
+ } catch (error) {
2474
+ return `Could not stop shell session ${session.id}: ${error.message}`;
2475
+ }
2476
+ }
2477
+
2478
+ return null;
2479
+ }
2480
+
2481
+ function getTrackedShellSessions() {
2482
+ return Array.from(shellSessions.values()).sort((left, right) => right.startedAt - left.startedAt);
2483
+ }
2484
+
2485
+ function shellSessionStatusLabel(session) {
2486
+ if (!session) return 'unknown';
2487
+ if (!session.completed) return 'running';
2488
+ if (session.signal) return `stopped (${session.signal})`;
2489
+ return `done (${session.exitCode ?? 'unknown'})`;
2490
+ }
2491
+
2492
+ function renderShellSessionsPanel() {
2493
+ const sessions = getTrackedShellSessions();
2494
+ const activeCount = sessions.filter(session => !session.completed).length;
2495
+ const completedCount = sessions.length - activeCount;
2496
+ const lines = [
2497
+ `config ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`chunk ${shellOutputChunkChars()}`)}`,
2498
+ UI.slate(`visibility bg off keeps long shell commands fully attached and visible in the terminal`),
2499
+ `sessions ${chalk.white(`${activeCount} active`)} ${UI.slate('·')} ${chalk.white(`${completedCount} completed`)}`,
2500
+ ];
2501
+
2502
+ if (sessions.length === 0) {
2503
+ lines.push(UI.slate('No background shell sessions are currently tracked.'));
2504
+ } else {
2505
+ for (const session of sessions.slice(0, 8)) {
2506
+ const elapsed = formatElapsed(Date.now() - session.startedAt);
2507
+ const lastOutputLine = String(session.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || '(no output yet)';
2508
+ lines.push(`${chalk.white(session.id)} ${UI.slate('·')} ${chalk.white(shellSessionStatusLabel(session))} ${UI.slate('·')} ${UI.slate(elapsed)}`);
2509
+ lines.push(` ${UI.ink(ellipsis(session.command, 90))}`);
2510
+ lines.push(` ${UI.slate(ellipsis(lastOutputLine, 90))}`);
2511
+ }
2512
+ if (sessions.length > 8) {
2513
+ lines.push(UI.slate(`Showing 8 of ${sessions.length} tracked sessions.`));
2514
+ }
2515
+ }
2516
+
2517
+ return box(lines.join('\n'), 'Shell Sessions', 'cyan');
1802
2518
  }
1803
2519
 
1804
2520
  // Configure marked with terminal renderer
@@ -1901,6 +2617,181 @@ async function safeQuestion(query) {
1901
2617
  });
1902
2618
  }
1903
2619
 
2620
+ function countLines(text = '') {
2621
+ if (!text) return 0;
2622
+ return String(text).split('\n').length;
2623
+ }
2624
+
2625
+ function formatPreviewLine(line = '', maxWidth = Math.max(32, terminalWidth(82) - 12)) {
2626
+ return ellipsis(String(line).replace(/\t/g, ' '), maxWidth);
2627
+ }
2628
+
2629
+ function buildPreviewBlock(lines, startIdx, endIdx, changeStart, changeEnd, marker, colorFn, maxLines = 14) {
2630
+ if (lines.length === 0) {
2631
+ return colorFn(`${marker} | (empty)`);
2632
+ }
2633
+
2634
+ const indexes = [];
2635
+ for (let index = startIdx; index <= endIdx; index++) {
2636
+ indexes.push(index);
2637
+ }
2638
+
2639
+ const clipped = indexes.length > maxLines;
2640
+ const visibleIndexes = clipped
2641
+ ? [
2642
+ ...indexes.slice(0, Math.ceil(maxLines / 2)),
2643
+ -1,
2644
+ ...indexes.slice(-(Math.floor(maxLines / 2)))
2645
+ ]
2646
+ : indexes;
2647
+ const numberWidth = String(Math.max(endIdx + 1, 1)).length;
2648
+ const rows = [];
2649
+
2650
+ if (startIdx > 0) {
2651
+ rows.push(UI.slate(' ...'));
2652
+ }
2653
+
2654
+ for (const index of visibleIndexes) {
2655
+ if (index === -1) {
2656
+ rows.push(UI.slate(' ...'));
2657
+ continue;
2658
+ }
2659
+
2660
+ const prefix = index >= changeStart && index <= changeEnd ? marker : ' ';
2661
+ const row = `${prefix} ${String(index + 1).padStart(numberWidth)} | ${formatPreviewLine(lines[index])}`;
2662
+ rows.push(prefix === marker ? colorFn(row) : UI.slate(row));
2663
+ }
2664
+
2665
+ if (clipped || endIdx < lines.length - 1) {
2666
+ rows.push(UI.slate(' ...'));
2667
+ }
2668
+
2669
+ return rows.join('\n');
2670
+ }
2671
+
2672
+ function buildFileChangePreview(oldContent = '', newContent = '') {
2673
+ const before = String(oldContent ?? '');
2674
+ const after = String(newContent ?? '');
2675
+
2676
+ if (before === after) {
2677
+ return UI.slate('No visible text changes.');
2678
+ }
2679
+
2680
+ const oldLines = before ? before.split('\n') : [];
2681
+ const newLines = after ? after.split('\n') : [];
2682
+
2683
+ if (oldLines.length === 0) {
2684
+ return [
2685
+ chalk.green('New file content'),
2686
+ buildPreviewBlock(newLines, 0, Math.max(0, Math.min(newLines.length - 1, 13)), 0, Math.max(0, Math.min(newLines.length - 1, 13)), '+', chalk.green)
2687
+ ].join('\n');
2688
+ }
2689
+
2690
+ let start = 0;
2691
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {
2692
+ start++;
2693
+ }
2694
+
2695
+ let oldEnd = oldLines.length - 1;
2696
+ let newEnd = newLines.length - 1;
2697
+ while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
2698
+ oldEnd--;
2699
+ newEnd--;
2700
+ }
2701
+
2702
+ const contextLines = 3;
2703
+ const oldStart = Math.max(0, start - contextLines);
2704
+ const newStart = Math.max(0, start - contextLines);
2705
+ const oldPreviewEnd = Math.min(oldLines.length - 1, Math.max(oldEnd, start - 1) + contextLines);
2706
+ const newPreviewEnd = Math.min(newLines.length - 1, Math.max(newEnd, start - 1) + contextLines);
2707
+
2708
+ return [
2709
+ chalk.red('Before'),
2710
+ buildPreviewBlock(oldLines, oldStart, oldPreviewEnd, start, oldEnd, '-', chalk.red),
2711
+ '',
2712
+ chalk.green('After'),
2713
+ buildPreviewBlock(newLines, newStart, newPreviewEnd, start, newEnd, '+', chalk.green),
2714
+ ].join('\n');
2715
+ }
2716
+
2717
+ function ensureParentDirectory(filePath) {
2718
+ const parentDir = dirname(filePath);
2719
+ if (parentDir && parentDir !== '.' && !fs.existsSync(parentDir)) {
2720
+ fs.mkdirSync(parentDir, { recursive: true });
2721
+ }
2722
+ }
2723
+
2724
+ function restoreFileSnapshot(filePath, originalContent, existedBefore) {
2725
+ if (existedBefore) {
2726
+ fs.writeFileSync(filePath, originalContent);
2727
+ } else if (fs.existsSync(filePath)) {
2728
+ fs.unlinkSync(filePath);
2729
+ }
2730
+ }
2731
+
2732
+ async function reviewCandidateFile({ filePath, originalContent = '', newContent = '', title = 'File Review', successMessage }) {
2733
+ const existedBefore = fs.existsSync(filePath);
2734
+
2735
+ ensureParentDirectory(filePath);
2736
+ fs.writeFileSync(filePath, newContent);
2737
+
2738
+ while (true) {
2739
+ console.log();
2740
+ console.log(box(
2741
+ `${keyValue('File', chalk.white(filePath), 8)}\n` +
2742
+ `${keyValue('Status', chalk.white(existedBefore ? 'modified' : 'new file'), 8)}\n` +
2743
+ `${keyValue('Lines', chalk.white(`${countLines(originalContent)} -> ${countLines(newContent)}`), 8)}\n` +
2744
+ `${UI.slate('Candidate change written to disk. Review it in your editor now.')}\n` +
2745
+ `${UI.slate('Choose keep to accept it, ignore to revert it, diff to inspect, f for feedback, or e for edit instructions.')}`,
2746
+ title, 'yellow'
2747
+ ));
2748
+
2749
+ const decisionInput = await safeQuestion(chalk.yellow('Review change ') + chalk.gray('[k]eep/[i]gnore/[d]iff/[f]eedback/[e]dit: '));
2750
+ const decisionRaw = String(decisionInput ?? '').trim();
2751
+ const decision = decisionRaw.toLowerCase();
2752
+
2753
+ if (['k', 'keep', 'y', 'yes'].includes(decision)) {
2754
+ return successMessage || `Successfully saved changes to ${filePath}`;
2755
+ }
2756
+
2757
+ if (['i', 'ignore', 'n', 'no'].includes(decision)) {
2758
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
2759
+ return existedBefore
2760
+ ? `Ignored change and restored ${filePath}`
2761
+ : `Ignored change and removed ${filePath}`;
2762
+ }
2763
+
2764
+ if (decision === '' || decision === 'd' || decision === 'diff') {
2765
+ console.log();
2766
+ console.log(box(buildFileChangePreview(originalContent, newContent), 'Change Diff', 'yellow'));
2767
+ continue;
2768
+ }
2769
+
2770
+ const approvalInstruction = await resolveApprovalInstruction(decisionRaw, {
2771
+ feedbackPrompt: 'Feedback for this change: ',
2772
+ editPrompt: 'Edit instruction for this change: ',
2773
+ });
2774
+
2775
+ if (approvalInstruction) {
2776
+ if (!approvalInstruction.detail) {
2777
+ console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose keep/ignore/diff.'));
2778
+ continue;
2779
+ }
2780
+
2781
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
2782
+ const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
2783
+ return `Change rejected by user for ${filePath}.\n${label}: ${approvalInstruction.detail}\nThe original file was restored. Revise the change and try again.`;
2784
+ }
2785
+
2786
+ if (decisionRaw) {
2787
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
2788
+ return `Change rejected by user for ${filePath}.\nUser feedback: ${decisionRaw}\nThe original file was restored. Revise the change and try again.`;
2789
+ }
2790
+
2791
+ console.log(UI.slate('Type k to keep, i to ignore, d to view the diff, f for feedback, e for edit instructions, or write feedback directly.'));
2792
+ }
2793
+ }
2794
+
1904
2795
  // Directories to ignore when listing files
1905
2796
  const IGNORE_DIRS = new Set([
1906
2797
  'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
@@ -2423,11 +3314,17 @@ async function pickModel(models) {
2423
3314
 
2424
3315
  const tools = {
2425
3316
  read: (path) => {
2426
- try { return fs.readFileSync(path.trim(), 'utf8'); }
3317
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
3318
+ if (!trimmedPath) return 'Error reading file: missing file path';
3319
+ try { return fs.readFileSync(trimmedPath, 'utf8'); }
2427
3320
  catch (error) { return `Error reading file: ${error.message}`; }
2428
3321
  },
2429
3322
  patch: async (path, oldText, newText) => {
2430
- const trimmedPath = path.trim();
3323
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
3324
+ if (!trimmedPath) return 'Error patching file: missing file path';
3325
+ if (typeof oldText !== 'string' || typeof newText !== 'string') {
3326
+ return 'Error patching file: missing old_text or new_text';
3327
+ }
2431
3328
  try {
2432
3329
  const content = fs.readFileSync(trimmedPath, 'utf8');
2433
3330
 
@@ -2439,23 +3336,19 @@ const tools = {
2439
3336
  if (lineNum < 1 || lineNum > lines.length) {
2440
3337
  return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
2441
3338
  }
2442
- const oldLine = lines[lineNum - 1];
2443
3339
  lines[lineNum - 1] = newText;
2444
3340
  const newContent = lines.join('\n');
2445
- console.log();
2446
- const diffContent =
2447
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2448
- `${keyValue('Line', chalk.white(String(lineNum)), 8)}\n` +
2449
- `${UI.slate('Preview')}\n` +
2450
- chalk.red('- ' + oldLine) + '\n' +
2451
- chalk.green('+ ' + newText);
2452
- console.log(box(diffContent, 'Patch Review', 'yellow'));
2453
- const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
2454
- if (confirm.toLowerCase() === 'y') {
2455
- fs.writeFileSync(trimmedPath, newContent);
2456
- return `Successfully patched line ${lineNum} of ${trimmedPath}`;
3341
+ if (newContent === content) {
3342
+ return `No changes needed in ${trimmedPath}`;
2457
3343
  }
2458
- return 'Patch rejected by user.';
3344
+
3345
+ return reviewCandidateFile({
3346
+ filePath: trimmedPath,
3347
+ originalContent: content,
3348
+ newContent,
3349
+ title: 'Patch Review',
3350
+ successMessage: `Successfully patched line ${lineNum} of ${trimmedPath}`,
3351
+ });
2459
3352
  }
2460
3353
 
2461
3354
  // --- Exact match (try as-is first, then trimmed) ---
@@ -2513,99 +3406,179 @@ const tools = {
2513
3406
  `Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
2514
3407
  }
2515
3408
  }
2516
-
2517
- // Show diff preview
2518
- console.log();
2519
- const diffContent =
2520
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2521
- `${UI.slate('Preview')}\n` +
2522
- chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
2523
- chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
2524
- console.log(box(diffContent, 'Patch Review', 'yellow'));
2525
-
2526
- const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
2527
- if (confirm.toLowerCase() === 'y') {
2528
- fs.writeFileSync(trimmedPath, newContent);
2529
- return `Successfully patched ${trimmedPath}`;
3409
+
3410
+ if (newContent === content) {
3411
+ return `No changes needed in ${trimmedPath}`;
2530
3412
  }
2531
- return 'Patch rejected by user.';
3413
+
3414
+ return reviewCandidateFile({
3415
+ filePath: trimmedPath,
3416
+ originalContent: content,
3417
+ newContent,
3418
+ title: 'Patch Review',
3419
+ successMessage: `Successfully patched ${trimmedPath}`,
3420
+ });
2532
3421
  } catch (error) { return `Error patching file: ${error.message}`; }
2533
3422
  },
2534
3423
  write: async (path, content) => {
2535
- const trimmedPath = path.trim();
2536
- console.log();
2537
- console.log(box(
2538
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2539
- `${keyValue('Size', chalk.white((content?.length || 0) + ' chars'), 8)}\n` +
2540
- `${UI.slate('Preview')}\n` +
2541
- chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
2542
- 'Write Review', 'yellow'
2543
- ));
2544
- const confirm = await safeQuestion(confirmPrompt('Allow file write', 'warning'));
2545
- if (confirm.toLowerCase() === 'y') {
2546
- try {
2547
- fs.writeFileSync(trimmedPath, content);
2548
- return `Successfully saved changes to ${trimmedPath}`;
2549
- } catch (error) { return `Error writing file: ${error.message}`; }
2550
- }
2551
- return "Write blocked by user.";
3424
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
3425
+ if (!trimmedPath) return 'Error writing file: missing file path';
3426
+ try {
3427
+ const fileExists = fs.existsSync(trimmedPath);
3428
+ const existingContent = fileExists ? fs.readFileSync(trimmedPath, 'utf8') : '';
3429
+ const nextContent = String(content ?? '');
3430
+
3431
+ if (fileExists && existingContent === nextContent) {
3432
+ return `No changes needed in ${trimmedPath}`;
3433
+ }
3434
+
3435
+ return reviewCandidateFile({
3436
+ filePath: trimmedPath,
3437
+ originalContent: existingContent,
3438
+ newContent: nextContent,
3439
+ title: 'Write Review',
3440
+ successMessage: `Successfully saved changes to ${trimmedPath}`,
3441
+ });
3442
+ } catch (error) { return `Error writing file: ${error.message}`; }
2552
3443
  },
2553
3444
  mkdir: (path) => {
3445
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
3446
+ if (!trimmedPath) return 'Error creating directory: missing directory path';
2554
3447
  try {
2555
- fs.mkdirSync(path.trim(), { recursive: true });
2556
- return `Directory created: ${path}`;
3448
+ fs.mkdirSync(trimmedPath, { recursive: true });
3449
+ return `Directory created: ${trimmedPath}`;
2557
3450
  } catch (error) { return `Error creating directory: ${error.message}`; }
2558
3451
  },
2559
3452
  shell: async (cmd) => {
3453
+ const trimmedCmd = String(cmd ?? '').trim();
3454
+ if (!trimmedCmd) return 'Error executing shell: missing command';
3455
+
3456
+ const sessionCommandResult = await handleShellSessionCommand(trimmedCmd);
3457
+ if (sessionCommandResult !== null) {
3458
+ return sessionCommandResult;
3459
+ }
3460
+
3461
+ const backgroundEligible = shouldBackgroundShellCommand(trimmedCmd);
2560
3462
  console.log();
2561
3463
  console.log(box(
2562
3464
  `${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
2563
- `${UI.slate('Command')}\n${chalk.white.bold(cmd)}`,
3465
+ `${UI.slate('Command')}\n${chalk.white.bold(trimmedCmd)}\n` +
3466
+ `${UI.slate('Type y to run, n to block, f for feedback, e for edit instructions, or write feedback directly.')}\n` +
3467
+ `${UI.slate(backgroundEligible ? `Background handoff ${shellBackgroundMode()} after ${shellBackgroundAfterSeconds()}s if still running.` : 'This command will stay attached unless it exits quickly.')}`,
2564
3468
  'Shell Approval', 'red'
2565
3469
  ));
2566
- const confirm = await safeQuestion(confirmPrompt('Run shell command', 'error'));
2567
- if (confirm.toLowerCase() === 'y') {
2568
- return new Promise((resolve) => {
2569
- const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
2570
- console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
2571
- const proc = spawn('sh', ['-c', cmd], {
2572
- cwd: process.cwd()
2573
- });
2574
- let output = '';
2575
- proc.stdout.on('data', (data) => {
2576
- const text = data.toString();
2577
- output += text;
2578
- process.stdout.write(text); // Still show to user in real-time
2579
- });
2580
- proc.stderr.on('data', (data) => {
2581
- const text = data.toString();
2582
- output += text;
2583
- process.stderr.write(text); // Still show errors to user
2584
- });
2585
- proc.on('close', (code) => {
2586
- // Crucial: give control back to Node
2587
- if (process.stdin.isTTY) {
2588
- try { process.stdin.setRawMode(false); } catch (e) {}
3470
+ while (true) {
3471
+ const confirmInput = await safeQuestion(confirmPrompt('Run shell command', 'error', '[y/N/f/e or text] '));
3472
+ const confirmRaw = String(confirmInput ?? '').trim();
3473
+ const confirm = confirmRaw.toLowerCase();
3474
+
3475
+ if (['y', 'yes'].includes(confirm)) {
3476
+ return new Promise((resolve) => {
3477
+ console.log(chalk.cyan(`\n[RUNNING] ${trimmedCmd}\n`));
3478
+ const proc = spawn('sh', ['-c', trimmedCmd], {
3479
+ cwd: process.cwd()
3480
+ });
3481
+ const session = createShellSession(trimmedCmd, process.cwd(), proc);
3482
+ let resolved = false;
3483
+ let backgroundTimer = null;
3484
+
3485
+ const finish = (result) => {
3486
+ if (resolved) return;
3487
+ resolved = true;
3488
+ if (backgroundTimer) {
3489
+ clearTimeout(backgroundTimer);
3490
+ backgroundTimer = null;
3491
+ }
3492
+ resolve(result);
3493
+ };
3494
+
3495
+ if (backgroundEligible) {
3496
+ backgroundTimer = setTimeout(() => {
3497
+ if (resolved || session.completed) return;
3498
+ session.backgrounded = true;
3499
+ session.liveEchoEnabled = false;
3500
+ showStreamPhase(`Shell command still running. Background session ${session.id} is active...`, 'warning');
3501
+ finish(buildShellSessionResult(session, {
3502
+ includeOutput: shellStreamToModelEnabled(),
3503
+ onlyNewOutput: false,
3504
+ markReported: shellStreamToModelEnabled(),
3505
+ backgroundHandoff: true,
3506
+ }));
3507
+ }, shellBackgroundAfterSeconds() * 1000);
2589
3508
  }
2590
- // Delay slightly to let terminal settle
2591
- setTimeout(() => {
2592
- recreateReadline();
2593
- // Return actual output to AI, truncated if too long
2594
- const maxOutput = 10000;
2595
- let result = output.trim();
2596
- if (result.length > maxOutput) {
2597
- result = result.substring(0, maxOutput) + '\n... (output truncated)';
3509
+
3510
+ proc.stdout.on('data', (data) => {
3511
+ const text = data.toString();
3512
+ appendShellSessionOutput(session, text);
3513
+ if (session.liveEchoEnabled) {
3514
+ process.stdout.write(text);
3515
+ }
3516
+ });
3517
+ proc.stderr.on('data', (data) => {
3518
+ const text = data.toString();
3519
+ appendShellSessionOutput(session, text);
3520
+ if (session.liveEchoEnabled) {
3521
+ process.stderr.write(text);
3522
+ }
3523
+ });
3524
+ proc.on('error', (error) => {
3525
+ session.completed = true;
3526
+ session.error = error.message;
3527
+ session.exitCode = 1;
3528
+ finish(`Shell command failed to start: ${error.message}`);
3529
+ });
3530
+ proc.on('close', (code, signal) => {
3531
+ session.completed = true;
3532
+ session.exitCode = code;
3533
+ session.signal = signal;
3534
+
3535
+ if (resolved) {
3536
+ return;
2598
3537
  }
2599
- resolve(result || `Command completed with exit code ${code}`);
2600
- }, 200);
3538
+
3539
+ if (process.stdin.isTTY) {
3540
+ try { process.stdin.setRawMode(false); } catch (e) {}
3541
+ }
3542
+
3543
+ setTimeout(() => {
3544
+ recreateReadline();
3545
+ const maxOutput = 10000;
3546
+ let result = session.output.trim();
3547
+ if (result.length > maxOutput) {
3548
+ result = result.substring(0, maxOutput) + '\n... (output truncated)';
3549
+ }
3550
+ finish(result || `Command completed with exit code ${code}`);
3551
+ }, 200);
3552
+ });
2601
3553
  });
3554
+ }
3555
+
3556
+ if (['', 'n', 'no'].includes(confirm)) {
3557
+ return "Command blocked by user.";
3558
+ }
3559
+
3560
+ const approvalInstruction = await resolveApprovalInstruction(confirmRaw, {
3561
+ feedbackPrompt: 'Feedback for this command: ',
3562
+ editPrompt: 'Edit instruction for this command: ',
2602
3563
  });
3564
+
3565
+ if (approvalInstruction) {
3566
+ if (!approvalInstruction.detail) {
3567
+ console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose y/n.'));
3568
+ continue;
3569
+ }
3570
+
3571
+ const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
3572
+ return `Command blocked by user.\n${label}: ${approvalInstruction.detail}\nNo command was executed. Revise the command and ask again if needed.`;
3573
+ }
3574
+
3575
+ return `Command blocked by user.\nUser feedback: ${confirmRaw}\nNo command was executed. Revise the command and ask again if needed.`;
2603
3576
  }
2604
- return "Command blocked by user.";
2605
3577
  },
2606
3578
  list: (path) => {
2607
3579
  try {
2608
- let dir = path.trim() || '.';
3580
+ let dir = typeof path === 'string' ? path.trim() : '';
3581
+ if (!dir) dir = '.';
2609
3582
  // If AI sends "/" (root), treat as current directory "."
2610
3583
  if (dir === '/') dir = '.';
2611
3584
  const entries = fs.readdirSync(dir);
@@ -2721,6 +3694,12 @@ async function runSapper() {
2721
3694
  const startupLines = [
2722
3695
  `${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
2723
3696
  `${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
3697
+ `${statusBadge('prompt', hasCustomPromptConfig() ? 'warning' : 'neutral')} ${UI.slate(hasCustomPromptConfig() ? 'custom prompt on' : 'default prompt')}`,
3698
+ `${statusBadge('thinking', 'neutral')} ${UI.slate(`mode ${thinkingMode()}`)}`,
3699
+ `${statusBadge('tools', 'action')} ${UI.slate(`limit ${toolRoundLimit()} rounds`)}`,
3700
+ `${statusBadge('shell', 'info')} ${UI.slate(`stream ${shellStreamToModelEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${UI.slate(`${activeShellSessionCount()} active`)}`,
3701
+ `${statusBadge('stream', 'neutral')} ${UI.slate(`heartbeat ${streamHeartbeatEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`phases ${streamPhaseStatusEnabled() ? 'on' : 'off'}`)}`,
3702
+ `${statusBadge('summary', 'info')} ${UI.slate(`phases ${summaryPhasesEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`trigger ${summaryTriggerPercent()}%`)}`,
2724
3703
  `${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
2725
3704
  ];
2726
3705
  if (newlyCreated > 0) {
@@ -2834,13 +3813,12 @@ async function runSapper() {
2834
3813
  type: 'function',
2835
3814
  function: {
2836
3815
  name: 'list_directory',
2837
- description: 'List the contents of a directory. Use "." for current directory.',
3816
+ description: 'List the contents of a directory. If path is omitted, use the current directory ".".',
2838
3817
  parameters: {
2839
3818
  type: 'object',
2840
3819
  properties: {
2841
3820
  path: { type: 'string', description: 'Directory path to list' }
2842
- },
2843
- required: ['path']
3821
+ }
2844
3822
  }
2845
3823
  }
2846
3824
  },
@@ -2921,7 +3899,7 @@ async function runSapper() {
2921
3899
  type: 'function',
2922
3900
  function: {
2923
3901
  name: 'run_shell',
2924
- description: 'Execute a shell command in the project directory',
3902
+ description: 'Execute a shell command in the project directory. Special commands: __shell_list__, __shell_read__ <session_id>, __shell_stop__ <session_id>.',
2925
3903
  parameters: {
2926
3904
  type: 'object',
2927
3905
  properties: {
@@ -2950,12 +3928,24 @@ async function runSapper() {
2950
3928
  // Main conversation loop - never exits unless user types 'exit'
2951
3929
  while (true) {
2952
3930
  try {
3931
+ const previousConfig = JSON.stringify(sapperConfig);
3932
+ const reloadedConfig = loadConfig();
3933
+ if (JSON.stringify(reloadedConfig) !== previousConfig) {
3934
+ sapperConfig = reloadedConfig;
3935
+ if (messages.length > 0 && messages[0]?.role === 'system') {
3936
+ refreshSystemPrompt(messages);
3937
+ }
3938
+ console.log(chalk.gray(`↻ Reloaded ${CONFIG_FILE}`));
3939
+ console.log(chalk.gray(' System prompt and runtime settings refreshed from config.'));
3940
+ }
3941
+
2953
3942
  // Context size check - auto-summarize when approaching effective context limit
2954
- const estimatedTokens = estimateMessagesTokens(messages);
3943
+ let estimatedTokens = estimateMessagesTokens(messages);
2955
3944
  const ctxLen = effectiveContextLength();
2956
- const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
3945
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
2957
3946
  if (estimatedTokens > tokenThreshold) {
2958
3947
  messages = await autoSummarizeContext(messages, selectedModel);
3948
+ estimatedTokens = estimateMessagesTokens(messages);
2959
3949
  }
2960
3950
 
2961
3951
  // Build prompt label with active agent/skills
@@ -2976,25 +3966,17 @@ async function runSapper() {
2976
3966
  ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
2977
3967
  : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
2978
3968
 
2979
- const input = await safeQuestion(`\n${promptShell(promptParts.join(' '), promptDetail)}`);
3969
+ const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
3970
+ const input = await safeQuestion(promptText);
3971
+ clearPromptEcho(promptText, input);
2980
3972
 
2981
3973
  // Block empty prompts
2982
3974
  if (!input.trim()) {
2983
3975
  continue;
2984
3976
  }
2985
3977
 
2986
- // Clear readline echo to prevent duplicate display
2987
- {
2988
- const promptWidth = visibleLength(promptParts.join(' ')) + 4; // account for prompt chars
2989
- const totalLen = promptWidth + input.length;
2990
- const lines = Math.ceil(totalLen / (process.stdout.columns || 80));
2991
- for (let i = 0; i < lines; i++) {
2992
- process.stdout.write('\x1B[1A\x1B[2K');
2993
- }
2994
- // Reprint clean version
2995
- const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
2996
- console.log(UI.accent('› ') + chalk.white(preview));
2997
- }
3978
+ const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
3979
+ console.log(UI.accent('› ') + chalk.white(preview));
2998
3980
 
2999
3981
  if (input.toLowerCase() === 'exit') {
3000
3982
  const stats = getSessionStats();
@@ -3056,7 +4038,11 @@ async function runSapper() {
3056
4038
  console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
3057
4039
  console.log(commandRow('/reset /clear', 'Clear all current context'));
3058
4040
  console.log(commandRow('/prune', 'Summarize long context and store memory'));
3059
- console.log(commandRow('/context', 'Inspect token usage and model window'));
4041
+ console.log(commandRow('/summary', 'Show or change auto-summary settings'));
4042
+ console.log(commandRow('/shell', 'Inspect shell config and background sessions'));
4043
+ console.log(commandRow('/shell read <id>', 'Read output from a tracked shell session'));
4044
+ console.log(commandRow('/shell stop <id>', 'Stop a tracked shell session'));
4045
+ console.log(commandRow('/context', 'Inspect token usage, summary trigger, and model window'));
3060
4046
  console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
3061
4047
  console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
3062
4048
  console.log(commandRow('/log', 'Show the session activity timeline'));
@@ -3064,6 +4050,13 @@ async function runSapper() {
3064
4050
  console.log(commandRow('/log file', 'Show log file path and history'));
3065
4051
  console.log(commandRow('/help', 'Open this command view again'));
3066
4052
  console.log(commandRow('exit', 'Quit Sapper'));
4053
+ console.log(UI.slate(' Summary settings: /summary | /summary phases off | /summary trigger 60'));
4054
+ console.log(UI.slate(' Tool config: .sapper/config.json -> toolRoundLimit (default 40)'));
4055
+ console.log(UI.slate(' Shell config: .sapper/config.json -> shell.streamToModel, shell.backgroundMode [off|auto|on], shell.backgroundAfterSeconds, shell.outputChunkChars'));
4056
+ console.log(UI.slate(' Want to see all live shell output? Set shell.backgroundMode to off. thinking.mode only controls model reasoning.'));
4057
+ console.log(UI.slate(' Streaming config: .sapper/config.json -> streaming.showPhaseStatus, streaming.showHeartbeat, streaming.idleNoticeSeconds'));
4058
+ console.log(UI.slate(' Thinking config: .sapper/config.json -> thinking.mode [auto|on|off]'));
4059
+ console.log(UI.slate(' Prompt config: .sapper/config.json -> prompt.prepend, prompt.append, prompt.coreOverride'));
3067
4060
  console.log();
3068
4061
  console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
3069
4062
  console.log(commandRow('/agents', 'List available agents'));
@@ -3288,20 +4281,112 @@ async function runSapper() {
3288
4281
  continue;
3289
4282
  }
3290
4283
 
4284
+ if (input.toLowerCase().startsWith('/summary')) {
4285
+ const arg = input.substring(8).trim();
4286
+
4287
+ if (!arg) {
4288
+ const effective = effectiveContextLength();
4289
+ const threshold = summaryTokenThreshold(effective);
4290
+ const lines = [
4291
+ `phases ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`,
4292
+ `trigger ${chalk.white.bold(summaryTriggerPercent() + '%')} ${UI.slate(`(~${threshold.toLocaleString()} tokens)`)}`,
4293
+ `config file ${chalk.white(CONFIG_FILE)}`,
4294
+ ];
4295
+ console.log();
4296
+ console.log(box(lines.join('\n'), 'Summary Settings', 'cyan'));
4297
+ console.log(UI.slate(' Usage: /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
4298
+ continue;
4299
+ }
4300
+
4301
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
4302
+ const subcommand = subcommandRaw.toLowerCase();
4303
+ const value = rest.join(' ').trim();
4304
+
4305
+ if (subcommand === 'reset' || subcommand === 'default') {
4306
+ sapperConfig.summaryPhases = DEFAULT_CONFIG.summaryPhases;
4307
+ sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
4308
+ saveConfig(sapperConfig);
4309
+ console.log(chalk.green(`✅ Summary settings reset: phases ${summaryPhasesEnabled() ? 'ON' : 'OFF'}, trigger ${summaryTriggerPercent()}%`));
4310
+ continue;
4311
+ }
4312
+
4313
+ if (subcommand === 'phases' || subcommand === 'phase') {
4314
+ let nextValue = null;
4315
+
4316
+ if (!value) {
4317
+ nextValue = !summaryPhasesEnabled();
4318
+ } else {
4319
+ const normalized = value.toLowerCase();
4320
+ if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(normalized)) {
4321
+ nextValue = true;
4322
+ } else if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(normalized)) {
4323
+ nextValue = false;
4324
+ } else if (['toggle', 'flip'].includes(normalized)) {
4325
+ nextValue = !summaryPhasesEnabled();
4326
+ }
4327
+ }
4328
+
4329
+ if (nextValue === null) {
4330
+ console.log(chalk.yellow('Usage: /summary phases [on|off]'));
4331
+ continue;
4332
+ }
4333
+
4334
+ sapperConfig.summaryPhases = nextValue;
4335
+ saveConfig(sapperConfig);
4336
+ console.log(chalk.green(`✅ Summary phases: ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
4337
+ continue;
4338
+ }
4339
+
4340
+ if (subcommand === 'trigger' || subcommand === 'percent' || subcommand === 'threshold') {
4341
+ if (!value) {
4342
+ console.log(chalk.yellow('Usage: /summary trigger <percent>'));
4343
+ console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
4344
+ continue;
4345
+ }
4346
+
4347
+ const parsedTrigger = parseSummaryTriggerInput(value);
4348
+ if (parsedTrigger === null) {
4349
+ console.log(chalk.yellow(`Invalid summary trigger: ${value}`));
4350
+ console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
4351
+ continue;
4352
+ }
4353
+
4354
+ sapperConfig.summarizeTriggerPercent = parsedTrigger;
4355
+ saveConfig(sapperConfig);
4356
+ const effective = effectiveContextLength();
4357
+ const threshold = summaryTokenThreshold(effective);
4358
+ console.log(chalk.green(`✅ Summary trigger set to ${chalk.white.bold(summaryTriggerPercent() + '%')}`));
4359
+ console.log(chalk.gray(` Auto-summary will start near ${threshold.toLocaleString()} tokens.`));
4360
+ continue;
4361
+ }
4362
+
4363
+ console.log(chalk.yellow(`Unknown summary option: ${subcommand}`));
4364
+ console.log(chalk.gray(' Usage: /summary | /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
4365
+ continue;
4366
+ }
4367
+
3291
4368
  if (input.toLowerCase() === '/context') {
3292
4369
  const contextSize = JSON.stringify(messages).length;
3293
4370
  const estTokens = estimateMessagesTokens(messages);
3294
4371
  const ctxLen = effectiveContextLength();
4372
+ const triggerPercent = summaryTriggerPercent();
4373
+ const promptConfig = getPromptConfig();
3295
4374
  const contextLines = [
3296
4375
  `messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
3297
4376
  ];
4377
+ contextLines.push(`prompt ${chalk.white(hasCustomPromptConfig() ? 'customized' : 'default')} ${UI.slate('·')} ${chalk.white(`prepend ${promptConfig.prepend.trim() ? 'yes' : 'no'}`)} ${UI.slate('·')} ${chalk.white(`append ${promptConfig.append.trim() ? 'yes' : 'no'}`)}`);
4378
+ contextLines.push(`thinking ${chalk.white(thinkingMode())} ${UI.slate('·')} ${UI.slate(thinkingMode() === 'auto' ? 'simple prompts skip reasoning' : thinkingMode() === 'off' ? 'reasoning hidden for all prompts' : 'reasoning enabled for all prompts')}`);
4379
+ contextLines.push(`tools ${chalk.white(`limit ${toolRoundLimit()} rounds`)} ${UI.slate('·')} ${UI.slate('per prompt turn')}`);
4380
+ contextLines.push(`shell ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`${activeShellSessionCount()} active`)}`);
4381
+ contextLines.push(`stream ${chalk.white(streamHeartbeatEnabled() ? 'heartbeat on' : 'heartbeat off')} ${UI.slate('·')} ${chalk.white(streamPhaseStatusEnabled() ? 'phase status on' : 'phase status off')} ${UI.slate('·')} ${chalk.white(`idle ${streamIdleNoticeSeconds()}s`)}`);
3298
4382
  if (ctxLen) {
3299
4383
  const usagePercent = Math.round((estTokens / ctxLen) * 100);
3300
- const threshold = Math.floor(ctxLen * 0.75);
4384
+ const threshold = summaryTokenThreshold(ctxLen);
3301
4385
  const limitLabel = sapperConfig.contextLimit
3302
4386
  ? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
3303
4387
  : `${ctxLen.toLocaleString()} tokens`;
3304
4388
  contextLines.push(`limit ${chalk.white(limitLabel)} ${UI.slate('·')} usage ${chalk.white(usagePercent + '%')}`);
4389
+ contextLines.push(`summary ${chalk.white(`trigger ${triggerPercent}%`)} ${UI.slate('·')} ${chalk.white(summaryPhasesEnabled() ? 'phases on' : 'phases off')}`);
3305
4390
  contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
3306
4391
  }
3307
4392
  if (lastPromptTokens > 0) {
@@ -3311,6 +4396,38 @@ async function runSapper() {
3311
4396
  console.log(box(contextLines.join('\n'), 'Context', 'gray'));
3312
4397
  continue;
3313
4398
  }
4399
+
4400
+ if (input.toLowerCase().startsWith('/shell')) {
4401
+ const arg = input.substring(6).trim();
4402
+
4403
+ if (!arg || ['sessions', 'session', 'list', 'ls', 'status'].includes(arg.toLowerCase())) {
4404
+ console.log();
4405
+ console.log(renderShellSessionsPanel());
4406
+ console.log(UI.slate(' Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
4407
+ continue;
4408
+ }
4409
+
4410
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
4411
+ const subcommand = subcommandRaw.toLowerCase();
4412
+ const sessionId = rest.join(' ').trim();
4413
+
4414
+ if (['read', 'show', 'tail'].includes(subcommand)) {
4415
+ const result = await handleShellSessionCommand(`__shell_read__ ${sessionId}`);
4416
+ console.log();
4417
+ console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Read', 'cyan'));
4418
+ continue;
4419
+ }
4420
+
4421
+ if (['stop', 'kill', 'end'].includes(subcommand)) {
4422
+ const result = await handleShellSessionCommand(`__shell_stop__ ${sessionId}`);
4423
+ console.log();
4424
+ console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Stop', 'red'));
4425
+ continue;
4426
+ }
4427
+
4428
+ console.log(chalk.yellow('Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
4429
+ continue;
4430
+ }
3314
4431
 
3315
4432
  // Handle debug mode toggle
3316
4433
  if (input.toLowerCase() === '/debug') {
@@ -3981,9 +5098,10 @@ async function runSapper() {
3981
5098
  } // End of if (!agentHandled)
3982
5099
 
3983
5100
  let toolRounds = 0; // Prevent infinite loops
3984
- const MAX_TOOL_ROUNDS = 20;
5101
+ const MAX_TOOL_ROUNDS = toolRoundLimit();
3985
5102
  const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
3986
5103
  const MAX_PATCH_RETRIES = 3;
5104
+ const turnThinkingEnabled = shouldUseThinkingForInput(input);
3987
5105
 
3988
5106
  let active = true;
3989
5107
  while (active) {
@@ -3998,8 +5116,8 @@ async function runSapper() {
3998
5116
  if (effectiveContextLength()) {
3999
5117
  chatOpts.options = { num_ctx: effectiveContextLength() };
4000
5118
  }
4001
- // Enable thinking for reasoning models (deepseek-r1, qwq, etc.)
4002
- chatOpts.think = true;
5119
+ // Thinking can be forced on, forced off, or auto-disabled for simple prompts.
5120
+ chatOpts.think = turnThinkingEnabled;
4003
5121
  if (useNativeTools) {
4004
5122
  // Filter tool defs by agent restrictions if any
4005
5123
  if (currentAgentTools) {
@@ -4037,10 +5155,37 @@ async function runSapper() {
4037
5155
  let chunkPromptTokens = 0; // Track actual tokens from Ollama
4038
5156
  let chunkEvalTokens = 0;
4039
5157
  let isThinking = false; // Track if we're currently in thinking mode
5158
+ let thinkingContinuationNeedsPrefix = false;
5159
+ let lastThinkingIdleNoticeAt = 0;
4040
5160
  const genStartTime = Date.now(); // Track generation elapsed time
4041
5161
  let genTokenCount = 0; // Count response tokens as they stream
5162
+ let lastVisibleActivityAt = Date.now();
5163
+ let heartbeatInterval = null;
4042
5164
 
4043
5165
  console.log(sectionTitle('Sapper', selectedModel, 'cyan'));
5166
+ if (streamHeartbeatEnabled()) {
5167
+ heartbeatInterval = setInterval(() => {
5168
+ if (abortStream) return;
5169
+
5170
+ if (isThinking) {
5171
+ const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
5172
+ const idleThreshold = streamIdleNoticeSeconds();
5173
+ if (idleSeconds >= idleThreshold && Date.now() - lastThinkingIdleNoticeAt >= 5000) {
5174
+ process.stdout.write(`\n${UI.slate(' │ ')}${UI.slate.italic(`... waiting ${idleSeconds}s for more reasoning`)}\n`);
5175
+ thinkingContinuationNeedsPrefix = true;
5176
+ lastThinkingIdleNoticeAt = Date.now();
5177
+ }
5178
+ return;
5179
+ }
5180
+
5181
+ renderStreamingHeartbeat({
5182
+ genTokenCount,
5183
+ genStartTime,
5184
+ lastVisibleActivityAt,
5185
+ stage: genTokenCount > 0 ? 'generating' : 'waiting-first',
5186
+ });
5187
+ }, 1000);
5188
+ }
4044
5189
  for await (const chunk of response) {
4045
5190
  // Check if user pressed Ctrl+C
4046
5191
  if (abortStream) {
@@ -4059,10 +5204,13 @@ async function runSapper() {
4059
5204
  // Live-stream thinking — dim italic, wrap at line breaks
4060
5205
  const lines = thinking.split('\n');
4061
5206
  for (let li = 0; li < lines.length; li++) {
4062
- if (li > 0) process.stdout.write(`\n${UI.slate(' │ ')}`);
5207
+ if (li > 0 || thinkingContinuationNeedsPrefix) process.stdout.write(`\n${UI.slate(' │ ')}`);
5208
+ thinkingContinuationNeedsPrefix = false;
4063
5209
  process.stdout.write(UI.slate.italic(lines[li]));
4064
5210
  }
4065
5211
  thinkMsg += thinking;
5212
+ lastVisibleActivityAt = Date.now();
5213
+ lastThinkingIdleNoticeAt = 0;
4066
5214
  }
4067
5215
 
4068
5216
  const content = chunk.message.content;
@@ -4073,10 +5221,13 @@ async function runSapper() {
4073
5221
  }
4074
5222
  msg += content;
4075
5223
  genTokenCount++;
4076
- // Show live progress with timer, tokens, and interrupt hint
4077
- const elapsed = ((Date.now() - genStartTime) / 1000).toFixed(1);
4078
- const tps = genTokenCount / Math.max((Date.now() - genStartTime) / 1000, 0.1);
4079
- process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s`)} ${UI.slate.italic('Ctrl+C to stop')}`);
5224
+ lastVisibleActivityAt = Date.now();
5225
+ renderStreamingHeartbeat({
5226
+ genTokenCount,
5227
+ genStartTime,
5228
+ lastVisibleActivityAt,
5229
+ stage: 'generating',
5230
+ });
4080
5231
  }
4081
5232
 
4082
5233
  // Capture token stats from the final chunk (done: true)
@@ -4112,9 +5263,19 @@ async function runSapper() {
4112
5263
  // Don't break - just warn. User can Ctrl+C if needed
4113
5264
  }
4114
5265
  }
5266
+ if (heartbeatInterval) {
5267
+ clearInterval(heartbeatInterval);
5268
+ heartbeatInterval = null;
5269
+ }
5270
+ if (isThinking) {
5271
+ isThinking = false;
5272
+ process.stdout.write(`\n${UI.slate(' └─')}\n`);
5273
+ }
4115
5274
  // Clear progress line and render formatted markdown
4116
5275
  process.stdout.write('\r\x1b[K');
5276
+ showStreamPhase('Finalizing streamed response...');
4117
5277
  if (msg.trim()) {
5278
+ showStreamPhase('Rendering markdown output...');
4118
5279
  console.log(renderMarkdown(msg));
4119
5280
  } else {
4120
5281
  console.log();
@@ -4179,6 +5340,8 @@ async function runSapper() {
4179
5340
  write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
4180
5341
  };
4181
5342
 
5343
+ showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
5344
+
4182
5345
  for (const tc of nativeToolCalls) {
4183
5346
  const fn = tc.function;
4184
5347
  const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
@@ -4202,8 +5365,8 @@ async function runSapper() {
4202
5365
  try {
4203
5366
  switch (fn.name) {
4204
5367
  case 'list_directory':
4205
- result = tools.list(args.path);
4206
- logEntry('file', { action: 'list', path: args.path });
5368
+ result = tools.list(args.path ?? '.');
5369
+ logEntry('file', { action: 'list', path: args.path ?? '.' });
4207
5370
  break;
4208
5371
  case 'read_file':
4209
5372
  result = tools.read(args.path);
@@ -4261,8 +5424,11 @@ async function runSapper() {
4261
5424
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
4262
5425
 
4263
5426
  if (hitToolLimit) {
5427
+ showStreamPhase('Tool limit reached. Requesting final answer...');
4264
5428
  resetTerminal();
4265
5429
  messages.push({ role: 'user', content: 'STOP using tools now. Provide your analysis based on what you have.' });
5430
+ } else {
5431
+ showStreamPhase('Tool results ready. Continuing response generation...');
4266
5432
  }
4267
5433
  continue; // Loop back for AI to process tool results
4268
5434
  }
@@ -4331,6 +5497,8 @@ async function runSapper() {
4331
5497
  if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
4332
5498
  }
4333
5499
 
5500
+ showStreamPhase(`Running ${toolMatches.length} parsed tool call${toolMatches.length === 1 ? '' : 's'}...`);
5501
+
4334
5502
  for (const match of toolMatches) {
4335
5503
  const [_, type, path, content] = match;
4336
5504
 
@@ -4428,11 +5596,14 @@ async function runSapper() {
4428
5596
 
4429
5597
  // If tool limit was reached, stop after processing this round
4430
5598
  if (hitToolLimit) {
5599
+ showStreamPhase('Tool limit reached. Requesting final answer...');
4431
5600
  resetTerminal();
4432
5601
  messages.push({
4433
5602
  role: 'user',
4434
5603
  content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
4435
5604
  });
5605
+ } else {
5606
+ showStreamPhase('Tool results ready. Continuing response generation...');
4436
5607
  }
4437
5608
  } else {
4438
5609
  // No tools found - check if malformed command