sapper-iq 1.1.36 → 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
@@ -8,7 +8,7 @@ import readline from 'readline';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { marked } from 'marked';
11
- import TerminalRenderer from 'marked-terminal';
11
+ import { markedTerminal } from 'marked-terminal';
12
12
  import * as acorn from 'acorn';
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
@@ -26,7 +26,7 @@ process.on('unhandledRejection', (reason) => {
26
26
  let ctrlCCount = 0;
27
27
  process.on('SIGINT', () => {
28
28
  ctrlCCount++;
29
- if (ctrlCCount >= 2) {
29
+ if (ctrlCCount >= 3) {
30
30
  console.log(chalk.red('\nForce quitting...'));
31
31
  process.exit(1);
32
32
  }
@@ -36,7 +36,11 @@ process.on('SIGINT', () => {
36
36
  // Clear current line and move to new one - stops ghost output
37
37
  process.stdout.clearLine(0);
38
38
  process.stdout.cursorTo(0);
39
- console.log(chalk.yellow('\n⏹️ Stopping response... (Ctrl+C again to force quit)'));
39
+ if (ctrlCCount >= 2) {
40
+ console.log(chalk.yellow('\n⏹️ Press Ctrl+C once more to force quit'));
41
+ } else {
42
+ console.log(UI.slate('\n⏹️ Stopped'));
43
+ }
40
44
 
41
45
  // Reset terminal immediately
42
46
  resetTerminal();
@@ -76,6 +80,7 @@ const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
76
80
  const AGENTS_DIR = `${SAPPER_DIR}/agents`;
77
81
  const SKILLS_DIR = `${SAPPER_DIR}/skills`;
78
82
  const LOGS_DIR = `${SAPPER_DIR}/logs`;
83
+ const SAPPERIGNORE_FILE = '.sapperignore';
79
84
 
80
85
  // ═══════════════════════════════════════════════════════════════
81
86
  // COMPREHENSIVE ACTIVITY LOGGER
@@ -300,6 +305,156 @@ function ensureSapperDir() {
300
305
  }
301
306
  }
302
307
 
308
+ // Default .sapperignore template — created on first run
309
+ const DEFAULT_SAPPERIGNORE = `# ═══════════════════════════════════════════════════════════════
310
+ # .sapperignore — Files and folders Sapper should ignore
311
+ # Works like .gitignore: one pattern per line, # for comments
312
+ # Edit this file to customize what Sapper skips
313
+ # ═══════════════════════════════════════════════════════════════
314
+
315
+ # ── Sapper internal ──
316
+ .sapper/
317
+
318
+ # ── Dependencies ──
319
+ node_modules/
320
+ vendor/
321
+ bower_components/
322
+
323
+ # ── Build outputs ──
324
+ dist/
325
+ build/
326
+ out/
327
+ .next/
328
+ .nuxt/
329
+ .output/
330
+ .vercel/
331
+ .netlify/
332
+
333
+ # ── Environment & secrets ──
334
+ .env
335
+ .env.*
336
+ !.env.example
337
+ *.pem
338
+ *.key
339
+ *.cert
340
+
341
+ # ── Version control ──
342
+ .git/
343
+ .svn/
344
+ .hg/
345
+
346
+ # ── IDE / Editor ──
347
+ .idea/
348
+ .vscode/
349
+ *.swp
350
+ *.swo
351
+ *~
352
+
353
+ # ── OS files ──
354
+ .DS_Store
355
+ Thumbs.db
356
+ desktop.ini
357
+
358
+ # ── Caches ──
359
+ .cache/
360
+ __pycache__/
361
+ *.pyc
362
+ .pytest_cache/
363
+ .mypy_cache/
364
+
365
+ # ── Coverage & tests ──
366
+ coverage/
367
+ .nyc_output/
368
+ htmlcov/
369
+
370
+ # ── Logs ──
371
+ *.log
372
+ npm-debug.log*
373
+ yarn-debug.log*
374
+ yarn-error.log*
375
+
376
+ # ── Lock files (large) ──
377
+ package-lock.json
378
+ yarn.lock
379
+ pnpm-lock.yaml
380
+ composer.lock
381
+ Gemfile.lock
382
+ Cargo.lock
383
+
384
+ # ── Compiled / binary / large ──
385
+ *.min.js
386
+ *.min.css
387
+ *.map
388
+ *.bundle.js
389
+ *.chunk.js
390
+ *.wasm
391
+ *.so
392
+ *.dylib
393
+ *.dll
394
+ *.exe
395
+ *.o
396
+ *.a
397
+ *.class
398
+ *.jar
399
+ *.war
400
+ *.zip
401
+ *.tar.gz
402
+ *.tgz
403
+ *.rar
404
+ *.7z
405
+ *.iso
406
+ *.dmg
407
+
408
+ # ── Media (large files) ──
409
+ *.mp4
410
+ *.mp3
411
+ *.avi
412
+ *.mov
413
+ *.mkv
414
+ *.wav
415
+ *.flac
416
+ *.png
417
+ *.jpg
418
+ *.jpeg
419
+ *.gif
420
+ *.bmp
421
+ *.ico
422
+ *.svg
423
+ *.webp
424
+ *.ttf
425
+ *.woff
426
+ *.woff2
427
+ *.eot
428
+ *.otf
429
+ *.pdf
430
+
431
+ # ── Database ──
432
+ *.sqlite
433
+ *.sqlite3
434
+ *.db
435
+
436
+ # ── Terraform / IaC ──
437
+ .terraform/
438
+ *.tfstate
439
+ *.tfstate.*
440
+
441
+ # ── Docker ──
442
+ *.tar
443
+
444
+ # ── Gradle / Maven ──
445
+ .gradle/
446
+ target/
447
+ `;
448
+
449
+ // Create .sapperignore if it doesn't exist (runs on startup)
450
+ function ensureSapperIgnore() {
451
+ if (!fs.existsSync(SAPPERIGNORE_FILE)) {
452
+ fs.writeFileSync(SAPPERIGNORE_FILE, DEFAULT_SAPPERIGNORE);
453
+ return true; // newly created
454
+ }
455
+ return false;
456
+ }
457
+
303
458
  // Ensure agents and skills directories exist
304
459
  function ensureAgentsDirs() {
305
460
  ensureSapperDir();
@@ -663,7 +818,10 @@ function buildSystemPrompt(agentContent = null, skillContents = []) {
663
818
  const now = new Date();
664
819
  const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
665
820
  const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
666
- 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.
667
825
  You can help with ANY task - coding, writing, research, planning, analysis, and more.
668
826
  Adapt your personality and expertise based on the active agent role and loaded skills.
669
827
 
@@ -675,6 +833,9 @@ RULES:
675
833
  3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
676
834
  4. VERIFY: After making changes, verify they work (run tests, check output, etc).
677
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;
678
839
 
679
840
  if (_useNativeToolsFlag) {
680
841
  prompt += `
@@ -686,7 +847,12 @@ Available tools: list_directory, read_file, search_files, write_file, patch_file
686
847
  PATCH TIPS:
687
848
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
688
849
  - Always read_file first to see exact content before using patch_file.
689
- - 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.`;
690
856
  } else {
691
857
  prompt += `
692
858
 
@@ -704,6 +870,11 @@ PATCH TIPS:
704
870
  - Always READ the file first to see exact content before using PATCH.
705
871
  - If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
706
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
+
707
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.
708
879
  Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
709
880
  }
@@ -739,6 +910,10 @@ FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to u
739
910
  prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
740
911
  }
741
912
 
913
+ if (promptAppend) {
914
+ prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
915
+ }
916
+
742
917
  return prompt;
743
918
  }
744
919
 
@@ -747,25 +922,413 @@ let currentAgent = null; // null = default Sapper, or agent name string
747
922
  let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
748
923
  let loadedSkills = []; // array of skill names currently loaded
749
924
 
750
- // 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)
751
1095
  function loadConfig() {
752
1096
  try {
753
1097
  ensureSapperDir();
754
1098
  if (fs.existsSync(CONFIG_FILE)) {
755
- 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;
756
1105
  }
757
1106
  } catch (e) {}
758
- return { autoAttach: true }; // Default: auto-attach related files is ON
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));
1113
+ }
1114
+ } catch (e) {}
1115
+ return defaultConfig;
759
1116
  }
760
1117
 
761
1118
  function saveConfig(config) {
762
1119
  ensureSapperDir();
763
- 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;
764
1123
  }
765
1124
 
766
1125
  // Global config
767
1126
  let sapperConfig = loadConfig();
768
1127
 
1128
+ // Effective context length — user limit overrides model's reported size
1129
+ function effectiveContextLength() {
1130
+ if (sapperConfig.contextLimit && sapperConfig.contextLimit > 0) {
1131
+ return sapperConfig.contextLimit;
1132
+ }
1133
+ return modelContextLength;
1134
+ }
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
+
769
1332
  // ═══════════════════════════════════════════════════════════════
770
1333
  // WORKSPACE GRAPH - Track file relationships and summaries
771
1334
  // ═══════════════════════════════════════════════════════════════
@@ -876,9 +1439,10 @@ async function buildWorkspaceGraph(showProgress = true) {
876
1439
  const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
877
1440
 
878
1441
  if (entry.isDirectory()) {
879
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
1442
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
880
1443
  scanDir(fullPath, depth + 1);
881
1444
  } else {
1445
+ if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
882
1446
  const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
883
1447
  if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
884
1448
 
@@ -1298,18 +1862,41 @@ async function addToEmbeddings(text, embeddings) {
1298
1862
  // SMART CONTEXT SUMMARIZATION
1299
1863
  // ═══════════════════════════════════════════════════════════════
1300
1864
 
1301
- async function autoSummarizeContext(messages, model) {
1865
+ async function autoSummarizeContext(messages, model, force = false) {
1866
+ // Use real token-based threshold if we know the model's context length
1867
+ const estimatedTokens = estimateMessagesTokens(messages);
1302
1868
  const contextSize = JSON.stringify(messages).length;
1303
- if (contextSize <= 32000 || messages.length <= 5) return messages;
1869
+
1870
+ // Summarize when we hit the configured share of the effective context window
1871
+ const ctxLen = effectiveContextLength();
1872
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
1873
+ // Also keep the old byte-based check as a fallback
1874
+ const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
1875
+ (!ctxLen && contextSize > 32000);
1876
+
1877
+ if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
1878
+
1879
+ const usagePercent = ctxLen
1880
+ ? Math.round((estimatedTokens / ctxLen) * 100)
1881
+ : Math.round((contextSize / 32000) * 100);
1304
1882
 
1305
1883
  console.log();
1306
- console.log(box(
1307
- `Context is ${chalk.red.bold(Math.round(contextSize / 1024) + 'KB')} (${messages.length} messages)\n` +
1308
- `${chalk.cyan('Auto-summarizing via AI to keep things fast...')}`,
1309
- '🧠 Smart Summary', 'cyan'
1310
- ));
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'));
1311
1896
 
1312
- 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();
1313
1900
 
1314
1901
  // Separate: system prompt, messages to summarize, recent messages to keep
1315
1902
  const systemPrompt = messages[0];
@@ -1363,13 +1950,13 @@ async function autoSummarizeContext(messages, model) {
1363
1950
  })
1364
1951
  .join('\n\n');
1365
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
+
1366
1958
  try {
1367
- const summaryResponse = await ollama.chat({
1368
- model,
1369
- messages: [
1370
- {
1371
- role: 'system',
1372
- 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:
1373
1960
  - Key topics discussed and decisions made
1374
1961
  - Files that were read, created, or modified (with paths)
1375
1962
  - Important code changes or bugs found
@@ -1381,7 +1968,20 @@ async function autoSummarizeContext(messages, model) {
1381
1968
 
1382
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.
1383
1970
 
1384
- 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
1385
1985
  },
1386
1986
  {
1387
1987
  role: 'user',
@@ -1390,10 +1990,13 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1390
1990
  ],
1391
1991
  stream: false
1392
1992
  });
1993
+ clearInterval(spinnerInterval);
1994
+ spinnerInterval = null;
1393
1995
 
1394
1996
  const summary = summaryResponse.message.content;
1395
1997
 
1396
1998
  // Save old messages to embeddings before discarding
1999
+ summarySpinner.text = summaryPhaseText(3, `Saving compressed context and memory (${elapsedSummaryTime()} elapsed)`);
1397
2000
  const embeddings = loadEmbeddings();
1398
2001
  const textToEmbed = oldMessages
1399
2002
  .filter(m => m.role !== 'system')
@@ -1444,19 +2047,31 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1444
2047
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(newMessages, null, 2));
1445
2048
 
1446
2049
  const newSize = JSON.stringify(newMessages).length;
2050
+ const newTokens = estimateMessagesTokens(newMessages);
1447
2051
  summarySpinner.stop();
1448
- console.log(chalk.green(`✅ Summarized! ${chalk.gray(`${Math.round(contextSize / 1024)}KB → ${Math.round(newSize / 1024)}KB`)} (${messages.length} → ${newMessages.length} messages)`));
2052
+ if (summaryPhasesEnabled()) {
2053
+ console.log(chalk.gray(` ${summaryPhaseText(4, 'Context ready. Returning to chat...')}`));
2054
+ }
2055
+ console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
2056
+ if (ctxLen) {
2057
+ const newPercent = Math.round((newTokens / ctxLen) * 100);
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
+ }
2062
+ }
1449
2063
  if (embeddings.chunks.length > 0) {
1450
2064
  console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
1451
2065
  }
1452
2066
  logEntry('summary', {
1453
- before: `${Math.round(contextSize / 1024)}KB / ${messages.length} msgs`,
1454
- after: `${Math.round(newSize / 1024)}KB / ${newMessages.length} msgs`
2067
+ before: `~${estimatedTokens.toLocaleString()} tokens / ${messages.length} msgs`,
2068
+ after: `~${newTokens.toLocaleString()} tokens / ${newMessages.length} msgs`
1455
2069
  });
1456
2070
  console.log();
1457
2071
 
1458
2072
  return newMessages;
1459
2073
  } catch (e) {
2074
+ if (spinnerInterval) clearInterval(spinnerInterval);
1460
2075
  summarySpinner.stop();
1461
2076
  console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
1462
2077
  console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
@@ -1468,64 +2083,468 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1468
2083
  // FANCY UI HELPERS
1469
2084
  // ═══════════════════════════════════════════════════════════════
1470
2085
 
1471
- const BANNER = `
1472
- ${chalk.cyan(' ███████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗ ')}
1473
- ${chalk.cyan(' ██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗')}
1474
- ${chalk.cyan(' ███████╗███████║██████╔╝██████╔╝█████╗ ██████╔╝')}
1475
- ${chalk.cyan(' ╚════██║██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗')}
1476
- ${chalk.cyan(' ███████║██║ ██║██║ ██║ ███████╗██║ ██║')}
1477
- ${chalk.cyan(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝')}
1478
- `;
2086
+ const UI = {
2087
+ accent: chalk.hex('#7cc4ff'),
2088
+ accentSoft: chalk.hex('#b8d9ff'),
2089
+ mint: chalk.hex('#9ad7b3'),
2090
+ gold: chalk.hex('#d8bc7a'),
2091
+ coral: chalk.hex('#de9d8f'),
2092
+ slate: chalk.hex('#8a95a6'),
2093
+ ink: chalk.hex('#e6ebf2'),
2094
+ };
1479
2095
 
1480
- function box(content, title = '', color = 'cyan') {
1481
- const lines = content.split('\n');
1482
- const maxLen = Math.max(...lines.map(l => l.length), title.length + 4);
1483
- const colorFn = chalk[color] || chalk.cyan;
1484
-
1485
- let result = colorFn('╭' + (title ? `─ ${title} ` : '') + '─'.repeat(maxLen - title.length - (title ? 3 : 0)) + '') + '\n';
1486
- for (const line of lines) {
1487
- result += colorFn('│') + ' ' + line.padEnd(maxLen) + ' ' + colorFn('│') + '\n';
2096
+ const BOX_TONES = {
2097
+ cyan: UI.accent,
2098
+ green: UI.mint,
2099
+ yellow: UI.gold,
2100
+ red: UI.coral,
2101
+ magenta: chalk.hex('#b7b9ff'),
2102
+ gray: UI.slate,
2103
+ blue: chalk.hex('#8fb6ff'),
2104
+ };
2105
+
2106
+ const BADGE_STYLES = {
2107
+ info: UI.accent,
2108
+ success: UI.mint,
2109
+ warning: UI.gold,
2110
+ error: UI.coral,
2111
+ action: chalk.hex('#9bbcff'),
2112
+ neutral: UI.slate,
2113
+ };
2114
+
2115
+ const ANSI_PATTERN = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g;
2116
+
2117
+ function stripAnsi(value = '') {
2118
+ return String(value).replace(ANSI_PATTERN, '');
2119
+ }
2120
+
2121
+ function visibleLength(value = '') {
2122
+ return stripAnsi(value).length;
2123
+ }
2124
+
2125
+ function terminalWidth(max = 98) {
2126
+ return Math.max(48, Math.min(max, process.stdout.columns || 88));
2127
+ }
2128
+
2129
+ function toneColor(tone = 'cyan') {
2130
+ return BOX_TONES[tone] || chalk.cyan;
2131
+ }
2132
+
2133
+ function padAnsi(value = '', width = 0) {
2134
+ return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}`;
2135
+ }
2136
+
2137
+ function formatBytes(bytes = 0) {
2138
+ if (!bytes || bytes < 1024) return `${bytes || 0} B`;
2139
+
2140
+ const units = ['KB', 'MB', 'GB', 'TB'];
2141
+ let size = bytes / 1024;
2142
+ let unitIndex = 0;
2143
+ while (size >= 1024 && unitIndex < units.length - 1) {
2144
+ size /= 1024;
2145
+ unitIndex++;
1488
2146
  }
1489
- result += colorFn('╰' + '─'.repeat(maxLen + 2) + '╯');
1490
- return result;
2147
+
2148
+ const precision = size >= 100 ? 0 : size >= 10 ? 1 : 2;
2149
+ return `${size.toFixed(precision)} ${units[unitIndex]}`;
1491
2150
  }
1492
2151
 
1493
- function divider(char = '─', color = 'gray') {
1494
- const width = process.stdout.columns || 60;
1495
- return chalk[color](char.repeat(Math.min(width, 60)));
2152
+ function formatRelativeTime(value) {
2153
+ if (!value) return 'unknown';
2154
+
2155
+ const delta = Math.max(0, Date.now() - new Date(value).getTime());
2156
+ const units = [
2157
+ ['d', 24 * 60 * 60 * 1000],
2158
+ ['h', 60 * 60 * 1000],
2159
+ ['m', 60 * 1000],
2160
+ ];
2161
+
2162
+ for (const [label, size] of units) {
2163
+ const amount = Math.floor(delta / size);
2164
+ if (amount >= 1) return `${amount}${label} ago`;
2165
+ }
2166
+
2167
+ return 'just now';
2168
+ }
2169
+
2170
+ const BANNER = [
2171
+ `${chalk.hex('#c8ecff').bold('Sapper')} ${UI.slate('terminal workspace')}`,
2172
+ UI.slate('Local models, live tools, and focused coding in one loop')
2173
+ ].join('\n');
2174
+
2175
+ function box(content, title = '', tone = 'cyan', options = {}) {
2176
+ const width = Math.max(28, Math.min(options.width || terminalWidth(72), terminalWidth(72)));
2177
+ const header = title ? `${toneColor(tone).bold(title)}\n${divider('─', tone, width)}\n` : '';
2178
+ return `${header}${String(content ?? '')}\n${divider('─', tone, width)}`;
2179
+ }
2180
+
2181
+ function divider(char = '─', tone = 'gray', width = terminalWidth(70)) {
2182
+ return toneColor(tone)(char.repeat(Math.max(12, width)));
2183
+ }
2184
+
2185
+ function sectionTitle(title, subtitle = '', tone = 'cyan') {
2186
+ return `${toneColor(tone).bold(title)}${subtitle ? ` ${UI.slate(subtitle)}` : ''}`;
1496
2187
  }
1497
2188
 
1498
2189
  function statusBadge(text, type = 'info') {
1499
- const badges = {
1500
- info: chalk.bgCyan.black(` ${text} `),
1501
- success: chalk.bgGreen.black(` ${text} `),
1502
- warning: chalk.bgYellow.black(` ${text} `),
1503
- error: chalk.bgRed.white(` ${text} `),
1504
- action: chalk.bgMagenta.white(` ${text} `)
2190
+ const badge = BADGE_STYLES[type] || BADGE_STYLES.info;
2191
+ return badge(`[${text}]`);
2192
+ }
2193
+
2194
+ function keyValue(label, value, width = 12) {
2195
+ return `${padAnsi(UI.slate(label), width)} ${value}`;
2196
+ }
2197
+
2198
+ function commandRow(command, description, width = 18) {
2199
+ return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
2200
+ }
2201
+
2202
+ function meter(current = 0, total = 0, width = 20) {
2203
+ if (!total || total <= 0) return UI.slate('░'.repeat(width));
2204
+
2205
+ const ratio = Math.max(0, Math.min(1, current / total));
2206
+ const filled = Math.round(ratio * width);
2207
+ const colorFn = ratio >= 0.85 ? toneColor('red') : ratio >= 0.65 ? toneColor('yellow') : toneColor('green');
2208
+ return `${colorFn('█'.repeat(filled))}${UI.slate('░'.repeat(Math.max(0, width - filled)))}`;
2209
+ }
2210
+
2211
+ function ellipsis(text = '', max = 48) {
2212
+ const plain = String(text);
2213
+ if (plain.length <= max) return plain;
2214
+ return `${plain.slice(0, Math.max(0, max - 1))}…`;
2215
+ }
2216
+
2217
+ function promptShell(label, detail = '') {
2218
+ return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
2219
+ }
2220
+
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] ') {
2269
+ const colors = {
2270
+ info: UI.accent,
2271
+ success: UI.mint,
2272
+ warning: UI.gold,
2273
+ error: UI.coral,
2274
+ action: chalk.hex('#8fb6ff'),
2275
+ neutral: UI.slate,
2276
+ };
2277
+ const colorFn = colors[type] || UI.gold;
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,
1505
2311
  };
1506
- return badges[type] || badges.info;
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');
1507
2518
  }
1508
2519
 
1509
2520
  // Configure marked with terminal renderer
1510
- marked.setOptions({
1511
- renderer: new TerminalRenderer({
2521
+ marked.use(markedTerminal({
1512
2522
  code: chalk.cyan,
1513
2523
  blockquote: chalk.gray.italic,
1514
2524
  html: chalk.gray,
1515
2525
  heading: chalk.bold.cyan,
1516
2526
  firstHeading: chalk.bold.cyan,
1517
- hr: chalk.gray('─'.repeat(40)),
1518
- listitem: chalk.yellow('• ') + '%s',
1519
2527
  table: chalk.white,
2528
+ tableOptions: {
2529
+ chars: {
2530
+ top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
2531
+ bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
2532
+ left: '│', 'left-mid': '├', mid: '─', 'mid-mid': '┼',
2533
+ right: '│', 'right-mid': '┤', middle: '│'
2534
+ },
2535
+ style: { head: ['cyan', 'bold'], border: ['gray'] }
2536
+ },
1520
2537
  paragraph: chalk.white,
1521
2538
  strong: chalk.bold.white,
1522
2539
  em: chalk.italic,
1523
2540
  codespan: chalk.cyan,
1524
2541
  del: chalk.strikethrough,
1525
2542
  link: chalk.underline.blue,
1526
- href: chalk.gray
1527
- })
1528
- });
2543
+ href: chalk.gray,
2544
+ showSectionPrefix: true,
2545
+ reflowText: true,
2546
+ width: Math.min(process.stdout.columns || 80, 120)
2547
+ }));
1529
2548
 
1530
2549
  // Render markdown to terminal
1531
2550
  function renderMarkdown(text) {
@@ -1539,6 +2558,35 @@ function renderMarkdown(text) {
1539
2558
  let stepMode = false;
1540
2559
  let debugMode = false; // Toggle with /debug command
1541
2560
  let abortStream = false; // Flag to interrupt AI response
2561
+
2562
+ // ═══════════════════════════════════════════════════════════════
2563
+ // REAL CONTEXT WINDOW TRACKING
2564
+ // ═══════════════════════════════════════════════════════════════
2565
+ let modelContextLength = null; // Detected from ollama.show() model_info
2566
+ let lastPromptTokens = 0; // prompt_eval_count from last response
2567
+ let lastEvalTokens = 0; // eval_count from last response
2568
+
2569
+ // Estimate token count from text (~4 chars per token for English, ~3 for code)
2570
+ // This is a rough heuristic - actual counts come from Ollama response stats
2571
+ function estimateTokens(text) {
2572
+ if (!text) return 0;
2573
+ // Count code blocks separately (denser tokens)
2574
+ const codeBlocks = text.match(/```[\s\S]*?```/g) || [];
2575
+ let codeChars = codeBlocks.reduce((sum, b) => sum + b.length, 0);
2576
+ let textChars = text.length - codeChars;
2577
+ return Math.ceil(textChars / 4 + codeChars / 3.5);
2578
+ }
2579
+
2580
+ // Estimate total tokens for the messages array
2581
+ function estimateMessagesTokens(messages) {
2582
+ let total = 0;
2583
+ for (const m of messages) {
2584
+ // Each message has ~4 tokens of overhead (role, formatting)
2585
+ total += 4;
2586
+ total += estimateTokens(m.content);
2587
+ }
2588
+ return total;
2589
+ }
1542
2590
  let rl = readline.createInterface({
1543
2591
  input: process.stdin,
1544
2592
  output: process.stdout,
@@ -1569,6 +2617,181 @@ async function safeQuestion(query) {
1569
2617
  });
1570
2618
  }
1571
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
+
1572
2795
  // Directories to ignore when listing files
1573
2796
  const IGNORE_DIRS = new Set([
1574
2797
  'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
@@ -1589,6 +2812,157 @@ const CODE_EXTENSIONS = new Set([
1589
2812
  // Max file size to include (skip large files like bundled/minified)
1590
2813
  const MAX_FILE_SIZE = 100000; // 100KB per file
1591
2814
  const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
2815
+ const MAX_URL_SIZE = 200000; // 200KB max for fetched web pages
2816
+
2817
+ // ═══════════════════════════════════════════════════════════════
2818
+ // URL FETCHING — Read web pages and learn from them
2819
+ // ═══════════════════════════════════════════════════════════════
2820
+ import https from 'https';
2821
+ import http from 'http';
2822
+
2823
+ // Fetch a URL and return extracted text content
2824
+ function fetchUrl(url, timeout = 15000) {
2825
+ return new Promise((resolve, reject) => {
2826
+ const lib = url.startsWith('https') ? https : http;
2827
+ const req = lib.get(url, {
2828
+ headers: {
2829
+ 'User-Agent': 'Sapper-AI/1.0',
2830
+ 'Accept': 'text/html,application/json,text/plain,*/*'
2831
+ },
2832
+ timeout
2833
+ }, (res) => {
2834
+ // Follow redirects (up to 3)
2835
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
2836
+ const redirectUrl = res.headers.location.startsWith('http')
2837
+ ? res.headers.location
2838
+ : new URL(res.headers.location, url).href;
2839
+ return fetchUrl(redirectUrl, timeout).then(resolve).catch(reject);
2840
+ }
2841
+ if (res.statusCode !== 200) {
2842
+ return reject(new Error(`HTTP ${res.statusCode}`));
2843
+ }
2844
+
2845
+ let data = '';
2846
+ let size = 0;
2847
+ res.on('data', (chunk) => {
2848
+ size += chunk.length;
2849
+ if (size > MAX_URL_SIZE) {
2850
+ res.destroy();
2851
+ reject(new Error(`Page too large (>${Math.round(MAX_URL_SIZE/1024)}KB)`));
2852
+ return;
2853
+ }
2854
+ data += chunk;
2855
+ });
2856
+ res.on('end', () => resolve(data));
2857
+ res.on('error', reject);
2858
+ });
2859
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
2860
+ req.on('error', reject);
2861
+ });
2862
+ }
2863
+
2864
+ // Strip HTML tags and extract readable text
2865
+ function htmlToText(html) {
2866
+ let text = html;
2867
+ // Remove script and style blocks entirely
2868
+ text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
2869
+ text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
2870
+ text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
2871
+ text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
2872
+ text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
2873
+ // Convert common block elements to newlines
2874
+ text = text.replace(/<\/?(p|div|br|h[1-6]|li|tr|td|th|blockquote|pre|hr)[^>]*>/gi, '\n');
2875
+ // Remove all other HTML tags
2876
+ text = text.replace(/<[^>]+>/g, '');
2877
+ // Decode common HTML entities
2878
+ text = text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
2879
+ .replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&nbsp;/g, ' ')
2880
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
2881
+ // Clean up whitespace
2882
+ text = text.replace(/[ \t]+/g, ' ');
2883
+ text = text.replace(/\n\s*\n/g, '\n\n');
2884
+ text = text.trim();
2885
+ // Limit to reasonable size
2886
+ if (text.length > 50000) {
2887
+ text = text.substring(0, 50000) + '\n\n[... content truncated at 50KB ...]';
2888
+ }
2889
+ return text;
2890
+ }
2891
+
2892
+ // Detect URLs in text
2893
+ const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
2894
+
2895
+ // ═══════════════════════════════════════════════════════════════
2896
+ // .sapperignore SUPPORT — like .gitignore for Sapper
2897
+ // ═══════════════════════════════════════════════════════════════
2898
+
2899
+ // Parse .sapperignore patterns (glob-like, one per line, # comments)
2900
+ function loadSapperIgnorePatterns() {
2901
+ const patterns = [];
2902
+ try {
2903
+ if (fs.existsSync(SAPPERIGNORE_FILE)) {
2904
+ const lines = fs.readFileSync(SAPPERIGNORE_FILE, 'utf8').split('\n');
2905
+ for (const rawLine of lines) {
2906
+ const line = rawLine.trim();
2907
+ if (!line || line.startsWith('#')) continue;
2908
+ // Track negation patterns (lines starting with !)
2909
+ const negate = line.startsWith('!');
2910
+ const pattern = negate ? line.slice(1) : line;
2911
+ patterns.push({ pattern, negate });
2912
+ }
2913
+ }
2914
+ } catch (e) {
2915
+ // Silent fail — ignore file is optional
2916
+ }
2917
+ return patterns;
2918
+ }
2919
+
2920
+ let _sapperIgnorePatterns = null;
2921
+ function getSapperIgnorePatterns() {
2922
+ if (_sapperIgnorePatterns === null) {
2923
+ _sapperIgnorePatterns = loadSapperIgnorePatterns();
2924
+ }
2925
+ return _sapperIgnorePatterns;
2926
+ }
2927
+
2928
+ // Reload patterns (call when .sapperignore changes)
2929
+ function reloadSapperIgnore() {
2930
+ _sapperIgnorePatterns = null;
2931
+ }
2932
+
2933
+ // Convert a .sapperignore glob pattern to a regex
2934
+ function ignorePatternToRegex(pattern) {
2935
+ // Remove trailing slashes (directory markers)
2936
+ let p = pattern.replace(/\/+$/, '');
2937
+ // Escape regex special chars except * and ?
2938
+ p = p.replace(/([.+^${}()|[\]\\])/g, '\\$1');
2939
+ // Convert glob wildcards
2940
+ p = p.replace(/\*\*/g, '<<<GLOBSTAR>>>');
2941
+ p = p.replace(/\*/g, '[^/]*');
2942
+ p = p.replace(/<<<GLOBSTAR>>>/g, '.*');
2943
+ p = p.replace(/\?/g, '[^/]');
2944
+ // Match the whole name or path
2945
+ return new RegExp(`(^|/)${p}($|/)`, 'i');
2946
+ }
2947
+
2948
+ // Check if a file/dir name or path should be ignored
2949
+ function shouldIgnore(nameOrPath) {
2950
+ // Always check built-in IGNORE_DIRS first (fast path)
2951
+ const baseName = nameOrPath.includes('/') ? nameOrPath.split('/').pop() : nameOrPath;
2952
+ if (IGNORE_DIRS.has(baseName)) return true;
2953
+
2954
+ const patterns = getSapperIgnorePatterns();
2955
+ if (patterns.length === 0) return false;
2956
+
2957
+ let ignored = false;
2958
+ for (const { pattern, negate } of patterns) {
2959
+ const regex = ignorePatternToRegex(pattern);
2960
+ if (regex.test(nameOrPath) || regex.test(baseName)) {
2961
+ ignored = !negate;
2962
+ }
2963
+ }
2964
+ return ignored;
2965
+ }
1592
2966
 
1593
2967
  // Scan entire codebase and return summary
1594
2968
  function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
@@ -1603,14 +2977,15 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
1603
2977
  for (const entry of entries) {
1604
2978
  const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
1605
2979
 
1606
- // Skip ignored directories
2980
+ // Skip ignored directories and files (respects .sapperignore)
1607
2981
  if (entry.isDirectory()) {
1608
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
2982
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
1609
2983
  const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
1610
2984
  files = files.concat(subResult.files);
1611
2985
  totalSize += subResult.totalSize;
1612
2986
  } else {
1613
2987
  // Check if file should be included
2988
+ if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
1614
2989
  const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
1615
2990
  const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
1616
2991
 
@@ -1649,7 +3024,7 @@ function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
1649
3024
  const entries = fs.readdirSync(dir, { withFileTypes: true });
1650
3025
  for (const entry of entries) {
1651
3026
  if (files.length >= maxFiles) break;
1652
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
3027
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
1653
3028
 
1654
3029
  const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1655
3030
 
@@ -1697,8 +3072,9 @@ async function pickFiles() {
1697
3072
  // Clear screen and move cursor to top
1698
3073
  console.clear();
1699
3074
  console.log(box(
1700
- `${chalk.cyan('↑↓')} Navigate ${chalk.cyan('Space')} Toggle ${chalk.cyan('a')} All ${chalk.cyan('Enter')} Confirm ${chalk.cyan('q/Esc')} Cancel`,
1701
- '📎 Select Files', 'cyan'
3075
+ `${statusBadge('Move', 'info')} ↑ ↓ ${statusBadge('Toggle', 'success')} space ${statusBadge('All', 'warning')} a\n` +
3076
+ `${statusBadge('Confirm', 'success')} enter ${statusBadge('Cancel', 'error')} q / esc`,
3077
+ 'Attach Files', 'cyan'
1702
3078
  ));
1703
3079
  console.log();
1704
3080
 
@@ -1729,7 +3105,7 @@ async function pickFiles() {
1729
3105
  }
1730
3106
 
1731
3107
  console.log();
1732
- console.log(chalk.gray(` Selected: ${selected.size} file${selected.size !== 1 ? 's' : ''}`));
3108
+ console.log(`${statusBadge('Selected', 'action')} ${chalk.white(`${selected.size} file${selected.size !== 1 ? 's' : ''}`)}`);
1733
3109
  };
1734
3110
 
1735
3111
  return new Promise((resolve) => {
@@ -1828,13 +3204,127 @@ function formatScanResults(scanResult) {
1828
3204
  return output;
1829
3205
  }
1830
3206
 
3207
+ // Interactive model picker with keyboard navigation
3208
+ async function pickModel(models) {
3209
+ if (!models || models.length === 0) return null;
3210
+
3211
+ let cursor = 0;
3212
+ const pageSize = Math.max(5, Math.min(8, (process.stdout.rows || 24) - 14));
3213
+
3214
+ if (process.stdin.isTTY) {
3215
+ process.stdin.setRawMode(true);
3216
+ }
3217
+ process.stdin.resume();
3218
+
3219
+ const render = () => {
3220
+ const current = models[cursor];
3221
+ console.clear();
3222
+ console.log(BANNER);
3223
+ console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
3224
+ console.log(divider());
3225
+ console.log(sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'));
3226
+ console.log();
3227
+
3228
+ const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
3229
+ const endIdx = Math.min(startIdx + pageSize, models.length);
3230
+
3231
+ if (startIdx > 0) {
3232
+ console.log(UI.slate(' ↑ more models'));
3233
+ }
3234
+
3235
+ for (let i = startIdx; i < endIdx; i++) {
3236
+ const model = models[i];
3237
+ const isActive = i === cursor;
3238
+ const marker = isActive ? UI.accent('›') : UI.slate(' ');
3239
+ const index = isActive ? UI.accent(String(i + 1).padStart(2, '0')) : UI.slate(String(i + 1).padStart(2, '0'));
3240
+ const name = isActive ? UI.accentSoft.bold(ellipsis(model.name, 40)) : chalk.white(ellipsis(model.name, 40));
3241
+ const meta = [
3242
+ model.size ? formatBytes(model.size) : null,
3243
+ model.modified_at ? formatRelativeTime(model.modified_at) : null,
3244
+ model.details?.parameter_size || null,
3245
+ ].filter(Boolean).join(' · ');
3246
+
3247
+ console.log(`${marker} ${index} ${name}`);
3248
+ if (meta) {
3249
+ console.log(` ${UI.slate(meta)}`);
3250
+ }
3251
+ }
3252
+
3253
+ if (endIdx < models.length) {
3254
+ console.log(UI.slate(' ↓ more models'));
3255
+ }
3256
+
3257
+ const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
3258
+ const quant = current.details?.quantization_level || current.details?.quantization || 'default';
3259
+ console.log();
3260
+ console.log(box(
3261
+ `${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
3262
+ `${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
3263
+ `${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
3264
+ `${keyValue('Profile', UI.ink(family), 10)}\n` +
3265
+ `${keyValue('Quant', UI.ink(quant), 10)}`,
3266
+ 'Preview', 'gray'
3267
+ ));
3268
+ };
3269
+
3270
+ return new Promise((resolve) => {
3271
+ render();
3272
+
3273
+ const cleanup = () => {
3274
+ process.stdin.removeListener('data', onKeypress);
3275
+ if (process.stdin.isTTY) {
3276
+ process.stdin.setRawMode(false);
3277
+ }
3278
+ };
3279
+
3280
+ const onKeypress = (chunk, key) => {
3281
+ if (!key) {
3282
+ const str = chunk.toString();
3283
+ if (str === '\x1b[A') key = { name: 'up' };
3284
+ else if (str === '\x1b[B') key = { name: 'down' };
3285
+ else if (str === '\r' || str === '\n') key = { name: 'return' };
3286
+ else if (str === '\x1b' || str === 'q') key = { name: 'escape' };
3287
+ else if (str === 'j') key = { name: 'down' };
3288
+ else if (str === 'k') key = { name: 'up' };
3289
+ else if (str === '\x03') key = { name: 'c', ctrl: true };
3290
+ }
3291
+
3292
+ if (!key) return;
3293
+
3294
+ if (key.name === 'up') {
3295
+ cursor = cursor > 0 ? cursor - 1 : models.length - 1;
3296
+ render();
3297
+ } else if (key.name === 'down') {
3298
+ cursor = cursor < models.length - 1 ? cursor + 1 : 0;
3299
+ render();
3300
+ } else if (key.name === 'return') {
3301
+ cleanup();
3302
+ console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3303
+ resolve(models[cursor].name);
3304
+ } else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
3305
+ cleanup();
3306
+ console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3307
+ resolve(models[cursor].name);
3308
+ }
3309
+ };
3310
+
3311
+ process.stdin.on('data', onKeypress);
3312
+ });
3313
+ }
3314
+
1831
3315
  const tools = {
1832
3316
  read: (path) => {
1833
- 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'); }
1834
3320
  catch (error) { return `Error reading file: ${error.message}`; }
1835
3321
  },
1836
3322
  patch: async (path, oldText, newText) => {
1837
- 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
+ }
1838
3328
  try {
1839
3329
  const content = fs.readFileSync(trimmedPath, 'utf8');
1840
3330
 
@@ -1846,22 +3336,19 @@ const tools = {
1846
3336
  if (lineNum < 1 || lineNum > lines.length) {
1847
3337
  return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
1848
3338
  }
1849
- const oldLine = lines[lineNum - 1];
1850
3339
  lines[lineNum - 1] = newText;
1851
3340
  const newContent = lines.join('\n');
1852
- console.log();
1853
- const diffContent =
1854
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)} ${chalk.gray(`(line ${lineNum})`)}\n` +
1855
- chalk.gray('─'.repeat(40)) + '\n' +
1856
- chalk.red('- ' + oldLine) + '\n' +
1857
- chalk.green('+ ' + newText);
1858
- console.log(box(diffContent, '🔧 Patch (line mode)', 'yellow'));
1859
- const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
1860
- if (confirm.toLowerCase() === 'y') {
1861
- fs.writeFileSync(trimmedPath, newContent);
1862
- return `Successfully patched line ${lineNum} of ${trimmedPath}`;
3341
+ if (newContent === content) {
3342
+ return `No changes needed in ${trimmedPath}`;
1863
3343
  }
1864
- 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
+ });
1865
3352
  }
1866
3353
 
1867
3354
  // --- Exact match (try as-is first, then trimmed) ---
@@ -1919,104 +3406,185 @@ const tools = {
1919
3406
  `Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
1920
3407
  }
1921
3408
  }
1922
-
1923
- // Show diff preview
1924
- console.log();
1925
- const diffContent =
1926
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
1927
- chalk.gray('─'.repeat(40)) + '\n' +
1928
- chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
1929
- chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
1930
- console.log(box(diffContent, '🔧 Patch', 'yellow'));
1931
-
1932
- const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
1933
- if (confirm.toLowerCase() === 'y') {
1934
- fs.writeFileSync(trimmedPath, newContent);
1935
- return `Successfully patched ${trimmedPath}`;
3409
+
3410
+ if (newContent === content) {
3411
+ return `No changes needed in ${trimmedPath}`;
1936
3412
  }
1937
- 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
+ });
1938
3421
  } catch (error) { return `Error patching file: ${error.message}`; }
1939
3422
  },
1940
3423
  write: async (path, content) => {
1941
- const trimmedPath = path.trim();
1942
- console.log();
1943
- console.log(box(
1944
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
1945
- `${chalk.white('Size:')} ${content?.length || 0} chars\n` +
1946
- chalk.gray('─'.repeat(40)) + '\n' +
1947
- chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
1948
- '✏️ Write File', 'yellow'
1949
- ));
1950
- const confirm = await safeQuestion(chalk.yellow('\n↪ Allow write? ') + chalk.gray('(y/n): '));
1951
- if (confirm.toLowerCase() === 'y') {
1952
- try {
1953
- fs.writeFileSync(trimmedPath, content);
1954
- return `Successfully saved changes to ${trimmedPath}`;
1955
- } catch (error) { return `Error writing file: ${error.message}`; }
1956
- }
1957
- 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}`; }
1958
3443
  },
1959
3444
  mkdir: (path) => {
3445
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
3446
+ if (!trimmedPath) return 'Error creating directory: missing directory path';
1960
3447
  try {
1961
- fs.mkdirSync(path.trim(), { recursive: true });
1962
- return `Directory created: ${path}`;
3448
+ fs.mkdirSync(trimmedPath, { recursive: true });
3449
+ return `Directory created: ${trimmedPath}`;
1963
3450
  } catch (error) { return `Error creating directory: ${error.message}`; }
1964
3451
  },
1965
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);
1966
3462
  console.log();
1967
3463
  console.log(box(
1968
- chalk.white.bold(cmd),
1969
- '🔐 Shell Command', 'red'
3464
+ `${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
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.')}`,
3468
+ 'Shell Approval', 'red'
1970
3469
  ));
1971
- const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
1972
- if (confirm.toLowerCase() === 'y') {
1973
- return new Promise((resolve) => {
1974
- const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
1975
- console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
1976
- const proc = spawn('sh', ['-c', cmd], {
1977
- cwd: process.cwd()
1978
- });
1979
- let output = '';
1980
- proc.stdout.on('data', (data) => {
1981
- const text = data.toString();
1982
- output += text;
1983
- process.stdout.write(text); // Still show to user in real-time
1984
- });
1985
- proc.stderr.on('data', (data) => {
1986
- const text = data.toString();
1987
- output += text;
1988
- process.stderr.write(text); // Still show errors to user
1989
- });
1990
- proc.on('close', (code) => {
1991
- // Crucial: give control back to Node
1992
- if (process.stdin.isTTY) {
1993
- 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);
1994
3508
  }
1995
- // Delay slightly to let terminal settle
1996
- setTimeout(() => {
1997
- recreateReadline();
1998
- // Return actual output to AI, truncated if too long
1999
- const maxOutput = 10000;
2000
- let result = output.trim();
2001
- if (result.length > maxOutput) {
2002
- 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;
3537
+ }
3538
+
3539
+ if (process.stdin.isTTY) {
3540
+ try { process.stdin.setRawMode(false); } catch (e) {}
2003
3541
  }
2004
- resolve(result || `Command completed with exit code ${code}`);
2005
- }, 200);
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
+ });
2006
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: ',
2007
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.`;
2008
3576
  }
2009
- return "Command blocked by user.";
2010
3577
  },
2011
3578
  list: (path) => {
2012
3579
  try {
2013
- let dir = path.trim() || '.';
3580
+ let dir = typeof path === 'string' ? path.trim() : '';
3581
+ if (!dir) dir = '.';
2014
3582
  // If AI sends "/" (root), treat as current directory "."
2015
3583
  if (dir === '/') dir = '.';
2016
3584
  const entries = fs.readdirSync(dir);
2017
- // Filter out ignored directories
3585
+ // Filter out ignored files/directories (respects .sapperignore)
2018
3586
  const filtered = entries.filter(entry => {
2019
- if (IGNORE_DIRS.has(entry)) return false;
3587
+ if (shouldIgnore(entry)) return false;
2020
3588
  // Also skip hidden files/folders (starting with .) except current dir
2021
3589
  if (entry.startsWith('.') && entry !== '.') return false;
2022
3590
  return true;
@@ -2026,7 +3594,12 @@ const tools = {
2026
3594
  },
2027
3595
  search: (pattern) => {
2028
3596
  return new Promise((resolve) => {
2029
- const excludeDirs = Array.from(IGNORE_DIRS).join(',');
3597
+ // Build exclude dirs from IGNORE_DIRS + .sapperignore directory patterns
3598
+ const allIgnoreDirs = new Set(IGNORE_DIRS);
3599
+ for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
3600
+ if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
3601
+ }
3602
+ const excludeDirs = Array.from(allIgnoreDirs).join(',');
2030
3603
  // Use grep to search for pattern, excluding ignored directories
2031
3604
  const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludeDirs}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
2032
3605
 
@@ -2054,10 +3627,8 @@ async function checkForUpdates() {
2054
3627
  const latestVersion = data.version;
2055
3628
 
2056
3629
  if (latestVersion && latestVersion !== CURRENT_VERSION) {
2057
- console.log(chalk.yellow('🔄 UPDATE AVAILABLE!'));
2058
- console.log(chalk.gray(` Current: v${CURRENT_VERSION}`));
2059
- console.log(chalk.green(` Latest: v${latestVersion}`));
2060
- console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
3630
+ console.log(UI.gold(`Update available: v${CURRENT_VERSION} -> v${latestVersion}`));
3631
+ console.log(UI.slate('Run npm update -g sapper-iq\n'));
2061
3632
  }
2062
3633
  } catch (error) {
2063
3634
  // Silently fail if update check fails
@@ -2067,23 +3638,31 @@ async function checkForUpdates() {
2067
3638
  async function runSapper() {
2068
3639
  console.clear();
2069
3640
  console.log(BANNER);
2070
- console.log(chalk.gray.dim(' ') + chalk.white.bold(`v${CURRENT_VERSION}`) + chalk.gray(' │ ') + chalk.cyan('Autonomous AI Coding Agent'));
2071
- console.log(chalk.gray.dim(' ') + chalk.gray('📁 ') + chalk.white(process.cwd()));
2072
- console.log();
2073
-
2074
- // Quick tips box
2075
- console.log(box(
2076
- `${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
2077
- `${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
2078
- `${chalk.yellow('💡')} Type ${chalk.cyan('/agents')} to see agents, ${chalk.cyan('/agentname')} to switch\n` +
2079
- `${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
2080
- 'Quick Tips', 'gray'
2081
- ));
3641
+ console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
3642
+ console.log(divider());
3643
+ console.log(sectionTitle('Quick start', '@file attach · /help commands · /agents modes', 'gray'));
2082
3644
  console.log();
2083
3645
 
2084
3646
  // Check for updates
2085
3647
  await checkForUpdates();
2086
3648
 
3649
+ // Ensure .sapperignore exists (create default on first run)
3650
+ const sapperIgnoreCreated = ensureSapperIgnore();
3651
+ if (sapperIgnoreCreated) {
3652
+ console.log(chalk.green('📋 Created .sapperignore') + chalk.gray(' — edit it to customize ignored files'));
3653
+ } else {
3654
+ // Reload patterns in case file was modified since last run
3655
+ reloadSapperIgnore();
3656
+ }
3657
+
3658
+ // Ensure config file exists with defaults, or reload user's config
3659
+ if (!fs.existsSync(CONFIG_FILE)) {
3660
+ saveConfig(sapperConfig);
3661
+ } else {
3662
+ // Reload in case user edited config.json manually
3663
+ sapperConfig = loadConfig();
3664
+ }
3665
+
2087
3666
  // Auto-load or build workspace graph
2088
3667
  let workspace = loadWorkspaceGraph();
2089
3668
  if (!workspace.indexed) {
@@ -2101,30 +3680,39 @@ async function runSapper() {
2101
3680
  }
2102
3681
  }
2103
3682
 
2104
- // Show memory status
2105
- console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
2106
- console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)`));
2107
-
2108
3683
  // Initialize agents and skills
2109
3684
  const newlyCreated = createDefaultAgentsAndSkills();
2110
3685
  const agents = loadAgents();
2111
3686
  const skills = loadSkills();
2112
3687
  const agentCount = Object.keys(agents).length;
2113
3688
  const skillCount = Object.keys(skills).length;
2114
- console.log(chalk.gray(`🤖 Agents: ${agentCount} available`) + chalk.gray(` │ `) + chalk.gray(`📘 Skills: ${skillCount} available`));
3689
+ const workspaceFileCount = Object.keys(workspace.files).length;
3690
+ const workspaceSymbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
3691
+ const workspaceAgeMinutes = workspace.indexed
3692
+ ? Math.max(0, Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60))
3693
+ : 0;
3694
+ const startupLines = [
3695
+ `${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
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()}%`)}`,
3703
+ `${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
3704
+ ];
2115
3705
  if (newlyCreated > 0) {
2116
- console.log(chalk.green(` ✨ Created ${newlyCreated} default agents/skills in .sapper/`));
2117
- }
2118
- if (agentCount > 0) {
2119
- console.log(chalk.gray(` Agents: ${Object.keys(agents).map(a => '/' + a).join(', ')}`));
3706
+ startupLines.push(UI.slate(`${newlyCreated} default agents or skills created in .sapper/`));
2120
3707
  }
3708
+ console.log(box(startupLines.join('\n'), 'Workspace', 'gray'));
2121
3709
  console.log();
2122
3710
 
2123
3711
  let messages = [];
2124
3712
  if (fs.existsSync(CONTEXT_FILE)) {
2125
- console.log();
2126
- console.log(box('Previous session found! Resume where you left off?', '📂 Session', 'green'));
2127
- const resume = await safeQuestion(chalk.green('\n↪ Resume? ') + chalk.gray('(y/n): '));
3713
+ console.log(divider());
3714
+ console.log(UI.ink('Previous session found in .sapper/context.json'));
3715
+ const resume = await safeQuestion(confirmPrompt('Resume session', 'success'));
2128
3716
  if (resume.toLowerCase() === 'y') {
2129
3717
  messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
2130
3718
  console.log(chalk.green(' ✓ Session restored\n'));
@@ -2165,30 +3753,58 @@ async function runSapper() {
2165
3753
  process.exit(1);
2166
3754
  }
2167
3755
 
2168
- console.log(divider());
2169
- console.log(statusBadge('MODELS', 'info') + chalk.gray(' Available Ollama models:\n'));
2170
- localModels.models.forEach((m, i) => {
2171
- const num = chalk.cyan.bold(`[${i + 1}]`);
2172
- const name = chalk.white(m.name);
2173
- console.log(` ${num} ${name}`);
2174
- });
2175
- console.log(divider());
2176
- const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
2177
- const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
3756
+ const selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
2178
3757
 
2179
- // ─── Detect native tool-calling support ───────────────────────────
3758
+ // ─── Detect model capabilities & context window ───────────────────
2180
3759
  let useNativeTools = false;
3760
+ let toolModeLabel = 'tool detection unavailable';
3761
+ let contextLabel = '4,096 tokens (fallback)';
2181
3762
  try {
2182
3763
  const modelInfo = await ollama.show({ model: selectedModel });
2183
3764
  if (modelInfo.capabilities && modelInfo.capabilities.includes('tools')) {
2184
3765
  useNativeTools = true;
2185
- console.log(chalk.green(' ✓ ') + chalk.gray('Native tool calling: ') + chalk.green('enabled'));
3766
+ toolModeLabel = 'native tool calling';
2186
3767
  } else {
2187
- console.log(chalk.yellow(' ℹ ') + chalk.gray('Native tool calling: ') + chalk.yellow('unavailable — using text markers'));
3768
+ toolModeLabel = 'text markers';
3769
+ }
3770
+ // Extract context window size from model_info
3771
+ // Different model families use different keys: llama.context_length, qwen2.context_length, etc.
3772
+ if (modelInfo.model_info) {
3773
+ for (const [key, value] of Object.entries(modelInfo.model_info)) {
3774
+ if (key.endsWith('.context_length') && typeof value === 'number') {
3775
+ modelContextLength = value;
3776
+ break;
3777
+ }
3778
+ }
3779
+ }
3780
+ // Fallback: parse from parameters string (e.g. "num_ctx 4096")
3781
+ if (!modelContextLength && modelInfo.parameters) {
3782
+ const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
3783
+ if (match) modelContextLength = parseInt(match[1]);
3784
+ }
3785
+ if (modelContextLength) {
3786
+ contextLabel = `${modelContextLength.toLocaleString()} tokens`;
3787
+ } else {
3788
+ modelContextLength = 4096; // Conservative default
3789
+ contextLabel = '4,096 tokens (default)';
2188
3790
  }
2189
3791
  } catch (e) {
2190
- console.log(chalk.gray(' ℹ Tool detection skipped — using text markers'));
3792
+ modelContextLength = 4096;
3793
+ toolModeLabel = 'default mode';
3794
+ contextLabel = '4,096 tokens (fallback)';
3795
+ }
3796
+ // Show custom limit if set
3797
+ const effectiveCtx = effectiveContextLength();
3798
+ if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
3799
+ contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
2191
3800
  }
3801
+ console.log(box(
3802
+ `${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
3803
+ `${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
3804
+ `${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
3805
+ 'Session', 'cyan'
3806
+ ));
3807
+ console.log();
2192
3808
  _useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
2193
3809
 
2194
3810
  // Native Ollama tool definitions (used when useNativeTools=true)
@@ -2197,13 +3813,12 @@ async function runSapper() {
2197
3813
  type: 'function',
2198
3814
  function: {
2199
3815
  name: 'list_directory',
2200
- 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 ".".',
2201
3817
  parameters: {
2202
3818
  type: 'object',
2203
3819
  properties: {
2204
3820
  path: { type: 'string', description: 'Directory path to list' }
2205
- },
2206
- required: ['path']
3821
+ }
2207
3822
  }
2208
3823
  }
2209
3824
  },
@@ -2284,7 +3899,7 @@ async function runSapper() {
2284
3899
  type: 'function',
2285
3900
  function: {
2286
3901
  name: 'run_shell',
2287
- 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>.',
2288
3903
  parameters: {
2289
3904
  type: 'object',
2290
3905
  properties: {
@@ -2313,22 +3928,55 @@ async function runSapper() {
2313
3928
  // Main conversation loop - never exits unless user types 'exit'
2314
3929
  while (true) {
2315
3930
  try {
2316
- // Context size check - auto-summarize when too large
2317
- const contextSize = JSON.stringify(messages).length;
2318
- if (contextSize > 32000) {
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
+
3942
+ // Context size check - auto-summarize when approaching effective context limit
3943
+ let estimatedTokens = estimateMessagesTokens(messages);
3944
+ const ctxLen = effectiveContextLength();
3945
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
3946
+ if (estimatedTokens > tokenThreshold) {
2319
3947
  messages = await autoSummarizeContext(messages, selectedModel);
3948
+ estimatedTokens = estimateMessagesTokens(messages);
2320
3949
  }
2321
3950
 
2322
3951
  // Build prompt label with active agent/skills
2323
- let promptLabel = chalk.white.bold('You');
2324
- if (currentAgent) {
2325
- promptLabel += chalk.gray('') + chalk.magenta.bold(currentAgent);
2326
- }
3952
+ const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
3953
+ const promptParts = [
3954
+ statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
3955
+ currentAgent ? statusBadge(`/${currentAgent}`, 'info') : statusBadge('default', 'neutral'),
3956
+ ];
2327
3957
  if (loadedSkills.length > 0) {
2328
- promptLabel += chalk.gray(' [') + chalk.blue(loadedSkills.join(', ')) + chalk.gray(']');
3958
+ promptParts.push(statusBadge(`${loadedSkills.length} skill${loadedSkills.length !== 1 ? 's' : ''}`, 'success'));
2329
3959
  }
3960
+ if (contextPercent !== null) {
3961
+ const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
3962
+ promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
3963
+ }
3964
+
3965
+ const promptDetail = ctxLen
3966
+ ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
3967
+ : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
3968
+
3969
+ const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
3970
+ const input = await safeQuestion(promptText);
3971
+ clearPromptEcho(promptText, input);
2330
3972
 
2331
- const input = await safeQuestion(chalk.cyan('\n┌─[') + promptLabel + chalk.cyan(']\n└─➤ '));
3973
+ // Block empty prompts
3974
+ if (!input.trim()) {
3975
+ continue;
3976
+ }
3977
+
3978
+ const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
3979
+ console.log(UI.accent('› ') + chalk.white(preview));
2332
3980
 
2333
3981
  if (input.toLowerCase() === 'exit') {
2334
3982
  const stats = getSessionStats();
@@ -2369,42 +4017,57 @@ async function runSapper() {
2369
4017
  continue;
2370
4018
  }
2371
4019
 
2372
- messages = await autoSummarizeContext(messages, selectedModel);
4020
+ messages = await autoSummarizeContext(messages, selectedModel, true);
2373
4021
  continue;
2374
4022
  }
2375
4023
 
2376
4024
  // Handle help command
2377
4025
  if (input.toLowerCase() === '/help') {
2378
4026
  console.log();
2379
- const helpContent =
2380
- `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
2381
- `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
2382
- `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
2383
- `${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
2384
- `${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
2385
- `${chalk.cyan('/symbol name')} ${chalk.gray('│')} Search functions/classes\n` +
2386
- `${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
2387
- `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
2388
- `${chalk.cyan('/reset /clear')} ${chalk.gray('')} Clear all context\n` +
2389
- `${chalk.cyan('/prune')} ${chalk.gray('│')} AI-summarize context + save to memory\n` +
2390
- `${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
2391
- `${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
2392
- `${chalk.cyan('/log')} ${chalk.gray('│')} Show activity timeline\n` +
2393
- `${chalk.cyan('/log stats')} ${chalk.gray('│')} Show session statistics\n` +
2394
- `${chalk.cyan('/log file')} ${chalk.gray('│')} Show log file path & history\n` +
2395
- `${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
2396
- `${chalk.cyan('exit')} ${chalk.gray('')} Quit Sapper\n` +
2397
- `\n` +
2398
- chalk.bold.white('🤖 Agents & Skills:\n') +
2399
- `${chalk.cyan('/agents')} ${chalk.gray('│')} List available agents\n` +
2400
- `${chalk.cyan('/skills')} ${chalk.gray('│')} List available skills\n` +
2401
- `${chalk.cyan('/agentname')} ${chalk.gray('│')} Switch to agent (e.g., /salesmanager)\n` +
2402
- `${chalk.cyan('/default')} ${chalk.gray('│')} Switch back to default Sapper\n` +
2403
- `${chalk.cyan('/use skill')} ${chalk.gray('│')} Load a skill (e.g., /use react)\n` +
2404
- `${chalk.cyan('/unload skill')} ${chalk.gray('│')} Unload a skill\n` +
2405
- `${chalk.cyan('/newagent')} ${chalk.gray('│')} Create a new agent\n` +
2406
- `${chalk.cyan('/newskill')} ${chalk.gray('│')} Create a new skill`;
2407
- console.log(box(helpContent, '📚 Commands', 'cyan'));
4027
+ console.log(sectionTitle('Core', 'daily workflow', 'cyan'));
4028
+ console.log(commandRow('@ or /attach', 'Pick files to attach interactively'));
4029
+ console.log(commandRow('@file', 'Attach a file inline, for example @src/app.js'));
4030
+ console.log(commandRow('/scan', 'Scan the codebase into context'));
4031
+ console.log(commandRow('/index', 'Rebuild the workspace graph'));
4032
+ console.log(commandRow('/graph file', 'Show related files from the graph'));
4033
+ console.log(commandRow('/symbol name', 'Search indexed functions and classes'));
4034
+ console.log(commandRow('/auto', 'Toggle automatic related-file attach'));
4035
+ console.log();
4036
+ console.log(sectionTitle('Context', 'memory and visibility', 'cyan'));
4037
+ console.log(commandRow('/recall', 'Search memory for relevant context'));
4038
+ console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
4039
+ console.log(commandRow('/reset /clear', 'Clear all current context'));
4040
+ console.log(commandRow('/prune', 'Summarize long context and store memory'));
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'));
4046
+ console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
4047
+ console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
4048
+ console.log(commandRow('/log', 'Show the session activity timeline'));
4049
+ console.log(commandRow('/log stats', 'Show session statistics'));
4050
+ console.log(commandRow('/log file', 'Show log file path and history'));
4051
+ console.log(commandRow('/help', 'Open this command view again'));
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'));
4060
+ console.log();
4061
+ console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
4062
+ console.log(commandRow('/agents', 'List available agents'));
4063
+ console.log(commandRow('/skills', 'List available skills'));
4064
+ console.log(commandRow('/agentname', 'Switch to an agent such as /reviewer'));
4065
+ console.log(commandRow('/default', 'Return to the default Sapper role'));
4066
+ console.log(commandRow('/use skill', 'Load a skill into the session'));
4067
+ console.log(commandRow('/unload skill', 'Unload a previously loaded skill'));
4068
+ console.log(commandRow('/newagent', 'Create a new agent'));
4069
+ console.log(commandRow('/newskill', 'Create a new skill'));
4070
+ console.log(divider());
2408
4071
  console.log();
2409
4072
  continue;
2410
4073
  }
@@ -2572,12 +4235,197 @@ async function runSapper() {
2572
4235
  }
2573
4236
 
2574
4237
  // Handle context size command
4238
+ // Handle /ctx command — view or set context window limit
4239
+ if (input.toLowerCase().startsWith('/ctx')) {
4240
+ const arg = input.substring(4).trim();
4241
+ if (arg === 'reset' || arg === 'auto') {
4242
+ sapperConfig.contextLimit = null;
4243
+ saveConfig(sapperConfig);
4244
+ console.log(chalk.green(`✅ Context limit reset to model default (${modelContextLength ? modelContextLength.toLocaleString() : 'auto'} tokens)`));
4245
+ } else if (arg) {
4246
+ // Parse number with optional k/K suffix (e.g. 64k, 32768)
4247
+ let limit = null;
4248
+ const kMatch = arg.match(/^(\d+\.?\d*)\s*[kK]$/);
4249
+ if (kMatch) {
4250
+ limit = Math.round(parseFloat(kMatch[1]) * 1024);
4251
+ } else {
4252
+ limit = parseInt(arg);
4253
+ }
4254
+ if (!limit || limit < 1024) {
4255
+ console.log(chalk.yellow('Usage: /ctx <tokens> — e.g. /ctx 64k, /ctx 32768, /ctx reset'));
4256
+ console.log(chalk.gray(' Minimum: 1024 tokens'));
4257
+ } else {
4258
+ sapperConfig.contextLimit = limit;
4259
+ saveConfig(sapperConfig);
4260
+ const effective = effectiveContextLength();
4261
+ console.log(chalk.green(`✅ Context limit set to ${chalk.white.bold(effective.toLocaleString())} tokens`));
4262
+ if (modelContextLength && limit < modelContextLength) {
4263
+ console.log(chalk.gray(` Model supports ${modelContextLength.toLocaleString()} but will use ${limit.toLocaleString()} (saves RAM)`));
4264
+ } else if (modelContextLength && limit > modelContextLength) {
4265
+ console.log(chalk.yellow(` ⚠ Limit exceeds model's ${modelContextLength.toLocaleString()} context — may cause errors`));
4266
+ }
4267
+ }
4268
+ } else {
4269
+ // Show current setting
4270
+ const effective = effectiveContextLength();
4271
+ const custom = sapperConfig.contextLimit;
4272
+ const lines = [
4273
+ `model default ${chalk.white(modelContextLength ? modelContextLength.toLocaleString() : 'unknown')} tokens`,
4274
+ `custom limit ${custom ? chalk.cyan.bold(custom.toLocaleString() + ' tokens') : UI.slate('not set (using model default)')}`,
4275
+ `effective ${chalk.white.bold(effective ? effective.toLocaleString() + ' tokens' : 'unknown')}`,
4276
+ ];
4277
+ console.log();
4278
+ console.log(box(lines.join('\n'), 'Context Limit', 'cyan'));
4279
+ console.log(UI.slate(' Set: /ctx 64k | /ctx 32768 | /ctx reset'));
4280
+ }
4281
+ continue;
4282
+ }
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
+
2575
4368
  if (input.toLowerCase() === '/context') {
2576
4369
  const contextSize = JSON.stringify(messages).length;
2577
- console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
2578
- if (contextSize > 50000) {
2579
- console.log(chalk.yellow('⚠️ Context is large! Will auto-summarize on next message, or use /prune now.'));
4370
+ const estTokens = estimateMessagesTokens(messages);
4371
+ const ctxLen = effectiveContextLength();
4372
+ const triggerPercent = summaryTriggerPercent();
4373
+ const promptConfig = getPromptConfig();
4374
+ const contextLines = [
4375
+ `messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
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`)}`);
4382
+ if (ctxLen) {
4383
+ const usagePercent = Math.round((estTokens / ctxLen) * 100);
4384
+ const threshold = summaryTokenThreshold(ctxLen);
4385
+ const limitLabel = sapperConfig.contextLimit
4386
+ ? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
4387
+ : `${ctxLen.toLocaleString()} tokens`;
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')}`);
4390
+ contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
4391
+ }
4392
+ if (lastPromptTokens > 0) {
4393
+ contextLines.push(`last turn ${UI.slate(`${lastPromptTokens.toLocaleString()} prompt • ${lastEvalTokens.toLocaleString()} response`)}`);
4394
+ }
4395
+ console.log();
4396
+ console.log(box(contextLines.join('\n'), 'Context', 'gray'));
4397
+ continue;
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;
2580
4426
  }
4427
+
4428
+ console.log(chalk.yellow('Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
2581
4429
  continue;
2582
4430
  }
2583
4431
 
@@ -2917,12 +4765,15 @@ async function runSapper() {
2917
4765
  messages[0] = { role: 'system', content: buildSystemPrompt(agent.content, skillContents) };
2918
4766
 
2919
4767
  console.log();
2920
- console.log(statusBadge(`AGENT: ${agent.description}`, 'action'));
2921
- const toolsInfo = agent.tools ? chalk.gray(` [tools: ${agent.tools.join(', ')}]`) : chalk.gray(' [all tools]');
2922
- console.log(chalk.green(`Switched to /${cmdPart} agent`) + toolsInfo);
4768
+ console.log(box(
4769
+ `${statusBadge('Active Agent', 'action')} ${chalk.white('/' + cmdPart)}\n` +
4770
+ `${keyValue('Role', chalk.white(agent.description), 8)}\n` +
4771
+ `${keyValue('Tools', agent.tools ? UI.slate(agent.tools.join(', ')) : UI.slate('all tools'), 8)}`,
4772
+ 'Agent Mode', 'magenta'
4773
+ ));
2923
4774
 
2924
4775
  if (!prompt) {
2925
- console.log(chalk.gray(`Type your prompt to chat with this agent.`));
4776
+ console.log(UI.slate('Type your prompt to chat with this agent.'));
2926
4777
  continue; // Just switched, no prompt to send
2927
4778
  }
2928
4779
 
@@ -2934,6 +4785,46 @@ async function runSapper() {
2934
4785
  }
2935
4786
  }
2936
4787
 
4788
+ // Handle /fetch command - fetch a URL and add to context
4789
+ if (input.toLowerCase().startsWith('/fetch')) {
4790
+ const url = input.slice(6).trim();
4791
+ if (!url || !url.match(/^https?:\/\//)) {
4792
+ console.log(chalk.yellow('Usage: /fetch <url>'));
4793
+ console.log(chalk.gray(' Example: /fetch https://docs.example.com/api'));
4794
+ continue;
4795
+ }
4796
+ try {
4797
+ const fetchSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
4798
+ const rawContent = await fetchUrl(url);
4799
+ fetchSpinner.stop();
4800
+
4801
+ const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
4802
+ const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
4803
+ let text;
4804
+ if (isJson) {
4805
+ try { text = JSON.stringify(JSON.parse(rawContent), null, 2); } catch { text = rawContent; }
4806
+ } else if (isHtml) {
4807
+ text = htmlToText(rawContent);
4808
+ } else {
4809
+ text = rawContent;
4810
+ }
4811
+
4812
+ if (text.trim().length > 0) {
4813
+ const webContent = `\n\n══════════════════════════════════════\n🌐 WEB PAGE CONTENT\n══════════════════════════════════════\n\nURL: ${url}\n\n${text}\n`;
4814
+ messages.push({ role: 'user', content: `I fetched this web page for reference:\n${webContent}\n\nUse this information to help me.` });
4815
+ ensureSapperDir();
4816
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
4817
+ console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
4818
+ console.log(chalk.gray('📝 Added to context. AI can now reference this page.\n'));
4819
+ } else {
4820
+ console.log(chalk.yellow('⚠️ No readable content found on that page.'));
4821
+ }
4822
+ } catch (e) {
4823
+ console.log(chalk.yellow(`⚠️ Could not fetch: ${e.message}`));
4824
+ }
4825
+ continue;
4826
+ }
4827
+
2937
4828
  // Handle recall command - search embeddings
2938
4829
  if (input.toLowerCase().startsWith('/recall')) {
2939
4830
  const query = input.slice(7).trim();
@@ -3022,14 +4913,24 @@ async function runSapper() {
3022
4913
  const fileAttachments = [];
3023
4914
  for (const filePath of selectedFiles) {
3024
4915
  try {
4916
+ // Check .sapperignore
4917
+ if (shouldIgnore(filePath)) {
4918
+ console.log(chalk.yellow(`⚠️ ${filePath} is in .sapperignore — skipped`));
4919
+ continue;
4920
+ }
3025
4921
  const stats = fs.statSync(filePath);
3026
4922
  if (stats.size > MAX_FILE_SIZE) {
3027
- console.log(chalk.yellow(`⚠️ ${filePath} is too large, skipping`));
4923
+ console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
4924
+ console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
4925
+ console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
4926
+ console.log(chalk.yellow(` File: ${filePath}`));
4927
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(MAX_FILE_SIZE/1024)}KB)`));
4928
+ console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3028
4929
  continue;
3029
4930
  }
3030
4931
  const content = fs.readFileSync(filePath, 'utf8');
3031
4932
  fileAttachments.push({ path: filePath, content, size: stats.size });
3032
- console.log(chalk.green(`📎 Attached: ${filePath}`));
4933
+ console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
3033
4934
  } catch (e) {
3034
4935
  console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
3035
4936
  }
@@ -3071,10 +4972,19 @@ async function runSapper() {
3071
4972
  const filePath = attachMatch[1];
3072
4973
  try {
3073
4974
  if (fs.existsSync(filePath)) {
4975
+ // Check .sapperignore
4976
+ if (shouldIgnore(filePath)) {
4977
+ console.log(chalk.yellow(`⚠️ @${filePath} is in .sapperignore — skipped`));
4978
+ continue;
4979
+ }
3074
4980
  const stats = fs.statSync(filePath);
3075
4981
  if (stats.isFile()) {
3076
4982
  if (stats.size > MAX_FILE_SIZE) {
3077
- console.log(chalk.yellow(`⚠️ @${filePath} is too large (${Math.round(stats.size/1024)}KB), skipping`));
4983
+ console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
4984
+ console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
4985
+ console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
4986
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(MAX_FILE_SIZE/1024)}KB limit`));
4987
+ console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3078
4988
  } else {
3079
4989
  const content = fs.readFileSync(filePath, 'utf8');
3080
4990
  fileAttachments.push({ path: filePath, content, size: stats.size });
@@ -3122,6 +5032,60 @@ async function runSapper() {
3122
5032
  processedInput = input + attachedContent;
3123
5033
  }
3124
5034
 
5035
+ // ── Detect and fetch URLs in the message ──
5036
+ const urlMatches = input.match(URL_REGEX);
5037
+ if (urlMatches && urlMatches.length > 0) {
5038
+ const uniqueUrls = [...new Set(urlMatches)].slice(0, 5); // Max 5 URLs
5039
+ const urlContents = [];
5040
+
5041
+ for (const url of uniqueUrls) {
5042
+ try {
5043
+ const urlSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
5044
+ const rawContent = await fetchUrl(url);
5045
+ urlSpinner.stop();
5046
+
5047
+ // Detect content type
5048
+ const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
5049
+ const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
5050
+
5051
+ let text;
5052
+ if (isJson) {
5053
+ // Pretty-print JSON
5054
+ try { text = JSON.stringify(JSON.parse(rawContent), null, 2); }
5055
+ catch { text = rawContent; }
5056
+ } else if (isHtml) {
5057
+ text = htmlToText(rawContent);
5058
+ } else {
5059
+ text = rawContent; // Plain text, markdown, etc.
5060
+ }
5061
+
5062
+ if (text.trim().length > 0) {
5063
+ urlContents.push({ url, content: text, size: text.length });
5064
+ console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
5065
+ } else {
5066
+ console.log(chalk.yellow(`⚠️ ${url} — no readable content`));
5067
+ }
5068
+ } catch (e) {
5069
+ console.log(chalk.yellow(`⚠️ Could not fetch ${url}: ${e.message}`));
5070
+ }
5071
+ }
5072
+
5073
+ if (urlContents.length > 0) {
5074
+ let urlAttached = '\n\n══════════════════════════════════════\n';
5075
+ urlAttached += `🌐 FETCHED WEB PAGES (${urlContents.length})\n`;
5076
+ urlAttached += '══════════════════════════════════════\n\n';
5077
+
5078
+ for (const page of urlContents) {
5079
+ urlAttached += `┌─── ${page.url} ───\n`;
5080
+ urlAttached += page.content;
5081
+ if (!page.content.endsWith('\n')) urlAttached += '\n';
5082
+ urlAttached += `└─── END ${page.url} ───\n\n`;
5083
+ }
5084
+
5085
+ processedInput = processedInput + urlAttached;
5086
+ }
5087
+ }
5088
+
3125
5089
  messages.push({ role: 'user', content: processedInput });
3126
5090
 
3127
5091
  // Log user input
@@ -3134,9 +5098,10 @@ async function runSapper() {
3134
5098
  } // End of if (!agentHandled)
3135
5099
 
3136
5100
  let toolRounds = 0; // Prevent infinite loops
3137
- const MAX_TOOL_ROUNDS = 20;
5101
+ const MAX_TOOL_ROUNDS = toolRoundLimit();
3138
5102
  const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
3139
5103
  const MAX_PATCH_RETRIES = 3;
5104
+ const turnThinkingEnabled = shouldUseThinkingForInput(input);
3140
5105
 
3141
5106
  let active = true;
3142
5107
  while (active) {
@@ -3148,6 +5113,11 @@ async function runSapper() {
3148
5113
  try {
3149
5114
  // Build chat options — pass native tools when supported
3150
5115
  const chatOpts = { model: selectedModel, messages, stream: true };
5116
+ if (effectiveContextLength()) {
5117
+ chatOpts.options = { num_ctx: effectiveContextLength() };
5118
+ }
5119
+ // Thinking can be forced on, forced off, or auto-disabled for simple prompts.
5120
+ chatOpts.think = turnThinkingEnabled;
3151
5121
  if (useNativeTools) {
3152
5122
  // Filter tool defs by agent restrictions if any
3153
5123
  if (currentAgentTools) {
@@ -3173,6 +5143,7 @@ async function runSapper() {
3173
5143
  spinner.stop();
3174
5144
 
3175
5145
  let msg = '';
5146
+ let thinkMsg = ''; // Thinking/reasoning content from thinking models
3176
5147
  const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
3177
5148
  let lastChunkTime = Date.now();
3178
5149
  let repetitionCount = 0;
@@ -3181,23 +5152,88 @@ async function runSapper() {
3181
5152
  let wasRepetitionStopped = false;
3182
5153
  let nativeToolCalls = []; // Collect native tool_calls from streaming chunks
3183
5154
  abortStream = false; // Reset abort flag before streaming
5155
+ let chunkPromptTokens = 0; // Track actual tokens from Ollama
5156
+ let chunkEvalTokens = 0;
5157
+ let isThinking = false; // Track if we're currently in thinking mode
5158
+ let thinkingContinuationNeedsPrefix = false;
5159
+ let lastThinkingIdleNoticeAt = 0;
5160
+ const genStartTime = Date.now(); // Track generation elapsed time
5161
+ let genTokenCount = 0; // Count response tokens as they stream
5162
+ let lastVisibleActivityAt = Date.now();
5163
+ let heartbeatInterval = null;
3184
5164
 
3185
- console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
3186
- process.stdout.write(chalk.magenta('│ '));
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
+ }
3187
5189
  for await (const chunk of response) {
3188
5190
  // Check if user pressed Ctrl+C
3189
5191
  if (abortStream) {
3190
- console.log(chalk.yellow('\n[Response interrupted]'));
5192
+ console.log(UI.slate('\n[response interrupted]'));
3191
5193
  wasInterrupted = true;
3192
5194
  break;
3193
5195
  }
3194
5196
 
5197
+ // Handle thinking/reasoning content (deepseek-r1, qwq, etc.)
5198
+ const thinking = chunk.message.thinking;
5199
+ if (thinking) {
5200
+ if (!isThinking) {
5201
+ isThinking = true;
5202
+ process.stdout.write(`\n${UI.slate.italic(' ◇ Thinking')}\n${UI.slate(' │ ')}`);
5203
+ }
5204
+ // Live-stream thinking — dim italic, wrap at line breaks
5205
+ const lines = thinking.split('\n');
5206
+ for (let li = 0; li < lines.length; li++) {
5207
+ if (li > 0 || thinkingContinuationNeedsPrefix) process.stdout.write(`\n${UI.slate(' │ ')}`);
5208
+ thinkingContinuationNeedsPrefix = false;
5209
+ process.stdout.write(UI.slate.italic(lines[li]));
5210
+ }
5211
+ thinkMsg += thinking;
5212
+ lastVisibleActivityAt = Date.now();
5213
+ lastThinkingIdleNoticeAt = 0;
5214
+ }
5215
+
3195
5216
  const content = chunk.message.content;
3196
5217
  if (content) {
3197
- process.stdout.write(content);
5218
+ if (isThinking) {
5219
+ isThinking = false;
5220
+ process.stdout.write(`\n${UI.slate(' └─')}\n\n`);
5221
+ }
3198
5222
  msg += content;
5223
+ genTokenCount++;
5224
+ lastVisibleActivityAt = Date.now();
5225
+ renderStreamingHeartbeat({
5226
+ genTokenCount,
5227
+ genStartTime,
5228
+ lastVisibleActivityAt,
5229
+ stage: 'generating',
5230
+ });
3199
5231
  }
3200
5232
 
5233
+ // Capture token stats from the final chunk (done: true)
5234
+ if (chunk.prompt_eval_count) chunkPromptTokens = chunk.prompt_eval_count;
5235
+ if (chunk.eval_count) chunkEvalTokens = chunk.eval_count;
5236
+
3201
5237
  // Collect native tool_calls (arrive in chunks, usually the final one)
3202
5238
  if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
3203
5239
  nativeToolCalls.push(...chunk.message.tool_calls);
@@ -3227,32 +5263,58 @@ async function runSapper() {
3227
5263
  // Don't break - just warn. User can Ctrl+C if needed
3228
5264
  }
3229
5265
  }
3230
- console.log(chalk.magenta('└─────────────────────────────────────'));
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
+ }
5274
+ // Clear progress line and render formatted markdown
5275
+ process.stdout.write('\r\x1b[K');
5276
+ showStreamPhase('Finalizing streamed response...');
5277
+ if (msg.trim()) {
5278
+ showStreamPhase('Rendering markdown output...');
5279
+ console.log(renderMarkdown(msg));
5280
+ } else {
5281
+ console.log();
5282
+ }
3231
5283
 
3232
- // Render AI response with markdown (only for non-tool responses displayed to user)
3233
- const hasTextToolCalls = msg.includes('[TOOL:') && msg.includes('[/TOOL]');
3234
- const hasNativeToolCalls = nativeToolCalls.length > 0;
3235
- if (!hasTextToolCalls && !hasNativeToolCalls && msg.trim().length > 0) {
3236
- try {
3237
- const rendered = renderMarkdown(msg);
3238
- // Clear raw output and re-render with markdown
3239
- process.stdout.write('\x1B[2K'); // clear current line
3240
- console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta('] ') + chalk.gray('(rendered)'));
3241
- console.log(rendered);
3242
- console.log(chalk.magenta('└─────────────────────────────────────'));
3243
- } catch (e) {
3244
- // Markdown rendering failed, raw output already shown
5284
+ // Update global token tracking from actual Ollama response
5285
+ if (chunkPromptTokens > 0) {
5286
+ lastPromptTokens = chunkPromptTokens;
5287
+ lastEvalTokens = chunkEvalTokens;
5288
+ const totalTokens = chunkPromptTokens + chunkEvalTokens;
5289
+ const ctxLenDisplay = effectiveContextLength();
5290
+ if (ctxLenDisplay) {
5291
+ const usagePercent = Math.round((totalTokens / ctxLenDisplay) * 100);
5292
+ const thinkNote = thinkMsg ? ` · ${UI.slate.italic(`${thinkMsg.length.toLocaleString()} chars thinking`)}` : '';
5293
+ console.log(`${meter(totalTokens, ctxLenDisplay, 22)} ${UI.slate(`${chunkPromptTokens.toLocaleString()} prompt · ${chunkEvalTokens.toLocaleString()} response · ${usagePercent}% of context`)}${thinkNote}`);
3245
5294
  }
3246
5295
  }
5296
+ console.log(divider('─', 'gray', 56));
3247
5297
 
3248
5298
  const aiDuration = Date.now() - aiStartTime;
3249
- // Build assistant message — include tool_calls if native tools were invoked
5299
+ // Build assistant message — include tool_calls and thinking if present
3250
5300
  const assistantMsg = { role: 'assistant', content: msg };
5301
+ if (thinkMsg) {
5302
+ assistantMsg.thinking = thinkMsg;
5303
+ }
3251
5304
  if (nativeToolCalls.length > 0) {
3252
5305
  assistantMsg.tool_calls = nativeToolCalls;
3253
5306
  }
3254
5307
  messages.push(assistantMsg);
3255
5308
 
5309
+ // If interrupted, skip tool processing — go straight back to prompt
5310
+ if (wasInterrupted) {
5311
+ ensureSapperDir();
5312
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
5313
+ active = false;
5314
+ resetTerminal();
5315
+ continue;
5316
+ }
5317
+
3256
5318
  // Log AI response
3257
5319
  logEntry('ai', {
3258
5320
  charCount: msg.length,
@@ -3278,6 +5340,8 @@ async function runSapper() {
3278
5340
  write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
3279
5341
  };
3280
5342
 
5343
+ showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
5344
+
3281
5345
  for (const tc of nativeToolCalls) {
3282
5346
  const fn = tc.function;
3283
5347
  const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
@@ -3301,8 +5365,8 @@ async function runSapper() {
3301
5365
  try {
3302
5366
  switch (fn.name) {
3303
5367
  case 'list_directory':
3304
- result = tools.list(args.path);
3305
- logEntry('file', { action: 'list', path: args.path });
5368
+ result = tools.list(args.path ?? '.');
5369
+ logEntry('file', { action: 'list', path: args.path ?? '.' });
3306
5370
  break;
3307
5371
  case 'read_file':
3308
5372
  result = tools.read(args.path);
@@ -3360,8 +5424,11 @@ async function runSapper() {
3360
5424
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
3361
5425
 
3362
5426
  if (hitToolLimit) {
5427
+ showStreamPhase('Tool limit reached. Requesting final answer...');
3363
5428
  resetTerminal();
3364
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...');
3365
5432
  }
3366
5433
  continue; // Loop back for AI to process tool results
3367
5434
  }
@@ -3430,6 +5497,8 @@ async function runSapper() {
3430
5497
  if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
3431
5498
  }
3432
5499
 
5500
+ showStreamPhase(`Running ${toolMatches.length} parsed tool call${toolMatches.length === 1 ? '' : 's'}...`);
5501
+
3433
5502
  for (const match of toolMatches) {
3434
5503
  const [_, type, path, content] = match;
3435
5504
 
@@ -3527,11 +5596,14 @@ async function runSapper() {
3527
5596
 
3528
5597
  // If tool limit was reached, stop after processing this round
3529
5598
  if (hitToolLimit) {
5599
+ showStreamPhase('Tool limit reached. Requesting final answer...');
3530
5600
  resetTerminal();
3531
5601
  messages.push({
3532
5602
  role: 'user',
3533
5603
  content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
3534
5604
  });
5605
+ } else {
5606
+ showStreamPhase('Tool results ready. Continuing response generation...');
3535
5607
  }
3536
5608
  } else {
3537
5609
  // No tools found - check if malformed command