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