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/README.md +65 -4
- package/package.json +6 -1
- package/sapper-ui.mjs +57 -3
- package/sapper.mjs +2347 -275
- package/.github/workflows/ci.yml +0 -35
- package/.github/workflows/publish.yml +0 -46
- package/PUBLISHING.md +0 -148
- package/old/sapper copy 2.mjs +0 -673
- package/old/sapper copy 3.mjs +0 -1154
- package/old/sapper copy.mjs +0 -483
- package/old/sapper copy4.mjs +0 -1950
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
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1307
|
-
`Context
|
|
1308
|
-
`${
|
|
1309
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
1454
|
-
after:
|
|
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
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
2147
|
+
|
|
2148
|
+
const precision = size >= 100 ? 0 : size >= 10 ? 1 : 2;
|
|
2149
|
+
return `${size.toFixed(precision)} ${units[unitIndex]}`;
|
|
1491
2150
|
}
|
|
1492
2151
|
|
|
1493
|
-
function
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
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.
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2879
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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 (
|
|
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 (
|
|
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
|
-
`${
|
|
1701
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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(
|
|
1962
|
-
return `Directory created: ${
|
|
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.
|
|
1969
|
-
'
|
|
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
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
2058
|
-
console.log(
|
|
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(
|
|
2071
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2127
|
-
const resume = await safeQuestion(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3766
|
+
toolModeLabel = 'native tool calling';
|
|
2186
3767
|
} else {
|
|
2187
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2317
|
-
const
|
|
2318
|
-
if (
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
console.log(
|
|
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
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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(
|
|
2921
|
-
|
|
2922
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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 =
|
|
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(
|
|
3186
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
console.log(
|
|
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
|
|
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
|