sapper-iq 1.1.39 → 1.1.40
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 +46 -6
- package/package.json +1 -1
- package/sapper.mjs +2974 -508
package/sapper.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import ollama from 'ollama';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
4
5
|
import { spawn } from 'child_process';
|
|
5
6
|
import chalk from 'chalk';
|
|
6
7
|
import ora from 'ora';
|
|
7
8
|
import readline from 'readline';
|
|
8
9
|
import { fileURLToPath } from 'url';
|
|
9
|
-
import { dirname, join, isAbsolute, resolve as pathResolve } from 'path';
|
|
10
|
+
import { dirname, join, isAbsolute, resolve as pathResolve, extname } from 'path';
|
|
10
11
|
import { marked } from 'marked';
|
|
11
12
|
import { markedTerminal } from 'marked-terminal';
|
|
12
13
|
import { highlight as highlightCode } from 'cli-highlight';
|
|
@@ -15,6 +16,32 @@ import * as acorn from 'acorn';
|
|
|
15
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
17
18
|
|
|
19
|
+
function safeCwd() {
|
|
20
|
+
try {
|
|
21
|
+
return process.cwd();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const pwdCandidate = process.env.PWD;
|
|
24
|
+
const homeCandidate = process.env.HOME;
|
|
25
|
+
const fallback = [pwdCandidate, homeCandidate, __dirname].find((value) => value && fs.existsSync(value));
|
|
26
|
+
|
|
27
|
+
if (fallback) {
|
|
28
|
+
try {
|
|
29
|
+
process.chdir(fallback);
|
|
30
|
+
} catch (chdirError) {
|
|
31
|
+
// Ignore and continue to final fallback return.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return process.cwd();
|
|
37
|
+
} catch (cwdError) {
|
|
38
|
+
return __dirname;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PROJECT_ROOT = safeCwd();
|
|
44
|
+
|
|
18
45
|
// Prevent process from exiting on unhandled errors
|
|
19
46
|
process.on('uncaughtException', (err) => {
|
|
20
47
|
console.error(chalk.red('\n❌ Uncaught exception:'), err.message);
|
|
@@ -76,6 +103,7 @@ const spinner = ora();
|
|
|
76
103
|
const SAPPER_DIR = '.sapper';
|
|
77
104
|
const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
|
|
78
105
|
const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
|
|
106
|
+
const LONG_MEMORY_FILE = `${SAPPER_DIR}/long-memory.md`;
|
|
79
107
|
const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
|
|
80
108
|
const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
|
|
81
109
|
const AGENTS_DIR = `${SAPPER_DIR}/agents`;
|
|
@@ -184,7 +212,7 @@ function appendLogToFile(entry) {
|
|
|
184
212
|
if (!existed) {
|
|
185
213
|
line += `# Sapper Session Log\n`;
|
|
186
214
|
line += `**Started:** ${new Date(entry.timestamp).toLocaleString()}\n`;
|
|
187
|
-
line += `**Working Directory:** \`${
|
|
215
|
+
line += `**Working Directory:** \`${safeCwd()}\`\n\n`;
|
|
188
216
|
line += `---\n\n`;
|
|
189
217
|
}
|
|
190
218
|
|
|
@@ -590,6 +618,12 @@ const TOOL_NAME_MAP = {
|
|
|
590
618
|
'search': 'SEARCH',
|
|
591
619
|
'grep': 'GREP',
|
|
592
620
|
'find': 'FIND',
|
|
621
|
+
'regex': 'REGEX',
|
|
622
|
+
'regex_search': 'REGEX',
|
|
623
|
+
'rx': 'REGEX',
|
|
624
|
+
'chunk': 'READ_CHUNK',
|
|
625
|
+
'read_chunk': 'READ_CHUNK',
|
|
626
|
+
'read_range': 'READ_CHUNK',
|
|
593
627
|
'shell': 'SHELL',
|
|
594
628
|
'mkdir': 'MKDIR',
|
|
595
629
|
'rmdir': 'RMDIR',
|
|
@@ -607,6 +641,12 @@ const TOOL_NAME_MAP = {
|
|
|
607
641
|
'memory': 'MEMORY',
|
|
608
642
|
'recall': 'MEMORY',
|
|
609
643
|
'recall_memory': 'MEMORY',
|
|
644
|
+
'save_memory_note': 'MEMORY',
|
|
645
|
+
'search_memory_notes': 'MEMORY',
|
|
646
|
+
'read_memory_notes': 'MEMORY',
|
|
647
|
+
'memory_note_save': 'MEMORY',
|
|
648
|
+
'memory_note_search': 'MEMORY',
|
|
649
|
+
'memory_note_read': 'MEMORY',
|
|
610
650
|
'open': 'OPEN',
|
|
611
651
|
'browser': 'OPEN',
|
|
612
652
|
'open_url': 'OPEN',
|
|
@@ -623,6 +663,8 @@ const TOOL_ALLOWED_BY = {
|
|
|
623
663
|
SEARCH: ['SEARCH', 'GREP'],
|
|
624
664
|
GREP: ['SEARCH', 'GREP'],
|
|
625
665
|
FIND: ['FIND'],
|
|
666
|
+
REGEX: ['REGEX'],
|
|
667
|
+
READ_CHUNK: ['READ_CHUNK', 'READ', 'CAT', 'HEAD', 'TAIL'],
|
|
626
668
|
WRITE: ['WRITE'],
|
|
627
669
|
PATCH: ['PATCH'],
|
|
628
670
|
MKDIR: ['MKDIR'],
|
|
@@ -981,28 +1023,137 @@ function buildSystemPrompt(agentContent = null, skillContents = []) {
|
|
|
981
1023
|
const promptConfig = getPromptConfig();
|
|
982
1024
|
const promptPrepend = promptConfig.prepend.trim();
|
|
983
1025
|
const promptAppend = promptConfig.append.trim();
|
|
984
|
-
const corePrompt = promptConfig.coreOverride.trim() ||
|
|
1026
|
+
const corePrompt = promptConfig.coreOverride.trim() || getPromptTemplate('system.core', '', {
|
|
1027
|
+
date: dateStr,
|
|
1028
|
+
time: timeStr,
|
|
1029
|
+
});
|
|
1030
|
+
let prompt = promptPrepend
|
|
1031
|
+
? `${wrapPromptCustomizationBlock('CUSTOM PROMPT PREPEND', promptPrepend, false)}\n\n${corePrompt}`
|
|
1032
|
+
: corePrompt;
|
|
1033
|
+
|
|
1034
|
+
if (_useNativeToolsFlag) {
|
|
1035
|
+
prompt += `\n\n${getPromptTemplate('system.nativeTools')}`;
|
|
1036
|
+
} else {
|
|
1037
|
+
prompt += `\n\n${getPromptTemplate('system.legacyTools')}`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
prompt += `\n\n${getPromptTemplate('system.importantContext')}`;
|
|
1041
|
+
|
|
1042
|
+
if (chunkingEnabled()) {
|
|
1043
|
+
const ctx = chunkingContextLines();
|
|
1044
|
+
prompt += `\n\nCONTEXT-WINDOW STRATEGY (chunked reading is ON):\n` +
|
|
1045
|
+
`- Do NOT load entire large files. The context window is limited and full-file reads waste it.\n` +
|
|
1046
|
+
`- Two-step approach: (1) SEARCH with grep / regex_search to find the relevant lines, then (2) CHUNK with read_chunk(path, start, end) to pull just those lines plus ~${ctx} lines of surrounding context.\n` +
|
|
1047
|
+
`- For files larger than ~${chunkingAutoAboveLines()} lines, always prefer read_chunk over read_file unless the user explicitly asks for the whole file.\n` +
|
|
1048
|
+
`- regex_search already groups nearby hits into chunks (±${ctx} lines) — read those first before opening anything else.`;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (agentContent) {
|
|
1052
|
+
prompt += `\n\n${getPromptTemplate('system.activeAgentWrapper', '', { agentContent })}`;
|
|
1053
|
+
|
|
1054
|
+
// If the active agent has tool restrictions, inform the AI
|
|
1055
|
+
if (currentAgentTools && currentAgentTools.length > 0) {
|
|
1056
|
+
const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
|
|
1057
|
+
const forbidden = allTools.filter(t => !currentAgentTools.includes(t));
|
|
1058
|
+
prompt += `\n\n${getPromptTemplate('system.agentRestriction', '', {
|
|
1059
|
+
allowedTools: currentAgentTools.join(', '),
|
|
1060
|
+
forbiddenTools: forbidden.join(', '),
|
|
1061
|
+
})}`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (skillContents.length > 0) {
|
|
1066
|
+
const skillBlock = skillContents.map(skill => `${skill}\n---`).join('\n');
|
|
1067
|
+
prompt += `\n\n${getPromptTemplate('system.loadedSkillsWrapper', '', { skillBlock })}`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (promptAppend) {
|
|
1071
|
+
prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return prompt;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Track active agent
|
|
1078
|
+
let currentAgent = null; // null = default Sapper, or agent name string
|
|
1079
|
+
let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
|
|
1080
|
+
let loadedSkills = []; // array of skill names currently loaded
|
|
1081
|
+
|
|
1082
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
1083
|
+
defaultModel: null,
|
|
1084
|
+
defaultAgent: null,
|
|
1085
|
+
autoAttach: true,
|
|
1086
|
+
debug: false,
|
|
1087
|
+
contextLimit: null,
|
|
1088
|
+
toolRoundLimit: 40,
|
|
1089
|
+
patchRetries: 3,
|
|
1090
|
+
maxFileSize: 100000,
|
|
1091
|
+
maxScanSize: 1000000,
|
|
1092
|
+
maxUrlSize: 200000,
|
|
1093
|
+
summaryPhases: true,
|
|
1094
|
+
summarizeTriggerPercent: 65,
|
|
1095
|
+
shell: Object.freeze({
|
|
1096
|
+
streamToModel: true,
|
|
1097
|
+
backgroundMode: 'auto',
|
|
1098
|
+
backgroundAfterSeconds: 8,
|
|
1099
|
+
outputChunkChars: 4000,
|
|
1100
|
+
}),
|
|
1101
|
+
streaming: Object.freeze({
|
|
1102
|
+
showPhaseStatus: true,
|
|
1103
|
+
showHeartbeat: true,
|
|
1104
|
+
idleNoticeSeconds: 4,
|
|
1105
|
+
}),
|
|
1106
|
+
thinking: Object.freeze({
|
|
1107
|
+
mode: 'auto',
|
|
1108
|
+
}),
|
|
1109
|
+
ui: Object.freeze({
|
|
1110
|
+
compactMode: 'auto',
|
|
1111
|
+
style: 'sapper',
|
|
1112
|
+
}),
|
|
1113
|
+
chunking: Object.freeze({
|
|
1114
|
+
enabled: true, // Master toggle — agent prefers chunked reads/searches
|
|
1115
|
+
contextLines: 20, // Lines of context above & below each match
|
|
1116
|
+
maxChunksPerFile: 5, // Max chunk groups returned per file
|
|
1117
|
+
autoChunkAboveLines: 400,// read_file warns + suggests chunking above this many lines
|
|
1118
|
+
}),
|
|
1119
|
+
voice: Object.freeze({
|
|
1120
|
+
enabled: true, // Feature available (still triggered manually via /voice)
|
|
1121
|
+
whisperBin: 'whisper-cli', // whisper.cpp CLI binary on PATH
|
|
1122
|
+
whisperStreamBin: 'whisper-stream', // whisper.cpp streaming binary (live mode)
|
|
1123
|
+
model: '/Users/ibrahimihsan/models/ggml-medium.bin', // Path to a ggml-*.bin (whisper.cpp format)
|
|
1124
|
+
language: 'auto', // 'auto' for detection, or 'en' / 'ar' / etc.
|
|
1125
|
+
recorder: 'ffmpeg', // 'ffmpeg' or 'sox'
|
|
1126
|
+
recordSeconds: 8, // Default mic-record duration
|
|
1127
|
+
device: ':0', // ffmpeg avfoundation audio input (macOS: ':0' default mic)
|
|
1128
|
+
sampleRate: 16000, // Whisper expects 16kHz mono
|
|
1129
|
+
translate: false, // True = translate non-English speech to English
|
|
1130
|
+
autoSend: false, // If true, transcript is sent to the AI immediately (no confirm prompt)
|
|
1131
|
+
liveStepMs: 500, // Live mode: re-decode every N ms (lower = snappier, more CPU)
|
|
1132
|
+
liveLengthMs: 5000, // Live mode: sliding window length in ms
|
|
1133
|
+
liveKeepMs: 200, // Live mode: ms of audio to carry over between steps
|
|
1134
|
+
archive: true, // Save every recording (audio + transcript) to disk
|
|
1135
|
+
archiveDir: '.sapper/voice', // Folder for archive (relative to cwd or absolute). A YYYY-MM-DD subfolder is auto-created.
|
|
1136
|
+
}),
|
|
1137
|
+
prompt: Object.freeze({
|
|
1138
|
+
prepend: '',
|
|
1139
|
+
append: '',
|
|
1140
|
+
coreOverride: '',
|
|
1141
|
+
system: Object.freeze({
|
|
1142
|
+
core: `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
|
|
985
1143
|
You can help with ANY task - coding, writing, research, planning, analysis, and more.
|
|
986
1144
|
Adapt your personality and expertise based on the active agent role and loaded skills.
|
|
987
1145
|
|
|
988
|
-
CURRENT DATE AND TIME:
|
|
1146
|
+
CURRENT DATE AND TIME: {date}, {time}
|
|
989
1147
|
|
|
990
1148
|
RULES:
|
|
991
1149
|
1. EXPLORE FIRST: Use list and read to understand files before making changes.
|
|
992
1150
|
2. THINK IN STEPS: Explain what you found and what you plan to do before acting.
|
|
993
1151
|
3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
|
|
994
1152
|
4. VERIFY: After making changes, verify they work (run tests, check output, etc).
|
|
995
|
-
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead
|
|
996
|
-
|
|
997
|
-
? `${wrapPromptCustomizationBlock('CUSTOM PROMPT PREPEND', promptPrepend, false)}\n\n${corePrompt}`
|
|
998
|
-
: corePrompt;
|
|
999
|
-
|
|
1000
|
-
if (_useNativeToolsFlag) {
|
|
1001
|
-
prompt += `
|
|
1002
|
-
|
|
1003
|
-
TOOLS:
|
|
1153
|
+
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`,
|
|
1154
|
+
nativeTools: `TOOLS:
|
|
1004
1155
|
You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
|
|
1005
|
-
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, pwd, cd, rmdir, changes, fetch_web, recall_memory, open_url, run_shell.
|
|
1156
|
+
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, regex_search, read_chunk, pwd, cd, rmdir, changes, fetch_web, recall_memory, save_memory_note, search_memory_notes, read_memory_notes, open_url, run_shell.
|
|
1006
1157
|
|
|
1007
1158
|
PATCH TIPS:
|
|
1008
1159
|
- For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
|
|
@@ -1013,21 +1164,23 @@ EXTRA TOOL TIPS:
|
|
|
1013
1164
|
- ls lists directory contents using the current tool working directory when path is omitted.
|
|
1014
1165
|
- cat reads a full file, while head and tail read the first or last N lines.
|
|
1015
1166
|
- grep searches file contents, and find searches file or directory names.
|
|
1167
|
+
- regex_search runs advanced JS-regex search across source code with capture groups; supports /pattern/flags syntax and an optional include filter (e.g. "js,ts" or "src/").
|
|
1168
|
+
- read_chunk reads a specific line range from a file (e.g. lines 40-80). Prefer this over read_file for large files — search first with grep/regex_search, then read_chunk the surrounding window instead of the whole file. This keeps the context window small and responses fast.
|
|
1016
1169
|
- pwd shows the current tool working directory, and cd changes it for later tool calls.
|
|
1017
1170
|
- rmdir removes a directory recursively and always asks for approval.
|
|
1018
1171
|
- changes shows git status and diff output for the current repository or an optional path.
|
|
1019
1172
|
- fetch_web fetches a web page and returns readable text content.
|
|
1020
1173
|
- recall_memory searches Sapper's saved conversation memory.
|
|
1174
|
+
- save_memory_note appends a durable markdown note for recurring patterns, decisions, or fixes.
|
|
1175
|
+
- search_memory_notes searches markdown long-memory notes in .sapper/long-memory.md.
|
|
1176
|
+
- read_memory_notes reads the full markdown long-memory file.
|
|
1021
1177
|
- open_url opens a URL in the default browser and always asks for approval.
|
|
1022
1178
|
|
|
1023
1179
|
SHELL TIPS:
|
|
1024
1180
|
- run_shell may keep long-running commands in a background session depending on config.
|
|
1025
1181
|
- If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
|
|
1026
|
-
- Use run_shell command "__shell_list__" to list sessions and "__shell_stop__ <session_id>" to stop one
|
|
1027
|
-
|
|
1028
|
-
prompt += `
|
|
1029
|
-
|
|
1030
|
-
TOOL SYNTAX (use these to interact with files and system):
|
|
1182
|
+
- Use run_shell command "__shell_list__" to list sessions and "__shell_stop__ <session_id>" to stop one.`,
|
|
1183
|
+
legacyTools: `TOOL SYNTAX (use these to interact with files and system):
|
|
1031
1184
|
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
1032
1185
|
- [TOOL:LS]dir[/TOOL] - Alias for LIST
|
|
1033
1186
|
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
@@ -1037,6 +1190,10 @@ TOOL SYNTAX (use these to interact with files and system):
|
|
|
1037
1190
|
- [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
|
|
1038
1191
|
- [TOOL:GREP]pattern[/TOOL] - Alias for SEARCH
|
|
1039
1192
|
- [TOOL:FIND]name_or_fragment[/TOOL] - Find files and directories by name
|
|
1193
|
+
- [TOOL:REGEX]/pattern/flags[/TOOL] - Advanced regex search across source code (returns file:line:col + capture groups)
|
|
1194
|
+
- [TOOL:REGEX]/pattern/flags:::js,ts[/TOOL] - Same, restricted to file extensions or path substrings
|
|
1195
|
+
- [TOOL:CHUNK]path:::40-80[/TOOL] - Read a specific line range from a file (preferred over READ for big files)
|
|
1196
|
+
- [TOOL:CHUNK]path:::40-80:::10[/TOOL] - Same with 10 extra context lines above & below the range
|
|
1040
1197
|
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
1041
1198
|
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
|
|
1042
1199
|
- [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace a specific line by number (PREFERRED — more reliable)
|
|
@@ -1046,6 +1203,9 @@ TOOL SYNTAX (use these to interact with files and system):
|
|
|
1046
1203
|
- [TOOL:CHANGES]path[/TOOL] - Show git status and diffs for the repository or a path
|
|
1047
1204
|
- [TOOL:FETCH]https://example.com[/TOOL] - Fetch a web page and return readable content
|
|
1048
1205
|
- [TOOL:MEMORY]query[/TOOL] - Search saved conversation memory
|
|
1206
|
+
- [TOOL:MEMORY_NOTE_SAVE]title:::note:::tag1,tag2[/TOOL] - Save a durable markdown note
|
|
1207
|
+
- [TOOL:MEMORY_NOTE_SEARCH]query[/TOOL] - Search markdown long memory notes
|
|
1208
|
+
- [TOOL:MEMORY_NOTE_READ][/TOOL] - Read markdown long memory file
|
|
1049
1209
|
- [TOOL:OPEN]https://example.com[/TOOL] - Open a URL in the default browser (asks for approval)
|
|
1050
1210
|
- [TOOL:SHELL]command[/TOOL] - Run shell command
|
|
1051
1211
|
|
|
@@ -1060,86 +1220,94 @@ SHELL TIPS:
|
|
|
1060
1220
|
- Use [TOOL:SHELL]__shell_list__[/TOOL] to list sessions and [TOOL:SHELL]__shell_stop__ <session_id>[/TOOL] to stop one.
|
|
1061
1221
|
|
|
1062
1222
|
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.
|
|
1063
|
-
Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
prompt += `
|
|
1067
|
-
|
|
1068
|
-
IMPORTANT CONTEXT:
|
|
1223
|
+
Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`,
|
|
1224
|
+
importantContext: `IMPORTANT CONTEXT:
|
|
1069
1225
|
- The current working directory is the user's project folder.
|
|
1070
1226
|
- Sapper has a built-in agent/skill system. Agents are managed via /agents, /agent create, /newagent commands - NOT by you creating files manually.
|
|
1071
1227
|
- Do NOT try to build agent frameworks, projects, or directory structures when the user mentions agents. The agent system is already built into Sapper.
|
|
1072
1228
|
- When the user asks you to do something, work within their current project directory.
|
|
1073
1229
|
- Use "." for the current directory when listing, not "/" or "agent/".
|
|
1074
1230
|
|
|
1075
|
-
When no agent is active, you are a general-purpose assistant. When an agent role is loaded, fully adopt that role
|
|
1231
|
+
When no agent is active, you are a general-purpose assistant. When an agent role is loaded, fully adopt that role.`,
|
|
1232
|
+
activeAgentWrapper: `═══ ACTIVE AGENT ROLE ═══
|
|
1233
|
+
{agentContent}
|
|
1234
|
+
═══ END AGENT ROLE ═══
|
|
1235
|
+
|
|
1236
|
+
IMPORTANT: You are now operating as the agent described above. Adopt its persona, expertise, and communication style while still having access to Sapper tools.`,
|
|
1237
|
+
agentRestriction: `TOOL RESTRICTION: This agent can ONLY use these tools: {allowedTools}.
|
|
1238
|
+
FORBIDDEN TOOLS (DO NOT USE): {forbiddenTools}. You MUST NOT attempt to use forbidden tools. If you need a forbidden tool, tell the user you cannot perform that action with your current role.`,
|
|
1239
|
+
loadedSkillsWrapper: `═══ LOADED SKILLS ═══
|
|
1240
|
+
{skillBlock}
|
|
1241
|
+
═══ END SKILLS ═══
|
|
1242
|
+
|
|
1243
|
+
Use the knowledge from the loaded skills above when relevant to the user's request.`,
|
|
1244
|
+
}),
|
|
1245
|
+
ui: Object.freeze({
|
|
1246
|
+
bannerTitle: 'Sapper',
|
|
1247
|
+
bannerSubtitle: 'terminal coding workspace',
|
|
1248
|
+
bannerTagline: 'Model selection, live tools, and focused sessions in one loop',
|
|
1249
|
+
quickStartTitle: 'Quick Start',
|
|
1250
|
+
quickStartSubtitle: '@file attach · /commands palette · /agents modes',
|
|
1251
|
+
cleanFrontendHint: 'clean frontend active · /ui style sapper to switch back',
|
|
1252
|
+
ultraFrontendHint: 'ultra frontend active /ui style sapper to switch back',
|
|
1253
|
+
modelPickerUltraTitle: 'models',
|
|
1254
|
+
modelPickerCleanTitle: 'model picker',
|
|
1255
|
+
modelPickerTitle: 'Model selection',
|
|
1256
|
+
modelPickerSectionTitle: 'Model',
|
|
1257
|
+
modelPickerSubtitle: 'use ↑↓ or j/k, enter to confirm',
|
|
1258
|
+
unknownCommandTitle: 'Unknown Command',
|
|
1259
|
+
uiUsage: ' Usage: /ui style [sapper|clean|ultra] | /ui compact [auto|on|off] | /ui reset',
|
|
1260
|
+
fetchStatus: 'Fetching {url}...',
|
|
1261
|
+
webPageContentTitle: 'WEB PAGE CONTENT',
|
|
1262
|
+
symbolSearchStatus: 'Searching for: "{query}"...',
|
|
1263
|
+
memorySearchStatus: 'Searching memory for: "{query}"...',
|
|
1264
|
+
scanStatus: 'Scanning codebase...',
|
|
1265
|
+
}),
|
|
1266
|
+
questions: Object.freeze({
|
|
1267
|
+
resumeSession: 'Resume session',
|
|
1268
|
+
removeDirectory: 'Remove directory',
|
|
1269
|
+
openUrlInBrowser: 'Open URL in browser',
|
|
1270
|
+
runShellCommand: 'Run shell command',
|
|
1271
|
+
stopBackgroundShellSession: 'Stop background shell session {id}',
|
|
1272
|
+
reviewChange: 'Review change [k]eep/[i]gnore/[d]iff/[f]eedback/[e]dit: ',
|
|
1273
|
+
feedbackForSapper: 'Feedback for Sapper: ',
|
|
1274
|
+
editInstructionForSapper: 'Edit instruction for Sapper: ',
|
|
1275
|
+
addFirstMatchFileToContext: 'Add first match file to context? (y/n): ',
|
|
1276
|
+
addFileAndRelatedToContext: 'Add this file + related to context? (y/n): ',
|
|
1277
|
+
addMemoryToCurrentContext: 'Add to current context? (y/n): ',
|
|
1278
|
+
agentName: '\nAgent name (lowercase, no spaces): ',
|
|
1279
|
+
agentTitle: 'Agent title/role: ',
|
|
1280
|
+
agentExpertise: 'Areas of expertise (comma-separated): ',
|
|
1281
|
+
agentStyle: 'Communication style (e.g., professional, casual, technical): ',
|
|
1282
|
+
agentTools: 'Allowed tools (comma-sep, or Enter for all): read,read_chunk,edit,write,list,ls,search,grep,find,regex,shell,mkdir,rmdir,pwd,cd,cat,head,tail,changes,fetch,memory,open: ',
|
|
1283
|
+
skillName: '\nSkill name (lowercase, no spaces): ',
|
|
1284
|
+
skillTitle: 'Skill title: ',
|
|
1285
|
+
skillDescription: 'Brief description (for /skills listing): ',
|
|
1286
|
+
skillArgumentHint: 'Argument hint (optional, e.g. "Describe what to do"): ',
|
|
1287
|
+
skillKnowledge: 'Skill knowledge (or Enter for template): ',
|
|
1288
|
+
promptForFiles: 'Your prompt for these files: ',
|
|
1289
|
+
stepContinue: '[STEP] Press Enter to let AI think...',
|
|
1290
|
+
}),
|
|
1291
|
+
}),
|
|
1292
|
+
});
|
|
1076
1293
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
// If the active agent has tool restrictions, inform the AI
|
|
1081
|
-
if (currentAgentTools && currentAgentTools.length > 0) {
|
|
1082
|
-
const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
|
|
1083
|
-
const forbidden = allTools.filter(t => !currentAgentTools.includes(t));
|
|
1084
|
-
prompt += `\n\nTOOL RESTRICTION: This agent can ONLY use these tools: ${currentAgentTools.join(', ')}.
|
|
1085
|
-
FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to use forbidden tools. If you need a forbidden tool, tell the user you cannot perform that action with your current role.`;
|
|
1086
|
-
}
|
|
1294
|
+
function normalizePromptTree(inputValue, defaultValue) {
|
|
1295
|
+
if (typeof defaultValue === 'string') {
|
|
1296
|
+
return normalizePromptText(inputValue === undefined ? defaultValue : inputValue);
|
|
1087
1297
|
}
|
|
1088
1298
|
|
|
1089
|
-
if (
|
|
1090
|
-
|
|
1091
|
-
for (const skill of skillContents) {
|
|
1092
|
-
prompt += `\n${skill}\n---`;
|
|
1093
|
-
}
|
|
1094
|
-
prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
|
|
1299
|
+
if (!defaultValue || typeof defaultValue !== 'object' || Array.isArray(defaultValue)) {
|
|
1300
|
+
return inputValue === undefined ? defaultValue : inputValue;
|
|
1095
1301
|
}
|
|
1096
1302
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1303
|
+
const source = inputValue && typeof inputValue === 'object' && !Array.isArray(inputValue) ? inputValue : {};
|
|
1304
|
+
const output = {};
|
|
1305
|
+
for (const [key, nestedDefault] of Object.entries(defaultValue)) {
|
|
1306
|
+
output[key] = normalizePromptTree(source[key], nestedDefault);
|
|
1099
1307
|
}
|
|
1100
|
-
|
|
1101
|
-
return prompt;
|
|
1308
|
+
return output;
|
|
1102
1309
|
}
|
|
1103
1310
|
|
|
1104
|
-
// Track active agent
|
|
1105
|
-
let currentAgent = null; // null = default Sapper, or agent name string
|
|
1106
|
-
let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
|
|
1107
|
-
let loadedSkills = []; // array of skill names currently loaded
|
|
1108
|
-
|
|
1109
|
-
const DEFAULT_CONFIG = Object.freeze({
|
|
1110
|
-
defaultModel: null,
|
|
1111
|
-
defaultAgent: null,
|
|
1112
|
-
autoAttach: true,
|
|
1113
|
-
debug: false,
|
|
1114
|
-
contextLimit: null,
|
|
1115
|
-
toolRoundLimit: 40,
|
|
1116
|
-
patchRetries: 3,
|
|
1117
|
-
maxFileSize: 100000,
|
|
1118
|
-
maxScanSize: 1000000,
|
|
1119
|
-
maxUrlSize: 200000,
|
|
1120
|
-
summaryPhases: true,
|
|
1121
|
-
summarizeTriggerPercent: 65,
|
|
1122
|
-
shell: Object.freeze({
|
|
1123
|
-
streamToModel: true,
|
|
1124
|
-
backgroundMode: 'auto',
|
|
1125
|
-
backgroundAfterSeconds: 8,
|
|
1126
|
-
outputChunkChars: 4000,
|
|
1127
|
-
}),
|
|
1128
|
-
streaming: Object.freeze({
|
|
1129
|
-
showPhaseStatus: true,
|
|
1130
|
-
showHeartbeat: true,
|
|
1131
|
-
idleNoticeSeconds: 4,
|
|
1132
|
-
}),
|
|
1133
|
-
thinking: Object.freeze({
|
|
1134
|
-
mode: 'auto',
|
|
1135
|
-
}),
|
|
1136
|
-
prompt: Object.freeze({
|
|
1137
|
-
prepend: '',
|
|
1138
|
-
append: '',
|
|
1139
|
-
coreOverride: '',
|
|
1140
|
-
}),
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
1311
|
function normalizeBoolean(value, fallback) {
|
|
1144
1312
|
if (typeof value === 'boolean') return value;
|
|
1145
1313
|
if (typeof value === 'string') {
|
|
@@ -1242,6 +1410,101 @@ function normalizeStreamingConfig(streamingConfig = {}) {
|
|
|
1242
1410
|
};
|
|
1243
1411
|
}
|
|
1244
1412
|
|
|
1413
|
+
function normalizeUICompactMode(value) {
|
|
1414
|
+
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
|
1415
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
1416
|
+
if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always', 'compact'].includes(normalized)) return 'on';
|
|
1417
|
+
if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never', 'full'].includes(normalized)) return 'off';
|
|
1418
|
+
return 'auto';
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function normalizeUIStyle(value) {
|
|
1422
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
1423
|
+
if (['ultra', 'minimal', 'min', 'ultra-clean', 'ultraclean'].includes(normalized)) return 'ultra';
|
|
1424
|
+
if (['clean', 'minimal', 'codex', 'opencode'].includes(normalized)) return 'clean';
|
|
1425
|
+
if (['sapper', 'classic', 'default'].includes(normalized)) return 'sapper';
|
|
1426
|
+
return DEFAULT_CONFIG.ui.style;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function normalizeUIConfig(uiConfig = {}) {
|
|
1430
|
+
if (typeof uiConfig === 'boolean' || typeof uiConfig === 'string') {
|
|
1431
|
+
return {
|
|
1432
|
+
compactMode: normalizeUICompactMode(uiConfig),
|
|
1433
|
+
style: DEFAULT_CONFIG.ui.style,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (!uiConfig || typeof uiConfig !== 'object' || Array.isArray(uiConfig)) {
|
|
1438
|
+
return { ...DEFAULT_CONFIG.ui };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
compactMode: normalizeUICompactMode(uiConfig.compactMode),
|
|
1443
|
+
style: normalizeUIStyle(uiConfig.style),
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function normalizeChunkingConfig(chunkingConfig = {}) {
|
|
1448
|
+
if (typeof chunkingConfig === 'boolean') {
|
|
1449
|
+
return { ...DEFAULT_CONFIG.chunking, enabled: chunkingConfig };
|
|
1450
|
+
}
|
|
1451
|
+
if (typeof chunkingConfig === 'string') {
|
|
1452
|
+
return { ...DEFAULT_CONFIG.chunking, enabled: normalizeBoolean(chunkingConfig, DEFAULT_CONFIG.chunking.enabled) };
|
|
1453
|
+
}
|
|
1454
|
+
if (!chunkingConfig || typeof chunkingConfig !== 'object' || Array.isArray(chunkingConfig)) {
|
|
1455
|
+
return { ...DEFAULT_CONFIG.chunking };
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
enabled: normalizeBoolean(chunkingConfig.enabled, DEFAULT_CONFIG.chunking.enabled),
|
|
1459
|
+
contextLines: normalizeIntegerInRange(chunkingConfig.contextLines, DEFAULT_CONFIG.chunking.contextLines, 0, 200),
|
|
1460
|
+
maxChunksPerFile: normalizeIntegerInRange(chunkingConfig.maxChunksPerFile, DEFAULT_CONFIG.chunking.maxChunksPerFile, 1, 50),
|
|
1461
|
+
autoChunkAboveLines: normalizeIntegerInRange(chunkingConfig.autoChunkAboveLines, DEFAULT_CONFIG.chunking.autoChunkAboveLines, 50, 10000),
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function normalizeVoiceConfig(voiceConfig = {}) {
|
|
1466
|
+
if (typeof voiceConfig === 'boolean') {
|
|
1467
|
+
return { ...DEFAULT_CONFIG.voice, enabled: voiceConfig };
|
|
1468
|
+
}
|
|
1469
|
+
if (typeof voiceConfig === 'string') {
|
|
1470
|
+
return { ...DEFAULT_CONFIG.voice, enabled: normalizeBoolean(voiceConfig, DEFAULT_CONFIG.voice.enabled) };
|
|
1471
|
+
}
|
|
1472
|
+
if (!voiceConfig || typeof voiceConfig !== 'object' || Array.isArray(voiceConfig)) {
|
|
1473
|
+
return { ...DEFAULT_CONFIG.voice };
|
|
1474
|
+
}
|
|
1475
|
+
const recorder = String(voiceConfig.recorder ?? '').trim().toLowerCase();
|
|
1476
|
+
return {
|
|
1477
|
+
enabled: normalizeBoolean(voiceConfig.enabled, DEFAULT_CONFIG.voice.enabled),
|
|
1478
|
+
whisperBin: typeof voiceConfig.whisperBin === 'string' && voiceConfig.whisperBin.trim()
|
|
1479
|
+
? voiceConfig.whisperBin.trim()
|
|
1480
|
+
: DEFAULT_CONFIG.voice.whisperBin,
|
|
1481
|
+
whisperStreamBin: typeof voiceConfig.whisperStreamBin === 'string' && voiceConfig.whisperStreamBin.trim()
|
|
1482
|
+
? voiceConfig.whisperStreamBin.trim()
|
|
1483
|
+
: DEFAULT_CONFIG.voice.whisperStreamBin,
|
|
1484
|
+
model: typeof voiceConfig.model === 'string' && voiceConfig.model.trim()
|
|
1485
|
+
? voiceConfig.model.trim()
|
|
1486
|
+
: DEFAULT_CONFIG.voice.model,
|
|
1487
|
+
language: typeof voiceConfig.language === 'string' && voiceConfig.language.trim()
|
|
1488
|
+
? voiceConfig.language.trim().toLowerCase()
|
|
1489
|
+
: DEFAULT_CONFIG.voice.language,
|
|
1490
|
+
recorder: ['ffmpeg', 'sox'].includes(recorder) ? recorder : DEFAULT_CONFIG.voice.recorder,
|
|
1491
|
+
recordSeconds: normalizeIntegerInRange(voiceConfig.recordSeconds, DEFAULT_CONFIG.voice.recordSeconds, 1, 300),
|
|
1492
|
+
device: typeof voiceConfig.device === 'string' && voiceConfig.device
|
|
1493
|
+
? voiceConfig.device
|
|
1494
|
+
: DEFAULT_CONFIG.voice.device,
|
|
1495
|
+
sampleRate: normalizeIntegerInRange(voiceConfig.sampleRate, DEFAULT_CONFIG.voice.sampleRate, 8000, 48000),
|
|
1496
|
+
translate: normalizeBoolean(voiceConfig.translate, DEFAULT_CONFIG.voice.translate),
|
|
1497
|
+
autoSend: normalizeBoolean(voiceConfig.autoSend, DEFAULT_CONFIG.voice.autoSend),
|
|
1498
|
+
liveStepMs: normalizeIntegerInRange(voiceConfig.liveStepMs, DEFAULT_CONFIG.voice.liveStepMs, 100, 5000),
|
|
1499
|
+
liveLengthMs: normalizeIntegerInRange(voiceConfig.liveLengthMs, DEFAULT_CONFIG.voice.liveLengthMs, 1000, 30000),
|
|
1500
|
+
liveKeepMs: normalizeIntegerInRange(voiceConfig.liveKeepMs, DEFAULT_CONFIG.voice.liveKeepMs, 0, 2000),
|
|
1501
|
+
archive: normalizeBoolean(voiceConfig.archive, DEFAULT_CONFIG.voice.archive),
|
|
1502
|
+
archiveDir: typeof voiceConfig.archiveDir === 'string' && voiceConfig.archiveDir.trim()
|
|
1503
|
+
? voiceConfig.archiveDir.trim()
|
|
1504
|
+
: DEFAULT_CONFIG.voice.archiveDir,
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1245
1508
|
function normalizePromptText(value) {
|
|
1246
1509
|
if (typeof value === 'string') return value;
|
|
1247
1510
|
if (value === null || value === undefined) return '';
|
|
@@ -1250,21 +1513,20 @@ function normalizePromptText(value) {
|
|
|
1250
1513
|
|
|
1251
1514
|
function normalizePromptConfig(promptConfig = {}) {
|
|
1252
1515
|
if (!promptConfig || typeof promptConfig !== 'object' || Array.isArray(promptConfig)) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
};
|
|
1516
|
+
const normalized = normalizePromptTree({}, DEFAULT_CONFIG.prompt);
|
|
1517
|
+
normalized.append = normalizePromptText(promptConfig);
|
|
1518
|
+
return normalized;
|
|
1257
1519
|
}
|
|
1258
1520
|
|
|
1259
1521
|
const coreOverride = promptConfig.coreOverride !== undefined
|
|
1260
1522
|
? promptConfig.coreOverride
|
|
1261
1523
|
: promptConfig.override;
|
|
1262
1524
|
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1525
|
+
const normalized = normalizePromptTree(promptConfig, DEFAULT_CONFIG.prompt);
|
|
1526
|
+
normalized.prepend = normalizePromptText(promptConfig.prepend);
|
|
1527
|
+
normalized.append = normalizePromptText(promptConfig.append);
|
|
1528
|
+
normalized.coreOverride = normalizePromptText(coreOverride);
|
|
1529
|
+
return normalized;
|
|
1268
1530
|
}
|
|
1269
1531
|
|
|
1270
1532
|
function normalizeConfig(config = {}) {
|
|
@@ -1285,19 +1547,156 @@ function normalizeConfig(config = {}) {
|
|
|
1285
1547
|
shell: normalizeShellConfig(config.shell),
|
|
1286
1548
|
streaming: normalizeStreamingConfig(config.streaming),
|
|
1287
1549
|
thinking: normalizeThinkingConfig(config.thinking),
|
|
1550
|
+
ui: normalizeUIConfig(config.ui),
|
|
1551
|
+
chunking: normalizeChunkingConfig(config.chunking),
|
|
1552
|
+
voice: normalizeVoiceConfig(config.voice),
|
|
1288
1553
|
prompt: normalizePromptConfig(config.prompt),
|
|
1289
1554
|
};
|
|
1290
1555
|
}
|
|
1291
1556
|
|
|
1557
|
+
function stripJsonComments(text = '') {
|
|
1558
|
+
let result = '';
|
|
1559
|
+
let inString = false;
|
|
1560
|
+
let escaped = false;
|
|
1561
|
+
let inLineComment = false;
|
|
1562
|
+
let inBlockComment = false;
|
|
1563
|
+
|
|
1564
|
+
for (let index = 0; index < text.length; index++) {
|
|
1565
|
+
const char = text[index];
|
|
1566
|
+
const next = text[index + 1];
|
|
1567
|
+
|
|
1568
|
+
if (inLineComment) {
|
|
1569
|
+
if (char === '\n') {
|
|
1570
|
+
inLineComment = false;
|
|
1571
|
+
result += char;
|
|
1572
|
+
}
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (inBlockComment) {
|
|
1577
|
+
if (char === '*' && next === '/') {
|
|
1578
|
+
inBlockComment = false;
|
|
1579
|
+
index++;
|
|
1580
|
+
}
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
if (inString) {
|
|
1585
|
+
result += char;
|
|
1586
|
+
if (escaped) {
|
|
1587
|
+
escaped = false;
|
|
1588
|
+
} else if (char === '\\') {
|
|
1589
|
+
escaped = true;
|
|
1590
|
+
} else if (char === '"') {
|
|
1591
|
+
inString = false;
|
|
1592
|
+
}
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
if (char === '"') {
|
|
1597
|
+
inString = true;
|
|
1598
|
+
result += char;
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (char === '/' && next === '/') {
|
|
1603
|
+
inLineComment = true;
|
|
1604
|
+
index++;
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
if (char === '/' && next === '*') {
|
|
1609
|
+
inBlockComment = true;
|
|
1610
|
+
index++;
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
result += char;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return result;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function appendConfigProperty(lines, name, value, { indent = 2, trailingComma = true } = {}) {
|
|
1621
|
+
const pad = ' '.repeat(indent);
|
|
1622
|
+
const serialized = JSON.stringify(value, null, 2).split('\n');
|
|
1623
|
+
lines.push(`${pad}"${name}": ${serialized[0]}`);
|
|
1624
|
+
for (const line of serialized.slice(1)) {
|
|
1625
|
+
lines.push(`${pad}${line}`);
|
|
1626
|
+
}
|
|
1627
|
+
if (trailingComma) {
|
|
1628
|
+
lines[lines.length - 1] += ',';
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function renderConfigFile(config) {
|
|
1633
|
+
const lines = [
|
|
1634
|
+
'// Sapper configuration file',
|
|
1635
|
+
'// This file supports JSON-style comments. Edit values and restart only when a command explicitly says so.',
|
|
1636
|
+
'{',
|
|
1637
|
+
' // Core runtime behavior',
|
|
1638
|
+
];
|
|
1639
|
+
|
|
1640
|
+
appendConfigProperty(lines, 'defaultModel', config.defaultModel);
|
|
1641
|
+
appendConfigProperty(lines, 'defaultAgent', config.defaultAgent);
|
|
1642
|
+
appendConfigProperty(lines, 'autoAttach', config.autoAttach);
|
|
1643
|
+
appendConfigProperty(lines, 'debug', config.debug);
|
|
1644
|
+
appendConfigProperty(lines, 'contextLimit', config.contextLimit);
|
|
1645
|
+
appendConfigProperty(lines, 'toolRoundLimit', config.toolRoundLimit);
|
|
1646
|
+
appendConfigProperty(lines, 'patchRetries', config.patchRetries);
|
|
1647
|
+
appendConfigProperty(lines, 'maxFileSize', config.maxFileSize);
|
|
1648
|
+
appendConfigProperty(lines, 'maxScanSize', config.maxScanSize);
|
|
1649
|
+
appendConfigProperty(lines, 'maxUrlSize', config.maxUrlSize);
|
|
1650
|
+
appendConfigProperty(lines, 'summaryPhases', config.summaryPhases);
|
|
1651
|
+
appendConfigProperty(lines, 'summarizeTriggerPercent', config.summarizeTriggerPercent);
|
|
1652
|
+
|
|
1653
|
+
lines.push('');
|
|
1654
|
+
lines.push(' // Shell execution settings');
|
|
1655
|
+
appendConfigProperty(lines, 'shell', config.shell);
|
|
1656
|
+
|
|
1657
|
+
lines.push('');
|
|
1658
|
+
lines.push(' // Model response visibility');
|
|
1659
|
+
appendConfigProperty(lines, 'thinking', config.thinking);
|
|
1660
|
+
appendConfigProperty(lines, 'streaming', config.streaming);
|
|
1661
|
+
|
|
1662
|
+
lines.push('');
|
|
1663
|
+
lines.push(' // Frontend style and layout');
|
|
1664
|
+
appendConfigProperty(lines, 'ui', config.ui);
|
|
1665
|
+
|
|
1666
|
+
lines.push('');
|
|
1667
|
+
lines.push(' // Chunked / windowed reading');
|
|
1668
|
+
lines.push(' // Keeps context small: agent reads relevant line ranges (e.g. 40-80) instead of whole files.');
|
|
1669
|
+
lines.push(' // Set enabled to false to disable chunk grouping in regex_search and the read_chunk tool.');
|
|
1670
|
+
appendConfigProperty(lines, 'chunking', config.chunking);
|
|
1671
|
+
|
|
1672
|
+
lines.push('');
|
|
1673
|
+
lines.push(' // Voice input (Whisper)');
|
|
1674
|
+
lines.push(' // Requires whisper.cpp `whisper-cli` and ffmpeg (or sox) on PATH, plus a ggml-*.bin model file.');
|
|
1675
|
+
lines.push(' // Trigger from the chat with /voice record [seconds] or /voice file <path>.');
|
|
1676
|
+
appendConfigProperty(lines, 'voice', config.voice);
|
|
1677
|
+
|
|
1678
|
+
lines.push('');
|
|
1679
|
+
lines.push(' // Prompt customization');
|
|
1680
|
+
lines.push(' // prompt.system.* controls the assistant system prompt blocks');
|
|
1681
|
+
lines.push(' // prompt.ui.* controls startup/model-picker/help labels');
|
|
1682
|
+
lines.push(' // prompt.questions.* controls interactive questions and confirmations');
|
|
1683
|
+
appendConfigProperty(lines, 'prompt', config.prompt, { trailingComma: false });
|
|
1684
|
+
|
|
1685
|
+
lines.push('}');
|
|
1686
|
+
lines.push('');
|
|
1687
|
+
return lines.join('\n');
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1292
1690
|
// Load config (settings like autoAttach and context summarization)
|
|
1293
1691
|
function loadConfig() {
|
|
1294
1692
|
try {
|
|
1295
1693
|
ensureSapperDir();
|
|
1296
1694
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
1297
|
-
const
|
|
1695
|
+
const fileText = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
1696
|
+
const rawConfig = JSON.parse(stripJsonComments(fileText));
|
|
1298
1697
|
const normalizedConfig = normalizeConfig(rawConfig);
|
|
1299
1698
|
if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) {
|
|
1300
|
-
fs.writeFileSync(CONFIG_FILE,
|
|
1699
|
+
fs.writeFileSync(CONFIG_FILE, renderConfigFile(normalizedConfig));
|
|
1301
1700
|
}
|
|
1302
1701
|
return normalizedConfig;
|
|
1303
1702
|
}
|
|
@@ -1307,7 +1706,7 @@ function loadConfig() {
|
|
|
1307
1706
|
try {
|
|
1308
1707
|
ensureSapperDir();
|
|
1309
1708
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
1310
|
-
fs.writeFileSync(CONFIG_FILE,
|
|
1709
|
+
fs.writeFileSync(CONFIG_FILE, renderConfigFile(defaultConfig));
|
|
1311
1710
|
}
|
|
1312
1711
|
} catch (e) {}
|
|
1313
1712
|
return defaultConfig;
|
|
@@ -1316,7 +1715,7 @@ function loadConfig() {
|
|
|
1316
1715
|
function saveConfig(config) {
|
|
1317
1716
|
ensureSapperDir();
|
|
1318
1717
|
const normalizedConfig = normalizeConfig(config);
|
|
1319
|
-
fs.writeFileSync(CONFIG_FILE,
|
|
1718
|
+
fs.writeFileSync(CONFIG_FILE, renderConfigFile(normalizedConfig));
|
|
1320
1719
|
sapperConfig = normalizedConfig;
|
|
1321
1720
|
}
|
|
1322
1721
|
|
|
@@ -1410,6 +1809,32 @@ function getPromptConfig() {
|
|
|
1410
1809
|
return normalizePromptConfig(sapperConfig.prompt);
|
|
1411
1810
|
}
|
|
1412
1811
|
|
|
1812
|
+
function getPromptTemplate(path, fallback = '', variables = {}) {
|
|
1813
|
+
const promptConfig = getPromptConfig();
|
|
1814
|
+
const value = String(path || '')
|
|
1815
|
+
.split('.')
|
|
1816
|
+
.filter(Boolean)
|
|
1817
|
+
.reduce((current, key) => {
|
|
1818
|
+
if (!current || typeof current !== 'object') return undefined;
|
|
1819
|
+
return current[key];
|
|
1820
|
+
}, promptConfig);
|
|
1821
|
+
|
|
1822
|
+
const template = typeof value === 'string' && value.trim() ? value : fallback;
|
|
1823
|
+
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
|
|
1824
|
+
const replacement = variables[key];
|
|
1825
|
+
return replacement === undefined || replacement === null ? '' : String(replacement);
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function promptQuestion(path, fallback, variables = {}, tone = 'cyan') {
|
|
1830
|
+
const resolved = getPromptTemplate(path, fallback, variables);
|
|
1831
|
+
return tone === 'cyan' ? chalk.cyan(resolved) : resolved;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function promptLabel(path, fallback, variables = {}) {
|
|
1835
|
+
return getPromptTemplate(path, fallback, variables);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1413
1838
|
function getThinkingConfig() {
|
|
1414
1839
|
return normalizeThinkingConfig(sapperConfig.thinking);
|
|
1415
1840
|
}
|
|
@@ -1418,6 +1843,59 @@ function getStreamingConfig() {
|
|
|
1418
1843
|
return normalizeStreamingConfig(sapperConfig.streaming);
|
|
1419
1844
|
}
|
|
1420
1845
|
|
|
1846
|
+
function getUIConfig() {
|
|
1847
|
+
return normalizeUIConfig(sapperConfig.ui);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function uiCompactMode() {
|
|
1851
|
+
const mode = getUIConfig().compactMode;
|
|
1852
|
+
if (mode === 'on') return true;
|
|
1853
|
+
if (mode === 'off') return false;
|
|
1854
|
+
const cols = process.stdout.columns || 100;
|
|
1855
|
+
const rows = process.stdout.rows || 30;
|
|
1856
|
+
return cols <= 100 || rows <= 28;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function uiStyle() {
|
|
1860
|
+
return getUIConfig().style;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function uiCleanMode() {
|
|
1864
|
+
return uiStyle() === 'clean' || uiStyle() === 'ultra';
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function uiUltraCleanMode() {
|
|
1868
|
+
return uiStyle() === 'ultra';
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function getChunkingConfig() {
|
|
1872
|
+
return normalizeChunkingConfig(sapperConfig.chunking);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function chunkingEnabled() {
|
|
1876
|
+
return getChunkingConfig().enabled;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function chunkingContextLines() {
|
|
1880
|
+
return getChunkingConfig().contextLines;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function chunkingMaxPerFile() {
|
|
1884
|
+
return getChunkingConfig().maxChunksPerFile;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function chunkingAutoAboveLines() {
|
|
1888
|
+
return getChunkingConfig().autoChunkAboveLines;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function getVoiceConfig() {
|
|
1892
|
+
return normalizeVoiceConfig(sapperConfig.voice);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function voiceEnabled() {
|
|
1896
|
+
return getVoiceConfig().enabled;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1421
1899
|
function streamPhaseStatusEnabled() {
|
|
1422
1900
|
return getStreamingConfig().showPhaseStatus;
|
|
1423
1901
|
}
|
|
@@ -1608,6 +2086,27 @@ function saveWorkspaceGraph(workspace) {
|
|
|
1608
2086
|
fs.writeFileSync(WORKSPACE_FILE, JSON.stringify(workspace, null, 2));
|
|
1609
2087
|
}
|
|
1610
2088
|
|
|
2089
|
+
async function buildWorkspaceGraph(dir = '.') {
|
|
2090
|
+
const scanResult = scanCodebase(dir);
|
|
2091
|
+
const files = {};
|
|
2092
|
+
const graph = {};
|
|
2093
|
+
|
|
2094
|
+
for (const file of scanResult.files) {
|
|
2095
|
+
if (file.skipped || !file.content) continue;
|
|
2096
|
+
|
|
2097
|
+
const symbols = extractSymbolsWithRegex(file.content, file.path);
|
|
2098
|
+
const deps = extractDependencies(file.content, file.path);
|
|
2099
|
+
const exports = extractExports(file.content, file.path);
|
|
2100
|
+
|
|
2101
|
+
files[file.path] = { symbols, exports, size: file.size };
|
|
2102
|
+
graph[file.path] = deps;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
const workspace = { indexed: new Date().toISOString(), files, graph };
|
|
2106
|
+
saveWorkspaceGraph(workspace);
|
|
2107
|
+
return workspace;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1611
2110
|
// Extract imports/requires from file content
|
|
1612
2111
|
function extractDependencies(content, filePath) {
|
|
1613
2112
|
const deps = new Set();
|
|
@@ -1649,18 +2148,9 @@ function extractExports(content, filePath) {
|
|
|
1649
2148
|
if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
1650
2149
|
// export function/class/const name
|
|
1651
2150
|
const namedExports = content.matchAll(/export\s+(?:function|class|const|let|var|async function)\s+(\w+)/g);
|
|
1652
|
-
for (const m of namedExports)
|
|
1653
|
-
|
|
1654
|
-
// export { name }
|
|
1655
|
-
const bracketExports = content.matchAll(/export\s*\{([^}]+)\}/g);
|
|
1656
|
-
for (const m of bracketExports) {
|
|
1657
|
-
m[1].split(',').forEach(e => {
|
|
1658
|
-
const name = e.trim().split(/\s+as\s+/)[0].trim();
|
|
1659
|
-
if (name) exports.add(name);
|
|
1660
|
-
});
|
|
2151
|
+
for (const m of namedExports) {
|
|
2152
|
+
exports.add(m[1]);
|
|
1661
2153
|
}
|
|
1662
|
-
|
|
1663
|
-
// export default
|
|
1664
2154
|
if (content.includes('export default')) exports.add('default');
|
|
1665
2155
|
}
|
|
1666
2156
|
|
|
@@ -1668,85 +2158,6 @@ function extractExports(content, filePath) {
|
|
|
1668
2158
|
}
|
|
1669
2159
|
|
|
1670
2160
|
// Resolve relative import to actual file path
|
|
1671
|
-
function resolveImportPath(importPath, fromFile) {
|
|
1672
|
-
if (!importPath.startsWith('.')) return null;
|
|
1673
|
-
|
|
1674
|
-
const fromDir = dirname(fromFile);
|
|
1675
|
-
let resolved = join(fromDir, importPath).replace(/\\/g, '/');
|
|
1676
|
-
|
|
1677
|
-
// Try common extensions
|
|
1678
|
-
const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '.mjs', '/index.js', '/index.ts'];
|
|
1679
|
-
for (const ext of extensions) {
|
|
1680
|
-
const fullPath = resolved + ext;
|
|
1681
|
-
if (fs.existsSync(fullPath)) {
|
|
1682
|
-
return fullPath.replace(/^\.\//, '');
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
return null;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
// Build workspace graph from codebase
|
|
1689
|
-
async function buildWorkspaceGraph(showProgress = true) {
|
|
1690
|
-
const workspace = { indexed: new Date().toISOString(), files: {}, graph: {} };
|
|
1691
|
-
|
|
1692
|
-
function scanDir(dir, depth = 0) {
|
|
1693
|
-
if (depth > 5) return;
|
|
1694
|
-
|
|
1695
|
-
try {
|
|
1696
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1697
|
-
|
|
1698
|
-
for (const entry of entries) {
|
|
1699
|
-
const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
1700
|
-
|
|
1701
|
-
if (entry.isDirectory()) {
|
|
1702
|
-
if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
|
|
1703
|
-
scanDir(fullPath, depth + 1);
|
|
1704
|
-
} else {
|
|
1705
|
-
if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
|
|
1706
|
-
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
1707
|
-
if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
|
|
1708
|
-
|
|
1709
|
-
try {
|
|
1710
|
-
const stats = fs.statSync(fullPath);
|
|
1711
|
-
if (stats.size > getMaxFileSize()) continue;
|
|
1712
|
-
|
|
1713
|
-
const content = fs.readFileSync(fullPath, 'utf8');
|
|
1714
|
-
const deps = extractDependencies(content, fullPath);
|
|
1715
|
-
const exports = extractExports(content, fullPath);
|
|
1716
|
-
|
|
1717
|
-
// Generate brief summary (first meaningful lines)
|
|
1718
|
-
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('#'));
|
|
1719
|
-
const summary = lines.slice(0, 3).join(' ').substring(0, 150);
|
|
1720
|
-
|
|
1721
|
-
workspace.files[fullPath] = {
|
|
1722
|
-
size: stats.size,
|
|
1723
|
-
modified: stats.mtime.toISOString(),
|
|
1724
|
-
imports: deps,
|
|
1725
|
-
exports: exports,
|
|
1726
|
-
symbols: parseFileSymbols(content, fullPath), // AST-extracted symbols
|
|
1727
|
-
summary: summary || '(no summary)'
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
|
-
// Build dependency graph
|
|
1731
|
-
workspace.graph[fullPath] = [];
|
|
1732
|
-
for (const dep of deps) {
|
|
1733
|
-
const resolved = resolveImportPath(dep, fullPath);
|
|
1734
|
-
if (resolved) {
|
|
1735
|
-
workspace.graph[fullPath].push(resolved);
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
} catch (e) {}
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
} catch (e) {}
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
scanDir('.');
|
|
1745
|
-
saveWorkspaceGraph(workspace);
|
|
1746
|
-
return workspace;
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
// Get related files for a given file (imports + files that import it)
|
|
1750
2161
|
function getRelatedFiles(filePath, workspace, depth = 1) {
|
|
1751
2162
|
const related = new Set();
|
|
1752
2163
|
|
|
@@ -1763,10 +2174,9 @@ function getRelatedFiles(filePath, workspace, depth = 1) {
|
|
|
1763
2174
|
|
|
1764
2175
|
// Second level if depth > 1
|
|
1765
2176
|
if (depth > 1) {
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
secondImports.forEach(sf => related.add(sf));
|
|
2177
|
+
for (const imported of imports) {
|
|
2178
|
+
const secondLevel = workspace.graph[imported] || [];
|
|
2179
|
+
secondLevel.forEach(f => related.add(f));
|
|
1770
2180
|
}
|
|
1771
2181
|
}
|
|
1772
2182
|
|
|
@@ -2118,6 +2528,119 @@ async function addToEmbeddings(text, embeddings) {
|
|
|
2118
2528
|
}
|
|
2119
2529
|
}
|
|
2120
2530
|
|
|
2531
|
+
function longMemoryTemplate() {
|
|
2532
|
+
return `# Sapper Long Memory
|
|
2533
|
+
|
|
2534
|
+
This file stores durable project notes, patterns, and decisions.
|
|
2535
|
+
Sapper can write and search this file with /memory commands and memory-note tools.
|
|
2536
|
+
|
|
2537
|
+
## Notes
|
|
2538
|
+
|
|
2539
|
+
`;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function ensureLongMemoryFile() {
|
|
2543
|
+
ensureSapperDir();
|
|
2544
|
+
if (!fs.existsSync(LONG_MEMORY_FILE)) {
|
|
2545
|
+
fs.writeFileSync(LONG_MEMORY_FILE, longMemoryTemplate());
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function loadLongMemoryText() {
|
|
2550
|
+
try {
|
|
2551
|
+
ensureLongMemoryFile();
|
|
2552
|
+
return fs.readFileSync(LONG_MEMORY_FILE, 'utf8');
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
return longMemoryTemplate();
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
function normalizeMemoryTags(tags) {
|
|
2559
|
+
const rawTags = Array.isArray(tags)
|
|
2560
|
+
? tags
|
|
2561
|
+
: String(tags ?? '').split(',');
|
|
2562
|
+
return Array.from(new Set(
|
|
2563
|
+
rawTags
|
|
2564
|
+
.map(tag => String(tag ?? '').trim().toLowerCase())
|
|
2565
|
+
.filter(tag => tag.length > 0)
|
|
2566
|
+
.map(tag => tag.replace(/\s+/g, '-'))
|
|
2567
|
+
)).slice(0, 8);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function inferMemoryTitle(content) {
|
|
2571
|
+
const singleLine = String(content ?? '').replace(/\s+/g, ' ').trim();
|
|
2572
|
+
if (!singleLine) return 'Untitled note';
|
|
2573
|
+
const sentence = singleLine.split(/[.!?]/)[0].trim();
|
|
2574
|
+
return (sentence || singleLine).slice(0, 80);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function getLongMemorySections() {
|
|
2578
|
+
const raw = loadLongMemoryText();
|
|
2579
|
+
return raw
|
|
2580
|
+
.split(/\n(?=##\s)/g)
|
|
2581
|
+
.map(section => section.trim())
|
|
2582
|
+
.filter(section => section.startsWith('## '));
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function appendLongMemoryNote({ title, content, tags = [], source = 'manual' } = {}) {
|
|
2586
|
+
const cleanContent = String(content ?? '').trim();
|
|
2587
|
+
if (!cleanContent) {
|
|
2588
|
+
return { ok: false, error: 'Note content is required.' };
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
const cleanTitle = String(title ?? '').trim() || inferMemoryTitle(cleanContent);
|
|
2592
|
+
const cleanTags = normalizeMemoryTags(tags);
|
|
2593
|
+
const timestamp = new Date().toISOString();
|
|
2594
|
+
const lines = [
|
|
2595
|
+
`## ${timestamp} | ${cleanTitle}`,
|
|
2596
|
+
`- Tags: ${cleanTags.length ? cleanTags.join(', ') : 'general'}`,
|
|
2597
|
+
`- Source: ${source}`,
|
|
2598
|
+
`- Project: ${PROJECT_ROOT}`,
|
|
2599
|
+
'',
|
|
2600
|
+
cleanContent,
|
|
2601
|
+
];
|
|
2602
|
+
|
|
2603
|
+
ensureLongMemoryFile();
|
|
2604
|
+
const existing = loadLongMemoryText().trimEnd();
|
|
2605
|
+
fs.writeFileSync(LONG_MEMORY_FILE, `${existing}\n\n${lines.join('\n')}\n`);
|
|
2606
|
+
|
|
2607
|
+
return {
|
|
2608
|
+
ok: true,
|
|
2609
|
+
title: cleanTitle,
|
|
2610
|
+
tags: cleanTags,
|
|
2611
|
+
timestamp,
|
|
2612
|
+
path: LONG_MEMORY_FILE,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function searchLongMemoryNotes(query, limit = 5) {
|
|
2617
|
+
const cleanQuery = String(query ?? '').trim().toLowerCase();
|
|
2618
|
+
if (!cleanQuery) return [];
|
|
2619
|
+
|
|
2620
|
+
const words = Array.from(new Set(cleanQuery.split(/[^a-z0-9_]+/i).filter(Boolean)));
|
|
2621
|
+
const sections = getLongMemorySections();
|
|
2622
|
+
const scored = sections.map((section) => {
|
|
2623
|
+
const lowered = section.toLowerCase();
|
|
2624
|
+
let score = 0;
|
|
2625
|
+
if (lowered.includes(cleanQuery)) score += 5;
|
|
2626
|
+
for (const word of words) {
|
|
2627
|
+
if (word.length >= 2 && lowered.includes(word)) score += 1;
|
|
2628
|
+
}
|
|
2629
|
+
return { section, score };
|
|
2630
|
+
}).filter(item => item.score > 0);
|
|
2631
|
+
|
|
2632
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2633
|
+
return scored.slice(0, Math.max(1, limit)).map(item => item.section);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
function listLongMemoryNotes(limit = 8) {
|
|
2637
|
+
return getLongMemorySections()
|
|
2638
|
+
.slice(-Math.max(1, limit))
|
|
2639
|
+
.reverse()
|
|
2640
|
+
.map(section => section.split('\n')[0]?.replace(/^##\s*/, '').trim())
|
|
2641
|
+
.filter(Boolean);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2121
2644
|
// ═══════════════════════════════════════════════════════════════
|
|
2122
2645
|
// SMART CONTEXT SUMMARIZATION
|
|
2123
2646
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -2435,13 +2958,21 @@ function formatRelativeTime(value) {
|
|
|
2435
2958
|
return 'just now';
|
|
2436
2959
|
}
|
|
2437
2960
|
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2961
|
+
function bannerText() {
|
|
2962
|
+
return [
|
|
2963
|
+
`${chalk.hex('#c8ecff').bold(promptLabel('ui.bannerTitle', 'Sapper'))} ${UI.slate(promptLabel('ui.bannerSubtitle', 'terminal coding workspace'))}`,
|
|
2964
|
+
UI.slate(promptLabel('ui.bannerTagline', 'Model selection, live tools, and focused sessions in one loop')),
|
|
2965
|
+
].join('\n');
|
|
2966
|
+
}
|
|
2442
2967
|
|
|
2443
2968
|
function box(content, title = '', tone = 'cyan', options = {}) {
|
|
2444
2969
|
const width = Math.max(28, Math.min(options.width || terminalWidth(72), terminalWidth(72)));
|
|
2970
|
+
if (uiCleanMode()) {
|
|
2971
|
+
const cleanTitle = String(title || '').replace(/[^\x20-\x7E]/g, '').replace(/\s+/g, ' ').trim();
|
|
2972
|
+
const line = UI.slate('-'.repeat(Math.max(12, width)));
|
|
2973
|
+
const header = cleanTitle ? `${chalk.white(cleanTitle)}\n${line}\n` : '';
|
|
2974
|
+
return `${header}${String(content ?? '')}\n${line}`;
|
|
2975
|
+
}
|
|
2445
2976
|
const header = title ? `${toneColor(tone).bold(title)}\n${divider('─', tone, width)}\n` : '';
|
|
2446
2977
|
return `${header}${String(content ?? '')}\n${divider('─', tone, width)}`;
|
|
2447
2978
|
}
|
|
@@ -2463,10 +2994,158 @@ function keyValue(label, value, width = 12) {
|
|
|
2463
2994
|
return `${padAnsi(UI.slate(label), width)} ${value}`;
|
|
2464
2995
|
}
|
|
2465
2996
|
|
|
2997
|
+
function piRow(label, value, width = 13) {
|
|
2998
|
+
return `${padAnsi(UI.slate(`[${label}]`), width)} ${value}`;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
2466
3001
|
function commandRow(command, description, width = 18) {
|
|
2467
3002
|
return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
|
|
2468
3003
|
}
|
|
2469
3004
|
|
|
3005
|
+
const COMMAND_GROUPS = Object.freeze([
|
|
3006
|
+
{
|
|
3007
|
+
title: 'Core',
|
|
3008
|
+
subtitle: 'daily workflow',
|
|
3009
|
+
tone: 'cyan',
|
|
3010
|
+
rows: [
|
|
3011
|
+
['@ or /attach', 'Pick files to attach interactively'],
|
|
3012
|
+
['@file', 'Attach a file inline, for example @src/app.js'],
|
|
3013
|
+
['/scan', 'Scan the codebase into context'],
|
|
3014
|
+
['/index', 'Rebuild the workspace graph'],
|
|
3015
|
+
['/graph file', 'Show related files from the graph'],
|
|
3016
|
+
['/symbol name', 'Search indexed functions and classes'],
|
|
3017
|
+
['/auto', 'Toggle automatic related-file attach'],
|
|
3018
|
+
],
|
|
3019
|
+
},
|
|
3020
|
+
{
|
|
3021
|
+
title: 'Context',
|
|
3022
|
+
subtitle: 'memory and visibility',
|
|
3023
|
+
tone: 'cyan',
|
|
3024
|
+
rows: [
|
|
3025
|
+
['/model', 'Open model picker to switch AI mid-session'],
|
|
3026
|
+
['/model <name>', 'Switch directly to a named model (e.g. /model llama3.2)'],
|
|
3027
|
+
['/recall', 'Search memory for relevant context'],
|
|
3028
|
+
['/memory', 'Manage markdown long-memory notes and patterns'],
|
|
3029
|
+
['/memory add title ::: note', 'Save a durable note to .sapper/long-memory.md'],
|
|
3030
|
+
['/fetch <url>', 'Fetch a web page into context'],
|
|
3031
|
+
['/reset /clear', 'Clear all current context'],
|
|
3032
|
+
['/prune', 'Summarize long context and store memory'],
|
|
3033
|
+
['/summary', 'Show or change auto-summary settings'],
|
|
3034
|
+
['/chunking', 'Toggle chunked reading (search-then-window) and context size'],
|
|
3035
|
+
['/voice, /v', 'Voice input via Whisper (record from mic or transcribe a file)'],
|
|
3036
|
+
['/v live', 'Live preview while you speak, clean final transcript on stop'],
|
|
3037
|
+
['/v models', 'List available Whisper models and pick one interactively'],
|
|
3038
|
+
['/v record', 'Record — press any key to stop, then transcribe'],
|
|
3039
|
+
['/v record <secs>', 'Record for a fixed duration, then transcribe'],
|
|
3040
|
+
['/ui', 'Show or change frontend style and compact mode'],
|
|
3041
|
+
['/ui style clean', 'Switch to a clean Codex/OpenCode-like frontend'],
|
|
3042
|
+
['/ui style ultra', 'Switch to an ultra-clean single-line frontend'],
|
|
3043
|
+
['/shell', 'Inspect shell config and background sessions'],
|
|
3044
|
+
['/shell read <id>', 'Read output from a tracked shell session'],
|
|
3045
|
+
['/shell stop <id>', 'Stop a tracked shell session'],
|
|
3046
|
+
['/context', 'Inspect token usage, summary trigger, and model window'],
|
|
3047
|
+
['/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'],
|
|
3048
|
+
['/debug', 'Toggle regex and tool debug output'],
|
|
3049
|
+
['/log', 'Show the session activity timeline'],
|
|
3050
|
+
['/log stats', 'Show session statistics'],
|
|
3051
|
+
['/log file', 'Show log file path and history'],
|
|
3052
|
+
['/help', 'Open command guide'],
|
|
3053
|
+
['/commands', 'Alias for /help'],
|
|
3054
|
+
['exit', 'Quit Sapper'],
|
|
3055
|
+
],
|
|
3056
|
+
},
|
|
3057
|
+
{
|
|
3058
|
+
title: 'Agents',
|
|
3059
|
+
subtitle: 'specialist modes and skills',
|
|
3060
|
+
tone: 'cyan',
|
|
3061
|
+
rows: [
|
|
3062
|
+
['/agents', 'List available agents'],
|
|
3063
|
+
['/skills', 'List available skills'],
|
|
3064
|
+
['/agentname', 'Switch to an agent such as /reviewer'],
|
|
3065
|
+
['/default', 'Return to the default Sapper role'],
|
|
3066
|
+
['/use skill', 'Load a skill into the session'],
|
|
3067
|
+
['/unload skill', 'Unload a previously loaded skill'],
|
|
3068
|
+
['/newagent', 'Create a new agent'],
|
|
3069
|
+
['/newskill', 'Create a new skill'],
|
|
3070
|
+
],
|
|
3071
|
+
},
|
|
3072
|
+
]);
|
|
3073
|
+
|
|
3074
|
+
const COMMAND_LOOKUP = Object.freeze(
|
|
3075
|
+
Array.from(new Set(COMMAND_GROUPS.flatMap(group => group.rows.map(([command]) => command))))
|
|
3076
|
+
);
|
|
3077
|
+
|
|
3078
|
+
function renderCommandPalette() {
|
|
3079
|
+
const lines = [];
|
|
3080
|
+
for (const group of COMMAND_GROUPS) {
|
|
3081
|
+
if (lines.length > 0) lines.push('');
|
|
3082
|
+
lines.push(sectionTitle(group.title, group.subtitle, group.tone));
|
|
3083
|
+
for (const [command, description] of group.rows) {
|
|
3084
|
+
lines.push(commandRow(command, description));
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
lines.push(divider());
|
|
3089
|
+
lines.push(UI.slate(' Summary settings: /summary | /summary phases off | /summary trigger 60'));
|
|
3090
|
+
lines.push(UI.slate(' Tool config: .sapper/config.json -> toolRoundLimit (default 40)'));
|
|
3091
|
+
lines.push(UI.slate(' Shell config: .sapper/config.json -> shell.streamToModel, shell.backgroundMode [off|auto|on], shell.backgroundAfterSeconds, shell.outputChunkChars'));
|
|
3092
|
+
lines.push(UI.slate(' Want to see all live shell output? Set shell.backgroundMode to off. thinking.mode only controls model reasoning.'));
|
|
3093
|
+
lines.push(UI.slate(' Streaming config: .sapper/config.json -> streaming.showPhaseStatus, streaming.showHeartbeat, streaming.idleNoticeSeconds'));
|
|
3094
|
+
lines.push(UI.slate(' Thinking config: .sapper/config.json -> thinking.mode [auto|on|off]'));
|
|
3095
|
+
lines.push(UI.slate(' UI config: .sapper/config.json -> ui.style [sapper|clean|ultra], ui.compactMode [auto|on|off]'));
|
|
3096
|
+
lines.push(UI.slate(' Prompt config: .sapper/config.json -> prompt.prepend, prompt.append, prompt.coreOverride, prompt.system.*, prompt.ui.*, prompt.questions.*'));
|
|
3097
|
+
|
|
3098
|
+
return lines.join('\n');
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function levenshteinDistance(a = '', b = '') {
|
|
3102
|
+
const left = String(a);
|
|
3103
|
+
const right = String(b);
|
|
3104
|
+
if (left === right) return 0;
|
|
3105
|
+
if (!left.length) return right.length;
|
|
3106
|
+
if (!right.length) return left.length;
|
|
3107
|
+
|
|
3108
|
+
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
|
|
3109
|
+
const current = new Array(right.length + 1);
|
|
3110
|
+
|
|
3111
|
+
for (let i = 1; i <= left.length; i++) {
|
|
3112
|
+
current[0] = i;
|
|
3113
|
+
for (let j = 1; j <= right.length; j++) {
|
|
3114
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
3115
|
+
current[j] = Math.min(
|
|
3116
|
+
current[j - 1] + 1,
|
|
3117
|
+
previous[j] + 1,
|
|
3118
|
+
previous[j - 1] + cost
|
|
3119
|
+
);
|
|
3120
|
+
}
|
|
3121
|
+
for (let j = 0; j <= right.length; j++) {
|
|
3122
|
+
previous[j] = current[j];
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
return previous[right.length];
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function suggestSlashCommands(inputValue = '', maxSuggestions = 4) {
|
|
3130
|
+
const normalized = inputValue.trim().toLowerCase().replace(/^\/+/, '');
|
|
3131
|
+
if (!normalized) return [];
|
|
3132
|
+
|
|
3133
|
+
const scored = COMMAND_LOOKUP.map(command => {
|
|
3134
|
+
const token = command.toLowerCase().replace(/^\/+/, '').split(/\s+/)[0];
|
|
3135
|
+
const score = token.startsWith(normalized)
|
|
3136
|
+
? 0
|
|
3137
|
+
: normalized.startsWith(token)
|
|
3138
|
+
? 1
|
|
3139
|
+
: levenshteinDistance(normalized, token);
|
|
3140
|
+
return { command, score };
|
|
3141
|
+
}).sort((a, b) => a.score - b.score || a.command.length - b.command.length);
|
|
3142
|
+
|
|
3143
|
+
return scored
|
|
3144
|
+
.filter(item => item.score <= Math.max(2, Math.ceil(normalized.length / 2)))
|
|
3145
|
+
.slice(0, maxSuggestions)
|
|
3146
|
+
.map(item => item.command);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
2470
3149
|
function renderViewport(content, { verticalAlign = 'top', minTopPadding = 0 } = {}) {
|
|
2471
3150
|
const text = String(content ?? '').replace(/\n+$/, '');
|
|
2472
3151
|
const rows = Math.max(12, process.stdout.rows || 24);
|
|
@@ -2499,7 +3178,11 @@ function ellipsis(text = '', max = 48) {
|
|
|
2499
3178
|
}
|
|
2500
3179
|
|
|
2501
3180
|
function promptShell(label, detail = '') {
|
|
2502
|
-
|
|
3181
|
+
if (uiCleanMode()) {
|
|
3182
|
+
const body = detail ? `${UI.slate(label)}\n${UI.slate(detail)}` : UI.slate(label);
|
|
3183
|
+
return `${body}\n${UI.accent('› ')} `;
|
|
3184
|
+
}
|
|
3185
|
+
return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('> ')} `;
|
|
2503
3186
|
}
|
|
2504
3187
|
|
|
2505
3188
|
function renderedTerminalLineCount(text = '', width = process.stdout.columns || 80) {
|
|
@@ -2758,7 +3441,7 @@ async function handleShellSessionCommand(command = '') {
|
|
|
2758
3441
|
}
|
|
2759
3442
|
|
|
2760
3443
|
console.log();
|
|
2761
|
-
const confirmation = await safeQuestion(confirmPrompt(
|
|
3444
|
+
const confirmation = await safeQuestion(confirmPrompt(promptLabel('questions.stopBackgroundShellSession', 'Stop background shell session {id}', { id: session.id }), 'error', '[y/N] '));
|
|
2762
3445
|
if (!['y', 'yes'].includes(String(confirmation ?? '').trim().toLowerCase())) {
|
|
2763
3446
|
return `Stop request cancelled for shell session ${session.id}.`;
|
|
2764
3447
|
}
|
|
@@ -3009,6 +3692,39 @@ let modelContextLength = null; // Detected from ollama.show() model_info
|
|
|
3009
3692
|
let lastPromptTokens = 0; // prompt_eval_count from last response
|
|
3010
3693
|
let lastEvalTokens = 0; // eval_count from last response
|
|
3011
3694
|
|
|
3695
|
+
const SLASH_COMPLETION_COMMANDS = Object.freeze(Array.from(new Set(
|
|
3696
|
+
COMMAND_GROUPS
|
|
3697
|
+
.flatMap(group => group.rows.map(([command]) => command))
|
|
3698
|
+
.flatMap(command => (String(command).match(/\/[a-z0-9-]+/gi) || []).map(token => token.toLowerCase()))
|
|
3699
|
+
.concat(['/commands', '/cmd'])
|
|
3700
|
+
)).sort());
|
|
3701
|
+
|
|
3702
|
+
function buildReadlineCompleter() {
|
|
3703
|
+
return (line) => {
|
|
3704
|
+
const raw = String(line || '');
|
|
3705
|
+
const trimmed = raw.trimStart();
|
|
3706
|
+
|
|
3707
|
+
if (!trimmed.startsWith('/')) {
|
|
3708
|
+
return [[], line];
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
// Complete only the first token (command) to avoid interfering with free-form args.
|
|
3712
|
+
const commandToken = trimmed.split(/\s+/)[0].toLowerCase();
|
|
3713
|
+
const hits = SLASH_COMPLETION_COMMANDS.filter(cmd => cmd.startsWith(commandToken));
|
|
3714
|
+
return [hits.length ? hits : SLASH_COMPLETION_COMMANDS, commandToken];
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
function createReadlineInterface() {
|
|
3719
|
+
return readline.createInterface({
|
|
3720
|
+
input: process.stdin,
|
|
3721
|
+
output: process.stdout,
|
|
3722
|
+
terminal: true,
|
|
3723
|
+
historySize: 100,
|
|
3724
|
+
completer: buildReadlineCompleter(),
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3012
3728
|
// Estimate token count from text (~4 chars per token for English, ~3 for code)
|
|
3013
3729
|
// This is a rough heuristic - actual counts come from Ollama response stats
|
|
3014
3730
|
function estimateTokens(text) {
|
|
@@ -3030,21 +3746,11 @@ function estimateMessagesTokens(messages) {
|
|
|
3030
3746
|
}
|
|
3031
3747
|
return total;
|
|
3032
3748
|
}
|
|
3033
|
-
let rl =
|
|
3034
|
-
input: process.stdin,
|
|
3035
|
-
output: process.stdout,
|
|
3036
|
-
terminal: true,
|
|
3037
|
-
historySize: 100
|
|
3038
|
-
});
|
|
3749
|
+
let rl = createReadlineInterface();
|
|
3039
3750
|
|
|
3040
3751
|
function recreateReadline() {
|
|
3041
3752
|
if (rl) rl.close();
|
|
3042
|
-
rl =
|
|
3043
|
-
input: process.stdin,
|
|
3044
|
-
output: process.stdout,
|
|
3045
|
-
terminal: true,
|
|
3046
|
-
historySize: 100
|
|
3047
|
-
});
|
|
3753
|
+
rl = createReadlineInterface();
|
|
3048
3754
|
// Force resume stdin to keep process alive
|
|
3049
3755
|
process.stdin.resume();
|
|
3050
3756
|
}
|
|
@@ -3668,7 +4374,12 @@ async function pickModel(models) {
|
|
|
3668
4374
|
if (!models || models.length === 0) return null;
|
|
3669
4375
|
|
|
3670
4376
|
let cursor = 0;
|
|
3671
|
-
const
|
|
4377
|
+
const compact = uiCompactMode();
|
|
4378
|
+
const clean = uiCleanMode();
|
|
4379
|
+
const ultra = uiUltraCleanMode();
|
|
4380
|
+
const pageSize = compact
|
|
4381
|
+
? Math.max(4, Math.min(7, (process.stdout.rows || 24) - 16))
|
|
4382
|
+
: Math.max(5, Math.min(8, (process.stdout.rows || 24) - 14));
|
|
3672
4383
|
|
|
3673
4384
|
if (process.stdin.isTTY) {
|
|
3674
4385
|
process.stdin.setRawMode(true);
|
|
@@ -3677,13 +4388,27 @@ async function pickModel(models) {
|
|
|
3677
4388
|
|
|
3678
4389
|
const render = () => {
|
|
3679
4390
|
const current = models[cursor];
|
|
3680
|
-
const lines =
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
4391
|
+
const lines = ultra
|
|
4392
|
+
? [
|
|
4393
|
+
`${chalk.white(promptLabel('ui.bannerTitle', 'Sapper'))} ${UI.slate(promptLabel('ui.modelPickerUltraTitle', 'models'))}`,
|
|
4394
|
+
`${UI.slate(safeCwd())}`,
|
|
4395
|
+
''
|
|
4396
|
+
]
|
|
4397
|
+
: clean
|
|
4398
|
+
? [
|
|
4399
|
+
`${chalk.white(promptLabel('ui.bannerTitle', 'Sapper'))} ${UI.slate(promptLabel('ui.modelPickerCleanTitle', 'model picker'))}`,
|
|
4400
|
+
`${UI.slate(safeCwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`,
|
|
4401
|
+
divider('─', 'gray', terminalWidth(70)),
|
|
4402
|
+
sectionTitle(promptLabel('ui.modelPickerSectionTitle', 'Model'), promptLabel('ui.modelPickerSubtitle', 'use ↑↓ or j/k, enter to confirm'), 'gray'),
|
|
4403
|
+
''
|
|
4404
|
+
]
|
|
4405
|
+
: [
|
|
4406
|
+
bannerText(),
|
|
4407
|
+
`${UI.slate(safeCwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`,
|
|
4408
|
+
divider(),
|
|
4409
|
+
sectionTitle(promptLabel('ui.modelPickerTitle', 'Model selection'), promptLabel('ui.modelPickerSubtitle', 'use ↑↓ or j/k, enter to confirm'), 'cyan'),
|
|
4410
|
+
''
|
|
4411
|
+
];
|
|
3687
4412
|
|
|
3688
4413
|
const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
|
|
3689
4414
|
const endIdx = Math.min(startIdx + pageSize, models.length);
|
|
@@ -3717,14 +4442,30 @@ async function pickModel(models) {
|
|
|
3717
4442
|
const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
|
|
3718
4443
|
const quant = current.details?.quantization_level || current.details?.quantization || 'default';
|
|
3719
4444
|
lines.push('');
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
'
|
|
3727
|
-
)
|
|
4445
|
+
if (ultra) {
|
|
4446
|
+
lines.push(
|
|
4447
|
+
`${UI.slate('selected')} ${chalk.white.bold(current.name)} ${UI.slate('·')} ` +
|
|
4448
|
+
`${UI.slate(current.size ? formatBytes(current.size) : 'unknown')} ${UI.slate('·')} ` +
|
|
4449
|
+
`${UI.slate(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown')}`
|
|
4450
|
+
);
|
|
4451
|
+
lines.push(UI.slate('enter confirm · q cancel'));
|
|
4452
|
+
} else if (clean) {
|
|
4453
|
+
lines.push(
|
|
4454
|
+
`${UI.slate('selected')} ${chalk.white.bold(current.name)} ${UI.slate('·')} ` +
|
|
4455
|
+
`${UI.slate('size')} ${UI.ink(current.size ? formatBytes(current.size) : 'unknown')} ${UI.slate('·')} ` +
|
|
4456
|
+
`${UI.slate('updated')} ${UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown')} ${UI.slate('·')} ` +
|
|
4457
|
+
`${UI.slate('profile')} ${UI.ink(family)} ${UI.slate('·')} ${UI.slate('quant')} ${UI.ink(quant)}`
|
|
4458
|
+
);
|
|
4459
|
+
} else {
|
|
4460
|
+
lines.push(box(
|
|
4461
|
+
`${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
|
|
4462
|
+
`${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
|
|
4463
|
+
`${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
|
|
4464
|
+
`${keyValue('Profile', UI.ink(family), 10)}\n` +
|
|
4465
|
+
`${keyValue('Quant', UI.ink(quant), 10)}`,
|
|
4466
|
+
'Preview', 'gray'
|
|
4467
|
+
));
|
|
4468
|
+
}
|
|
3728
4469
|
|
|
3729
4470
|
renderViewport(lines.join('\n'), { verticalAlign: 'center' });
|
|
3730
4471
|
};
|
|
@@ -3774,10 +4515,10 @@ async function pickModel(models) {
|
|
|
3774
4515
|
});
|
|
3775
4516
|
}
|
|
3776
4517
|
|
|
3777
|
-
let toolWorkingDirectory =
|
|
4518
|
+
let toolWorkingDirectory = PROJECT_ROOT;
|
|
3778
4519
|
|
|
3779
4520
|
function getToolWorkingDirectory() {
|
|
3780
|
-
return toolWorkingDirectory ||
|
|
4521
|
+
return toolWorkingDirectory || PROJECT_ROOT;
|
|
3781
4522
|
}
|
|
3782
4523
|
|
|
3783
4524
|
function resolveToolPath(pathValue = '.', { allowEmpty = false } = {}) {
|
|
@@ -3794,7 +4535,7 @@ function resolveToolPath(pathValue = '.', { allowEmpty = false } = {}) {
|
|
|
3794
4535
|
}
|
|
3795
4536
|
const resolved = isAbsolute(rawPath) ? rawPath : pathResolve(getToolWorkingDirectory(), rawPath);
|
|
3796
4537
|
// Prevent path traversal outside the project directory
|
|
3797
|
-
const projectRoot =
|
|
4538
|
+
const projectRoot = PROJECT_ROOT;
|
|
3798
4539
|
if (!resolved.startsWith(projectRoot + '/') && resolved !== projectRoot) {
|
|
3799
4540
|
return projectRoot; // Fall back to project root for paths that escape sandbox
|
|
3800
4541
|
}
|
|
@@ -3899,123 +4640,888 @@ function findPathsByName(patternValue, startPathValue = '.') {
|
|
|
3899
4640
|
return `${header}\n${body}${truncated}`;
|
|
3900
4641
|
}
|
|
3901
4642
|
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
4643
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4644
|
+
// REGEX SEARCH — advanced source-code search using JS RegExp
|
|
4645
|
+
// Accepts /pattern/flags or raw pattern (default flags = 'i').
|
|
4646
|
+
// Optional include filter: comma-separated extensions (js,ts) or
|
|
4647
|
+
// path substrings (src/,routes). Returns file:line:col matches
|
|
4648
|
+
// with capture groups, capped to keep context small.
|
|
4649
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4650
|
+
function parseRegexPattern(rawPattern) {
|
|
4651
|
+
const raw = String(rawPattern ?? '').trim();
|
|
4652
|
+
if (!raw) return { error: 'Missing regex pattern' };
|
|
4653
|
+
|
|
4654
|
+
let body = raw;
|
|
4655
|
+
let flags = 'i';
|
|
4656
|
+
const delimMatch = raw.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
4657
|
+
if (delimMatch) {
|
|
4658
|
+
body = delimMatch[1];
|
|
4659
|
+
flags = delimMatch[2] || 'i';
|
|
4660
|
+
}
|
|
4661
|
+
// Always evaluate per-line so we can report line numbers; strip a user-supplied 'g'.
|
|
4662
|
+
flags = flags.replace(/g/g, '');
|
|
4663
|
+
|
|
4664
|
+
try {
|
|
4665
|
+
return { regex: new RegExp(body, flags), body, flags };
|
|
4666
|
+
} catch (error) {
|
|
4667
|
+
return { error: `Invalid regex: ${error.message}` };
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
|
|
4671
|
+
function matchesIncludeFilter(relativePath, includeList) {
|
|
4672
|
+
if (!includeList || includeList.length === 0) return true;
|
|
4673
|
+
const lower = relativePath.toLowerCase();
|
|
4674
|
+
for (const token of includeList) {
|
|
4675
|
+
const norm = token.toLowerCase();
|
|
4676
|
+
if (!norm) continue;
|
|
4677
|
+
// Extension-only token (e.g. "js" or ".js")
|
|
4678
|
+
if (/^\.?[a-z0-9]+$/i.test(norm)) {
|
|
4679
|
+
const ext = norm.startsWith('.') ? norm : `.${norm}`;
|
|
4680
|
+
if (lower.endsWith(ext)) return true;
|
|
4681
|
+
} else if (lower.includes(norm)) {
|
|
4682
|
+
return true;
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
return false;
|
|
3907
4686
|
}
|
|
3908
4687
|
|
|
3909
|
-
|
|
3910
|
-
|
|
4688
|
+
const REGEX_TEXT_EXTENSIONS = new Set([
|
|
4689
|
+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs',
|
|
4690
|
+
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'scala',
|
|
4691
|
+
'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'swift', 'm', 'mm',
|
|
4692
|
+
'php', 'pl', 'lua', 'sh', 'bash', 'zsh', 'fish', 'ps1',
|
|
4693
|
+
'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte',
|
|
4694
|
+
'json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'env',
|
|
4695
|
+
'md', 'mdx', 'txt', 'rst', 'tex',
|
|
4696
|
+
'sql', 'graphql', 'gql', 'proto', 'xml',
|
|
4697
|
+
]);
|
|
4698
|
+
|
|
4699
|
+
function isLikelyTextFile(name) {
|
|
4700
|
+
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
|
4701
|
+
if (!ext) return false;
|
|
4702
|
+
return REGEX_TEXT_EXTENSIONS.has(ext);
|
|
3911
4703
|
}
|
|
3912
4704
|
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
let stderrTruncated = false;
|
|
3920
|
-
let finished = false;
|
|
4705
|
+
// Read a specific line range from a file (1-based, inclusive).
|
|
4706
|
+
// If start/end are omitted, returns the whole file capped at maxLines.
|
|
4707
|
+
// Always emits gutter-style line numbers so the agent knows exact positions.
|
|
4708
|
+
function readChunk(pathValue, startValue, endValue, contextValue) {
|
|
4709
|
+
const trimmedPath = typeof pathValue === 'string' ? pathValue.trim() : '';
|
|
4710
|
+
if (!trimmedPath) return 'Error reading chunk: missing file path';
|
|
3921
4711
|
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
if (chunkText.length > remaining) {
|
|
3929
|
-
setTruncated(true);
|
|
3930
|
-
return existing + chunkText.slice(0, remaining);
|
|
3931
|
-
}
|
|
3932
|
-
return existing + chunkText;
|
|
3933
|
-
};
|
|
4712
|
+
let content;
|
|
4713
|
+
try {
|
|
4714
|
+
content = fs.readFileSync(resolveToolPath(trimmedPath), 'utf8');
|
|
4715
|
+
} catch (error) {
|
|
4716
|
+
return `Error reading chunk: ${error.message}`;
|
|
4717
|
+
}
|
|
3934
4718
|
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
const finalStdout = stdoutTruncated
|
|
3940
|
-
? `${stdout}\n... (stdout truncated at ${maxOutput.toLocaleString()} chars)`
|
|
3941
|
-
: stdout;
|
|
3942
|
-
const finalStderr = stderrTruncated
|
|
3943
|
-
? `${stderr}\n... (stderr truncated at ${maxOutput.toLocaleString()} chars)`
|
|
3944
|
-
: stderr;
|
|
3945
|
-
resolve({ ...result, stdout: finalStdout, stderr: finalStderr });
|
|
3946
|
-
};
|
|
4719
|
+
const lines = content === '' ? [] : content.split('\n');
|
|
4720
|
+
if (lines.length === 0) {
|
|
4721
|
+
return `Chunk of ${trimmedPath}: (empty file)`;
|
|
4722
|
+
}
|
|
3947
4723
|
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
finish({ exitCode: 124, stdout, stderr: `${stderr}\nCommand timed out after ${timeoutMs}ms`.trim(), timedOut: true });
|
|
3952
|
-
}, timeoutMs)
|
|
3953
|
-
: null;
|
|
4724
|
+
const ctx = Number.isFinite(Number(contextValue)) ? Math.max(0, Math.round(Number(contextValue))) : 0;
|
|
4725
|
+
let start = Number(startValue);
|
|
4726
|
+
let end = Number(endValue);
|
|
3954
4727
|
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
});
|
|
3958
|
-
proc.stderr.on('data', (data) => {
|
|
3959
|
-
stderr = appendLimited(stderr, data.toString(), maxOutput, (value) => { stderrTruncated = value; });
|
|
3960
|
-
});
|
|
4728
|
+
if (!Number.isFinite(start) || start < 1) start = 1;
|
|
4729
|
+
if (!Number.isFinite(end) || end < start) end = Math.min(lines.length, start + 60);
|
|
3961
4730
|
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
4731
|
+
// Expand by context window when caller asked for it
|
|
4732
|
+
start = Math.max(1, start - ctx);
|
|
4733
|
+
end = Math.min(lines.length, end + ctx);
|
|
4734
|
+
|
|
4735
|
+
const MAX_CHUNK_LINES = 400;
|
|
4736
|
+
if (end - start + 1 > MAX_CHUNK_LINES) {
|
|
4737
|
+
end = start + MAX_CHUNK_LINES - 1;
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
const gutterWidth = String(end).length;
|
|
4741
|
+
const slice = lines.slice(start - 1, end).map((line, idx) => {
|
|
4742
|
+
const lineNo = String(start + idx).padStart(gutterWidth, ' ');
|
|
4743
|
+
return `${lineNo} | ${line}`;
|
|
3968
4744
|
});
|
|
4745
|
+
|
|
4746
|
+
const header = `Chunk of ${trimmedPath} (lines ${start}-${end} of ${lines.length}):`;
|
|
4747
|
+
const footer = end < lines.length || start > 1
|
|
4748
|
+
? `\n... (${start > 1 ? `${start - 1} line(s) before` : ''}${start > 1 && end < lines.length ? ', ' : ''}${end < lines.length ? `${lines.length - end} line(s) after` : ''})`
|
|
4749
|
+
: '';
|
|
4750
|
+
return `${header}\n${slice.join('\n')}${footer}`;
|
|
3969
4751
|
}
|
|
3970
4752
|
|
|
3971
|
-
function
|
|
3972
|
-
const
|
|
3973
|
-
if (
|
|
4753
|
+
function regexSearch(patternValue, includeValue = '', startPathValue = '.') {
|
|
4754
|
+
const parsed = parseRegexPattern(patternValue);
|
|
4755
|
+
if (parsed.error) return `Error: ${parsed.error}`;
|
|
4756
|
+
const { regex, body, flags } = parsed;
|
|
3974
4757
|
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
}
|
|
3979
|
-
return trimmed;
|
|
3980
|
-
}
|
|
4758
|
+
const startPath = typeof startPathValue === 'string' && startPathValue.trim() ? startPathValue.trim() : '.';
|
|
4759
|
+
const resolvedStart = resolveToolPath(startPath);
|
|
4760
|
+
if (!fs.existsSync(resolvedStart)) {
|
|
4761
|
+
return `Error: ${startPath} does not exist`;
|
|
3981
4762
|
}
|
|
3982
4763
|
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
4764
|
+
const includeList = String(includeValue ?? '')
|
|
4765
|
+
.split(',')
|
|
4766
|
+
.map(s => s.trim())
|
|
4767
|
+
.filter(Boolean);
|
|
3986
4768
|
|
|
3987
|
-
|
|
3988
|
-
|
|
4769
|
+
const useChunks = chunkingEnabled();
|
|
4770
|
+
const ctxLines = chunkingContextLines();
|
|
4771
|
+
const maxChunksFile = chunkingMaxPerFile();
|
|
3989
4772
|
|
|
3990
|
-
|
|
3991
|
-
const
|
|
3992
|
-
|
|
4773
|
+
const MAX_MATCHES = 80;
|
|
4774
|
+
const MAX_FILES_SCANNED = 2000;
|
|
4775
|
+
const MAX_FILE_BYTES = 512 * 1024; // 512KB — skip huge files
|
|
4776
|
+
const MAX_LINE_PREVIEW = 240;
|
|
3993
4777
|
|
|
3994
|
-
const
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
4778
|
+
const matches = [];
|
|
4779
|
+
const chunkBlocks = []; // { file, start, end, body }
|
|
4780
|
+
let filesScanned = 0;
|
|
4781
|
+
let filesMatched = 0;
|
|
4782
|
+
let truncated = false;
|
|
3998
4783
|
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
for (const word of queryWords) {
|
|
4006
|
-
if (lowered.includes(word)) score += 1;
|
|
4007
|
-
}
|
|
4008
|
-
return { ...chunk, score: Math.min(1, score / maxScore) };
|
|
4009
|
-
})
|
|
4010
|
-
.filter(chunk => chunk.score > 0)
|
|
4011
|
-
.sort((a, b) => b.score - a.score || (b.timestamp || 0) - (a.timestamp || 0))
|
|
4012
|
-
.slice(0, topK);
|
|
4013
|
-
}
|
|
4784
|
+
const visit = (dirPath, displayPrefix = '') => {
|
|
4785
|
+
if (truncated) return;
|
|
4786
|
+
let entries = [];
|
|
4787
|
+
try {
|
|
4788
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
4789
|
+
} catch { return; }
|
|
4014
4790
|
|
|
4015
|
-
const
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4791
|
+
for (const entry of entries) {
|
|
4792
|
+
if (truncated) return;
|
|
4793
|
+
if (entry.name.startsWith('.')) continue;
|
|
4794
|
+
if (shouldIgnore(entry.name)) continue;
|
|
4795
|
+
|
|
4796
|
+
const fullPath = join(dirPath, entry.name);
|
|
4797
|
+
const relativePath = displayPrefix ? `${displayPrefix}/${entry.name}` : entry.name;
|
|
4798
|
+
if (shouldIgnore(relativePath)) continue;
|
|
4799
|
+
|
|
4800
|
+
if (entry.isDirectory()) {
|
|
4801
|
+
visit(fullPath, relativePath);
|
|
4802
|
+
continue;
|
|
4803
|
+
}
|
|
4804
|
+
if (!entry.isFile()) continue;
|
|
4805
|
+
if (!isLikelyTextFile(entry.name)) continue;
|
|
4806
|
+
if (!matchesIncludeFilter(relativePath, includeList)) continue;
|
|
4807
|
+
|
|
4808
|
+
filesScanned++;
|
|
4809
|
+
if (filesScanned > MAX_FILES_SCANNED) { truncated = true; return; }
|
|
4810
|
+
|
|
4811
|
+
let stat;
|
|
4812
|
+
try { stat = fs.statSync(fullPath); } catch { continue; }
|
|
4813
|
+
if (stat.size > MAX_FILE_BYTES) continue;
|
|
4814
|
+
|
|
4815
|
+
let content;
|
|
4816
|
+
try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
4817
|
+
|
|
4818
|
+
const lines = content.split('\n');
|
|
4819
|
+
let fileMatchCount = 0;
|
|
4820
|
+
const fileMatchLines = []; // 1-based line numbers of hits in this file
|
|
4821
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4822
|
+
const line = lines[i];
|
|
4823
|
+
regex.lastIndex = 0;
|
|
4824
|
+
const m = line.match(regex);
|
|
4825
|
+
if (!m) continue;
|
|
4826
|
+
|
|
4827
|
+
const col = (m.index ?? 0) + 1;
|
|
4828
|
+
let preview = line.trim();
|
|
4829
|
+
if (preview.length > MAX_LINE_PREVIEW) {
|
|
4830
|
+
preview = `${preview.slice(0, MAX_LINE_PREVIEW)}…`;
|
|
4831
|
+
}
|
|
4832
|
+
let entryLine = `${relativePath}:${i + 1}:${col}: ${preview}`;
|
|
4833
|
+
if (m.length > 1) {
|
|
4834
|
+
const groups = m.slice(1).map((g, idx) => `$${idx + 1}=${g === undefined ? '∅' : JSON.stringify(g)}`).join(' ');
|
|
4835
|
+
entryLine += `\n └─ ${groups}`;
|
|
4836
|
+
}
|
|
4837
|
+
matches.push(entryLine);
|
|
4838
|
+
fileMatchLines.push(i + 1);
|
|
4839
|
+
fileMatchCount++;
|
|
4840
|
+
|
|
4841
|
+
if (matches.length >= MAX_MATCHES) { truncated = true; break; }
|
|
4842
|
+
if (fileMatchCount >= 10) break; // Cap per-file noise
|
|
4843
|
+
}
|
|
4844
|
+
if (fileMatchCount > 0) filesMatched++;
|
|
4845
|
+
|
|
4846
|
+
// Group nearby matches into chunk windows (lines ± ctxLines)
|
|
4847
|
+
if (useChunks && fileMatchLines.length > 0) {
|
|
4848
|
+
const groups = [];
|
|
4849
|
+
let curStart = Math.max(1, fileMatchLines[0] - ctxLines);
|
|
4850
|
+
let curEnd = Math.min(lines.length, fileMatchLines[0] + ctxLines);
|
|
4851
|
+
for (let k = 1; k < fileMatchLines.length; k++) {
|
|
4852
|
+
const ln = fileMatchLines[k];
|
|
4853
|
+
const winStart = Math.max(1, ln - ctxLines);
|
|
4854
|
+
const winEnd = Math.min(lines.length, ln + ctxLines);
|
|
4855
|
+
if (winStart <= curEnd + 1) {
|
|
4856
|
+
curEnd = Math.max(curEnd, winEnd);
|
|
4857
|
+
} else {
|
|
4858
|
+
groups.push([curStart, curEnd]);
|
|
4859
|
+
curStart = winStart;
|
|
4860
|
+
curEnd = winEnd;
|
|
4861
|
+
if (groups.length >= maxChunksFile) break;
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
if (groups.length < maxChunksFile) groups.push([curStart, curEnd]);
|
|
4865
|
+
|
|
4866
|
+
const gutterWidthMax = String(lines.length).length;
|
|
4867
|
+
for (const [s, e] of groups.slice(0, maxChunksFile)) {
|
|
4868
|
+
const sliceText = lines.slice(s - 1, e).map((ln, idx) => {
|
|
4869
|
+
const num = String(s + idx).padStart(gutterWidthMax, ' ');
|
|
4870
|
+
return `${num} | ${ln}`;
|
|
4871
|
+
}).join('\n');
|
|
4872
|
+
chunkBlocks.push({ file: relativePath, start: s, end: e, body: sliceText });
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
};
|
|
4877
|
+
|
|
4878
|
+
let startStat;
|
|
4879
|
+
try { startStat = fs.statSync(resolvedStart); } catch (e) { return `Error: ${e.message}`; }
|
|
4880
|
+
if (startStat.isFile()) {
|
|
4881
|
+
visit(dirname(resolvedStart), '');
|
|
4882
|
+
} else {
|
|
4883
|
+
visit(resolvedStart);
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
if (matches.length === 0) {
|
|
4887
|
+
return `No regex matches for /${body}/${flags}${includeList.length ? ` (filter: ${includeList.join(',')})` : ''}\nFiles scanned: ${filesScanned}`;
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
const header = `Found ${matches.length}${truncated ? '+' : ''} match${matches.length === 1 ? '' : 'es'} for /${body}/${flags}` +
|
|
4891
|
+
` in ${filesMatched} file${filesMatched === 1 ? '' : 's'} (scanned ${filesScanned})` +
|
|
4892
|
+
`${includeList.length ? ` · filter: ${includeList.join(',')}` : ''}` +
|
|
4893
|
+
`${useChunks ? ` · chunked ±${ctxLines} lines` : ''}`;
|
|
4894
|
+
const footer = truncated ? `\n... (results truncated at ${MAX_MATCHES} matches)` : '';
|
|
4895
|
+
|
|
4896
|
+
let output = `${header}\n${matches.join('\n')}${footer}`;
|
|
4897
|
+
|
|
4898
|
+
if (useChunks && chunkBlocks.length > 0) {
|
|
4899
|
+
const MAX_TOTAL_CHUNKS = 25;
|
|
4900
|
+
const shown = chunkBlocks.slice(0, MAX_TOTAL_CHUNKS);
|
|
4901
|
+
const chunkText = shown.map(c =>
|
|
4902
|
+
`── ${c.file}:${c.start}-${c.end} ──\n${c.body}`
|
|
4903
|
+
).join('\n\n');
|
|
4904
|
+
const chunkFooter = chunkBlocks.length > shown.length
|
|
4905
|
+
? `\n\n... (${chunkBlocks.length - shown.length} more chunk(s) omitted — narrow the pattern or use read_chunk for specific ranges)`
|
|
4906
|
+
: '';
|
|
4907
|
+
output += `\n\n=== Code chunks (±${ctxLines} lines of context) ===\n${chunkText}${chunkFooter}`;
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4910
|
+
return output;
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
function truncateToolText(textValue = '', maxChars = 24000) {
|
|
4914
|
+
const text = String(textValue ?? '').trim();
|
|
4915
|
+
if (!text) return '';
|
|
4916
|
+
if (text.length <= maxChars) return text;
|
|
4917
|
+
return `${text.slice(0, maxChars)}\n... (output truncated at ${maxChars.toLocaleString()} chars)`;
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4920
|
+
function shellQuote(value = '') {
|
|
4921
|
+
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4925
|
+
// VOICE INPUT (Whisper)
|
|
4926
|
+
// Uses whisper.cpp `whisper-cli` for transcription and ffmpeg (or
|
|
4927
|
+
// sox) to capture from the microphone. Triggered by /voice.
|
|
4928
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4929
|
+
function recordAudioToFile(outPath, { seconds, recorder, device, sampleRate }) {
|
|
4930
|
+
return new Promise((resolve) => {
|
|
4931
|
+
let cmd, args;
|
|
4932
|
+
if (recorder === 'sox') {
|
|
4933
|
+
// `rec` reads from default mic
|
|
4934
|
+
cmd = 'rec';
|
|
4935
|
+
args = ['-q', '-c', '1', '-r', String(sampleRate), outPath, 'trim', '0', String(seconds)];
|
|
4936
|
+
} else {
|
|
4937
|
+
// ffmpeg + avfoundation on macOS, alsa on linux. We default to macOS-style ':0'.
|
|
4938
|
+
const platform = process.platform;
|
|
4939
|
+
const inputFmt = platform === 'darwin' ? 'avfoundation'
|
|
4940
|
+
: platform === 'linux' ? 'alsa'
|
|
4941
|
+
: 'dshow';
|
|
4942
|
+
cmd = 'ffmpeg';
|
|
4943
|
+
args = [
|
|
4944
|
+
'-loglevel', 'error',
|
|
4945
|
+
'-y',
|
|
4946
|
+
'-f', inputFmt,
|
|
4947
|
+
'-i', device,
|
|
4948
|
+
'-t', String(seconds),
|
|
4949
|
+
'-ar', String(sampleRate),
|
|
4950
|
+
'-ac', '1',
|
|
4951
|
+
outPath,
|
|
4952
|
+
];
|
|
4953
|
+
}
|
|
4954
|
+
const proc = spawn(cmd, args);
|
|
4955
|
+
let stderr = '';
|
|
4956
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
4957
|
+
proc.on('error', (err) => resolve({ ok: false, error: `Failed to start ${cmd}: ${err.message}` }));
|
|
4958
|
+
proc.on('close', (code) => {
|
|
4959
|
+
if (code === 0 && fs.existsSync(outPath)) resolve({ ok: true });
|
|
4960
|
+
else resolve({ ok: false, error: stderr.trim() || `${cmd} exited with code ${code}` });
|
|
4961
|
+
});
|
|
4962
|
+
});
|
|
4963
|
+
}
|
|
4964
|
+
|
|
4965
|
+
// Push-to-stop recording: records until the user presses any key (or Ctrl-C).
|
|
4966
|
+
// Returns { ok, error?, durationMs }.
|
|
4967
|
+
function recordAudioUntilKey(outPath, { recorder, device, sampleRate, maxSeconds = 600 }) {
|
|
4968
|
+
return new Promise((resolve) => {
|
|
4969
|
+
let cmd, args;
|
|
4970
|
+
const platform = process.platform;
|
|
4971
|
+
if (recorder === 'sox') {
|
|
4972
|
+
cmd = 'rec';
|
|
4973
|
+
args = ['-q', '-c', '1', '-r', String(sampleRate), outPath, 'trim', '0', String(maxSeconds)];
|
|
4974
|
+
} else {
|
|
4975
|
+
const inputFmt = platform === 'darwin' ? 'avfoundation'
|
|
4976
|
+
: platform === 'linux' ? 'alsa'
|
|
4977
|
+
: 'dshow';
|
|
4978
|
+
cmd = 'ffmpeg';
|
|
4979
|
+
args = [
|
|
4980
|
+
'-loglevel', 'error',
|
|
4981
|
+
'-y',
|
|
4982
|
+
'-f', inputFmt,
|
|
4983
|
+
'-i', device,
|
|
4984
|
+
'-t', String(maxSeconds),
|
|
4985
|
+
'-ar', String(sampleRate),
|
|
4986
|
+
'-ac', '1',
|
|
4987
|
+
outPath,
|
|
4988
|
+
];
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
4992
|
+
let stderr = '';
|
|
4993
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
4994
|
+
|
|
4995
|
+
// Pause readline so its keypress listeners don't eat our input.
|
|
4996
|
+
const rlWasPaused = rl ? rl.paused : true;
|
|
4997
|
+
try { if (rl && !rl.paused) rl.pause(); } catch {}
|
|
4998
|
+
|
|
4999
|
+
const wasRaw = !!process.stdin.isRaw;
|
|
5000
|
+
let listenerAttached = false;
|
|
5001
|
+
let stopped = false;
|
|
5002
|
+
const startTs = Date.now();
|
|
5003
|
+
|
|
5004
|
+
const cleanupStdin = () => {
|
|
5005
|
+
try { process.stdin.removeListener('data', onKey); } catch {}
|
|
5006
|
+
try { if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw); } catch {}
|
|
5007
|
+
listenerAttached = false;
|
|
5008
|
+
};
|
|
5009
|
+
|
|
5010
|
+
const stop = () => {
|
|
5011
|
+
if (stopped) return;
|
|
5012
|
+
stopped = true;
|
|
5013
|
+
cleanupStdin();
|
|
5014
|
+
if (recorder === 'sox') {
|
|
5015
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
5016
|
+
} else {
|
|
5017
|
+
// ffmpeg: send 'q' on stdin for a clean shutdown (writes a valid WAV).
|
|
5018
|
+
try { proc.stdin.write('q'); } catch {}
|
|
5019
|
+
// Hard-stop fallback if it doesn't exit on its own.
|
|
5020
|
+
const t = setTimeout(() => { try { proc.kill('SIGINT'); } catch {} }, 1500);
|
|
5021
|
+
if (t.unref) t.unref();
|
|
5022
|
+
}
|
|
5023
|
+
};
|
|
5024
|
+
|
|
5025
|
+
const onKey = (chunk) => {
|
|
5026
|
+
// Ctrl-C → also stop (and let SIGINT bubble below via parent handlers)
|
|
5027
|
+
stop();
|
|
5028
|
+
};
|
|
5029
|
+
|
|
5030
|
+
// Ticker
|
|
5031
|
+
let tickInterval = null;
|
|
5032
|
+
const drawTick = () => {
|
|
5033
|
+
const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
|
|
5034
|
+
process.stdout.write('\r' + chalk.red('🔴 ') + chalk.cyan(`Recording ${elapsed}s `) + chalk.gray('— press any key to stop'));
|
|
5035
|
+
};
|
|
5036
|
+
const clearTick = () => {
|
|
5037
|
+
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
|
5038
|
+
// Clear the status line
|
|
5039
|
+
process.stdout.write('\r' + ' '.repeat(70) + '\r');
|
|
5040
|
+
};
|
|
5041
|
+
|
|
5042
|
+
try {
|
|
5043
|
+
if (process.stdin.isTTY) {
|
|
5044
|
+
process.stdin.setRawMode(true);
|
|
5045
|
+
process.stdin.resume();
|
|
5046
|
+
process.stdin.on('data', onKey);
|
|
5047
|
+
listenerAttached = true;
|
|
5048
|
+
drawTick();
|
|
5049
|
+
tickInterval = setInterval(drawTick, 100);
|
|
5050
|
+
} else {
|
|
5051
|
+
console.log(chalk.yellow(`No TTY — recording up to ${maxSeconds}s then auto-stopping.`));
|
|
5052
|
+
}
|
|
5053
|
+
} catch {}
|
|
5054
|
+
|
|
5055
|
+
proc.on('error', (err) => {
|
|
5056
|
+
clearTick();
|
|
5057
|
+
cleanupStdin();
|
|
5058
|
+
try { if (rl && !rlWasPaused) rl.resume(); } catch {}
|
|
5059
|
+
resolve({ ok: false, error: `Failed to start ${cmd}: ${err.message}` });
|
|
5060
|
+
});
|
|
5061
|
+
|
|
5062
|
+
proc.on('close', (code) => {
|
|
5063
|
+
clearTick();
|
|
5064
|
+
cleanupStdin();
|
|
5065
|
+
try { if (rl && !rlWasPaused) rl.resume(); } catch {}
|
|
5066
|
+
const durationMs = Date.now() - startTs;
|
|
5067
|
+
// ffmpeg sometimes exits non-zero when killed; accept if the file exists and isn't tiny.
|
|
5068
|
+
const fileOk = fs.existsSync(outPath) && (() => {
|
|
5069
|
+
try { return fs.statSync(outPath).size > 1000; } catch { return false; }
|
|
5070
|
+
})();
|
|
5071
|
+
if (fileOk || (code === 0 && fs.existsSync(outPath))) {
|
|
5072
|
+
return resolve({ ok: true, durationMs });
|
|
5073
|
+
}
|
|
5074
|
+
resolve({ ok: false, error: stderr.trim() || `${cmd} exited with code ${code}` });
|
|
5075
|
+
});
|
|
5076
|
+
});
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
function runWhisperCli(audioPath, { whisperBin, model, language, translate }) {
|
|
5080
|
+
return new Promise((resolve) => {
|
|
5081
|
+
if (!fs.existsSync(model)) {
|
|
5082
|
+
return resolve({ ok: false, error: `Whisper model not found: ${model}` });
|
|
5083
|
+
}
|
|
5084
|
+
if (!fs.existsSync(audioPath)) {
|
|
5085
|
+
return resolve({ ok: false, error: `Audio file not found: ${audioPath}` });
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
// whisper-cli writes <audio>.txt next to the input when --output-txt is set.
|
|
5089
|
+
const outTxt = `${audioPath}.txt`;
|
|
5090
|
+
try { if (fs.existsSync(outTxt)) fs.unlinkSync(outTxt); } catch {}
|
|
5091
|
+
|
|
5092
|
+
const args = [
|
|
5093
|
+
'-m', model,
|
|
5094
|
+
'-f', audioPath,
|
|
5095
|
+
'-l', language || 'auto',
|
|
5096
|
+
'-nt', // no timestamps
|
|
5097
|
+
'-np', // no progress / debug prints
|
|
5098
|
+
'-sns', // suppress non-speech tokens (kills "you"/"Thank you" hallucinations)
|
|
5099
|
+
'--no-fallback', // don't escalate temperature into garbage
|
|
5100
|
+
'--temperature', '0', // deterministic decoding
|
|
5101
|
+
'-bs', '5', // beam search
|
|
5102
|
+
'-bo', '5', // best-of
|
|
5103
|
+
'--output-txt', // write <audio>.txt
|
|
5104
|
+
];
|
|
5105
|
+
if (translate) args.push('-tr');
|
|
5106
|
+
|
|
5107
|
+
const proc = spawn(whisperBin, args);
|
|
5108
|
+
let stderr = '';
|
|
5109
|
+
let stdout = '';
|
|
5110
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
5111
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
5112
|
+
proc.on('error', (err) => resolve({ ok: false, error: `Failed to start ${whisperBin}: ${err.message}` }));
|
|
5113
|
+
proc.on('close', (code) => {
|
|
5114
|
+
if (code !== 0) {
|
|
5115
|
+
return resolve({ ok: false, error: stderr.trim() || `${whisperBin} exited with code ${code}` });
|
|
5116
|
+
}
|
|
5117
|
+
let text = '';
|
|
5118
|
+
try {
|
|
5119
|
+
if (fs.existsSync(outTxt)) text = fs.readFileSync(outTxt, 'utf8');
|
|
5120
|
+
} catch {}
|
|
5121
|
+
if (!text.trim()) text = stdout; // fallback
|
|
5122
|
+
try { if (fs.existsSync(outTxt)) fs.unlinkSync(outTxt); } catch {}
|
|
5123
|
+
// Strip whisper's silence/non-speech markers, timestamps, and common
|
|
5124
|
+
// hallucinations that surface on silence (multilingual).
|
|
5125
|
+
let cleaned = text
|
|
5126
|
+
.replace(/\r/g, '')
|
|
5127
|
+
.replace(/\[\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}\]/g, '')
|
|
5128
|
+
.replace(/\[BLANK_AUDIO\]/gi, '')
|
|
5129
|
+
.replace(/\(.*?silence.*?\)/gi, '')
|
|
5130
|
+
// Common subtitle/end-card hallucinations Whisper learned from training data:
|
|
5131
|
+
.replace(/Продолжение следует\.{0,3}/gi, '')
|
|
5132
|
+
.replace(/Thanks? for watching!?/gi, '')
|
|
5133
|
+
.replace(/Subtitles by[^\n.]*/gi, '')
|
|
5134
|
+
.replace(/Subtitles? (made|provided) by[^\n.]*/gi, '')
|
|
5135
|
+
.replace(/ترجمة[^\n.]*/g, '')
|
|
5136
|
+
.replace(/字幕[^\n。]*/g, '')
|
|
5137
|
+
.replace(/시청해주셔서 감사합니다/g, '')
|
|
5138
|
+
.replace(/ご視聴ありがとうございました/g, '')
|
|
5139
|
+
.replace(/\s+/g, ' ')
|
|
5140
|
+
.trim();
|
|
5141
|
+
// If the entire result is just a bare hallucination word, drop it.
|
|
5142
|
+
const HALLUCINATION_WHOLE = /^(you|thanks?|thank you\.?|okay\.?|um\.?|uh\.?|\.+|bye\.?|hi\.?)$/i;
|
|
5143
|
+
if (HALLUCINATION_WHOLE.test(cleaned)) cleaned = '';
|
|
5144
|
+
resolve({ ok: true, text: cleaned });
|
|
5145
|
+
});
|
|
5146
|
+
});
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
// Live transcription using whisper-stream. Streams partial text to the terminal
|
|
5150
|
+
// as the user is speaking. Press any key to stop. Returns the cleaned full
|
|
5151
|
+
// transcript on stop.
|
|
5152
|
+
function runWhisperLive({ whisperStreamBin, model, language, translate, stepMs, lengthMs, keepMs }) {
|
|
5153
|
+
return new Promise((resolve) => {
|
|
5154
|
+
if (!fs.existsSync(model)) {
|
|
5155
|
+
return resolve({ ok: false, error: `Whisper model not found: ${model}` });
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5158
|
+
// We ALWAYS save audio in live mode. The streamed text is for live preview
|
|
5159
|
+
// only — the final transcript comes from a single clean whisper-cli pass on
|
|
5160
|
+
// the saved audio (no overlap-induced duplicates).
|
|
5161
|
+
const saStartTs = Date.now();
|
|
5162
|
+
const saTmpDir = fs.mkdtempSync(join(os.tmpdir(), 'sapper-live-sa-'));
|
|
5163
|
+
|
|
5164
|
+
const args = [
|
|
5165
|
+
'-m', model,
|
|
5166
|
+
'-c', '-1', // default capture device
|
|
5167
|
+
'--step', String(stepMs),
|
|
5168
|
+
'--length', String(lengthMs),
|
|
5169
|
+
'--keep', String(keepMs),
|
|
5170
|
+
'-l', language || 'auto', // whisper-stream supports 'auto' (real-time language detection)
|
|
5171
|
+
'--keep-context',
|
|
5172
|
+
'-sa', // save the captured audio (we'll re-transcribe it)
|
|
5173
|
+
];
|
|
5174
|
+
if (translate) args.push('-tr');
|
|
5175
|
+
|
|
5176
|
+
let proc;
|
|
5177
|
+
try {
|
|
5178
|
+
proc = spawn(whisperStreamBin, args, {
|
|
5179
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
5180
|
+
cwd: saTmpDir,
|
|
5181
|
+
});
|
|
5182
|
+
} catch (err) {
|
|
5183
|
+
try { fs.rmSync(saTmpDir, { recursive: true, force: true }); } catch {}
|
|
5184
|
+
return resolve({ ok: false, error: `Failed to start ${whisperStreamBin}: ${err.message}` });
|
|
5185
|
+
}
|
|
5186
|
+
|
|
5187
|
+
// Pause readline so its keypress listeners don't eat our key.
|
|
5188
|
+
const rlWasPaused = rl ? rl.paused : true;
|
|
5189
|
+
try { if (rl && !rl.paused) rl.pause(); } catch {}
|
|
5190
|
+
|
|
5191
|
+
const wasRaw = !!process.stdin.isRaw;
|
|
5192
|
+
let stopped = false;
|
|
5193
|
+
let stderr = '';
|
|
5194
|
+
let initSeen = false;
|
|
5195
|
+
|
|
5196
|
+
// Print a header banner
|
|
5197
|
+
console.log();
|
|
5198
|
+
console.log(chalk.red('🔴 ') + chalk.cyan('Live preview ') + chalk.gray('— press any key to stop. Final transcript will be cleaner.'));
|
|
5199
|
+
if (!language || language === 'auto') {
|
|
5200
|
+
console.log(chalk.yellow(' 💡 Tip: lock language for much better quality, e.g. ') + chalk.white('/v lang ar') + chalk.yellow(' (Arabic) or ') + chalk.white('/v lang en'));
|
|
5201
|
+
} else {
|
|
5202
|
+
console.log(chalk.gray(` language: ${language}`));
|
|
5203
|
+
}
|
|
5204
|
+
console.log(chalk.gray('─'.repeat(Math.min(70, process.stdout.columns || 70))));
|
|
5205
|
+
|
|
5206
|
+
const cleanupStdin = () => {
|
|
5207
|
+
try { process.stdin.removeListener('data', onKey); } catch {}
|
|
5208
|
+
try { if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw); } catch {}
|
|
5209
|
+
};
|
|
5210
|
+
|
|
5211
|
+
const stop = () => {
|
|
5212
|
+
if (stopped) return;
|
|
5213
|
+
stopped = true;
|
|
5214
|
+
cleanupStdin();
|
|
5215
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
5216
|
+
// Hard-kill if it lingers
|
|
5217
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 1500);
|
|
5218
|
+
if (t.unref) t.unref();
|
|
5219
|
+
};
|
|
5220
|
+
|
|
5221
|
+
const onKey = () => { stop(); };
|
|
5222
|
+
|
|
5223
|
+
try {
|
|
5224
|
+
if (process.stdin.isTTY) {
|
|
5225
|
+
process.stdin.setRawMode(true);
|
|
5226
|
+
process.stdin.resume();
|
|
5227
|
+
process.stdin.on('data', onKey);
|
|
5228
|
+
}
|
|
5229
|
+
} catch {}
|
|
5230
|
+
|
|
5231
|
+
// Filter whisper-stream output for live preview only (we discard it later).
|
|
5232
|
+
const isNoise = (line) => {
|
|
5233
|
+
const s = line.trim();
|
|
5234
|
+
if (!s) return true;
|
|
5235
|
+
if (/^ggml_|^load_backend:|^whisper_|^main:|^init:|^system_info:/i.test(s)) return true;
|
|
5236
|
+
if (/^\[Start speaking\]/i.test(s)) return true;
|
|
5237
|
+
if (/^processing|^n_new_line|^n_threads|^step_ms|^length_ms|^keep_ms|^vad_thold|^freq_thold/i.test(s)) return true;
|
|
5238
|
+
if (/^use gpu|^flash attn|^audio_ctx|^model: |^translate|^language/i.test(s)) return true;
|
|
5239
|
+
return false;
|
|
5240
|
+
};
|
|
5241
|
+
|
|
5242
|
+
// Show only the LAST line of each chunk (collapse the overlap visually).
|
|
5243
|
+
let lastShown = '';
|
|
5244
|
+
proc.stdout.on('data', (chunk) => {
|
|
5245
|
+
const text = chunk.toString();
|
|
5246
|
+
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !isNoise(l));
|
|
5247
|
+
if (lines.length === 0) return;
|
|
5248
|
+
initSeen = true;
|
|
5249
|
+
const latest = lines[lines.length - 1];
|
|
5250
|
+
// Skip the dummy [BLANK_AUDIO] markers
|
|
5251
|
+
if (/^\[?BLANK_AUDIO\]?$/i.test(latest)) return;
|
|
5252
|
+
if (latest === lastShown) return;
|
|
5253
|
+
lastShown = latest;
|
|
5254
|
+
process.stdout.write(chalk.white(' ' + latest) + '\n');
|
|
5255
|
+
});
|
|
5256
|
+
|
|
5257
|
+
proc.stderr.on('data', (chunk) => {
|
|
5258
|
+
stderr += chunk.toString();
|
|
5259
|
+
});
|
|
5260
|
+
|
|
5261
|
+
proc.on('error', (err) => {
|
|
5262
|
+
cleanupStdin();
|
|
5263
|
+
try { if (rl && !rlWasPaused) rl.resume(); } catch {}
|
|
5264
|
+
try { fs.rmSync(saTmpDir, { recursive: true, force: true }); } catch {}
|
|
5265
|
+
resolve({ ok: false, error: `Failed to start ${whisperStreamBin}: ${err.message}` });
|
|
5266
|
+
});
|
|
5267
|
+
|
|
5268
|
+
proc.on('close', () => {
|
|
5269
|
+
cleanupStdin();
|
|
5270
|
+
try { if (rl && !rlWasPaused) rl.resume(); } catch {}
|
|
5271
|
+
|
|
5272
|
+
console.log(chalk.gray('─'.repeat(Math.min(70, process.stdout.columns || 70))));
|
|
5273
|
+
|
|
5274
|
+
// Locate the audio file produced by -sa (named like 20260605104159.wav).
|
|
5275
|
+
let savedAudio = null;
|
|
5276
|
+
try {
|
|
5277
|
+
const files = fs.readdirSync(saTmpDir)
|
|
5278
|
+
.filter(f => f.toLowerCase().endsWith('.wav'))
|
|
5279
|
+
.map(f => ({ f, full: join(saTmpDir, f), mtime: fs.statSync(join(saTmpDir, f)).mtimeMs, size: fs.statSync(join(saTmpDir, f)).size }))
|
|
5280
|
+
.filter(x => x.mtime >= saStartTs - 1000 && x.size > 1000)
|
|
5281
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
5282
|
+
if (files.length) savedAudio = files[0].full;
|
|
5283
|
+
} catch {}
|
|
5284
|
+
|
|
5285
|
+
if (!savedAudio) {
|
|
5286
|
+
try { fs.rmSync(saTmpDir, { recursive: true, force: true }); } catch {}
|
|
5287
|
+
const errMsg = stderr.trim().split('\n').slice(-3).join('\n') || 'no audio was captured';
|
|
5288
|
+
return resolve({ ok: false, error: errMsg });
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
resolve({ ok: true, audioPath: savedAudio, audioTmpDir: saTmpDir });
|
|
5292
|
+
});
|
|
5293
|
+
});
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5296
|
+
// Save a recording (audio + transcript) under voice.archiveDir/YYYY-MM-DD/.
|
|
5297
|
+
// Returns { ok, audio?, transcript?, error? } with the final saved paths.
|
|
5298
|
+
function archiveVoiceRecording({ sourceAudio, transcript, mode = 'record', extHint = null } = {}) {
|
|
5299
|
+
try {
|
|
5300
|
+
const cfg = getVoiceConfig();
|
|
5301
|
+
if (!cfg.archive) return { ok: false, error: 'archiving disabled' };
|
|
5302
|
+
|
|
5303
|
+
const baseDir = isAbsolute(cfg.archiveDir)
|
|
5304
|
+
? cfg.archiveDir
|
|
5305
|
+
: pathResolve(getToolWorkingDirectory(), cfg.archiveDir);
|
|
5306
|
+
|
|
5307
|
+
const now = new Date();
|
|
5308
|
+
const yyyy = now.getFullYear();
|
|
5309
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
5310
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
5311
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
5312
|
+
const mi = String(now.getMinutes()).padStart(2, '0');
|
|
5313
|
+
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
5314
|
+
const dayDir = join(baseDir, `${yyyy}-${mm}-${dd}`);
|
|
5315
|
+
fs.mkdirSync(dayDir, { recursive: true });
|
|
5316
|
+
|
|
5317
|
+
const stamp = `${hh}${mi}${ss}`;
|
|
5318
|
+
const safeMode = String(mode).replace(/[^a-z0-9_-]/gi, '');
|
|
5319
|
+
const baseName = `${stamp}-${safeMode}`;
|
|
5320
|
+
|
|
5321
|
+
let audioOut = null;
|
|
5322
|
+
if (sourceAudio && fs.existsSync(sourceAudio)) {
|
|
5323
|
+
const ext = extHint || extname(sourceAudio) || '.wav';
|
|
5324
|
+
audioOut = join(dayDir, `${baseName}${ext}`);
|
|
5325
|
+
try {
|
|
5326
|
+
fs.copyFileSync(sourceAudio, audioOut);
|
|
5327
|
+
} catch (err) {
|
|
5328
|
+
// Fall back to rename if same FS
|
|
5329
|
+
try { fs.renameSync(sourceAudio, audioOut); } catch { audioOut = null; }
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
5332
|
+
|
|
5333
|
+
let txtOut = null;
|
|
5334
|
+
const cleanTranscript = (transcript || '').trim();
|
|
5335
|
+
if (cleanTranscript) {
|
|
5336
|
+
txtOut = join(dayDir, `${baseName}.txt`);
|
|
5337
|
+
try {
|
|
5338
|
+
fs.writeFileSync(txtOut, cleanTranscript + '\n', 'utf8');
|
|
5339
|
+
} catch { txtOut = null; }
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
return { ok: true, audio: audioOut, transcript: txtOut, dir: dayDir };
|
|
5343
|
+
} catch (err) {
|
|
5344
|
+
return { ok: false, error: err.message };
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
async function transcribeAudio({ audioFile = null, seconds = null, pushToStop = false, mode = null } = {}) {
|
|
5349
|
+
const cfg = getVoiceConfig();
|
|
5350
|
+
if (!cfg.enabled) return { ok: false, error: 'Voice input is disabled in config (voice.enabled = false). Re-enable with /voice on.' };
|
|
5351
|
+
if (!cfg.model) return { ok: false, error: 'No Whisper model configured. Set one with /voice model <path-to-ggml.bin>.' };
|
|
5352
|
+
|
|
5353
|
+
let audioPath = audioFile;
|
|
5354
|
+
let cleanupAudio = false;
|
|
5355
|
+
|
|
5356
|
+
if (!audioPath) {
|
|
5357
|
+
audioPath = join(os.tmpdir(), `sapper-voice-${Date.now()}.wav`);
|
|
5358
|
+
cleanupAudio = true;
|
|
5359
|
+
|
|
5360
|
+
let rec;
|
|
5361
|
+
if (pushToStop) {
|
|
5362
|
+
// Push-to-stop: ticker + key-press handling are rendered inside the helper.
|
|
5363
|
+
rec = await recordAudioUntilKey(audioPath, {
|
|
5364
|
+
recorder: cfg.recorder,
|
|
5365
|
+
device: cfg.device,
|
|
5366
|
+
sampleRate: cfg.sampleRate,
|
|
5367
|
+
maxSeconds: 600,
|
|
5368
|
+
});
|
|
5369
|
+
} else {
|
|
5370
|
+
const dur = Math.max(1, Math.min(300, Number(seconds) || cfg.recordSeconds));
|
|
5371
|
+
const recIntro = uiCleanMode()
|
|
5372
|
+
? chalk.cyan(`Recording ${dur}s from ${cfg.recorder} (${cfg.device}) ...`)
|
|
5373
|
+
: chalk.cyan(`🎙️ Recording ${dur}s from ${cfg.recorder} (${cfg.device}) ...`);
|
|
5374
|
+
console.log(recIntro);
|
|
5375
|
+
rec = await recordAudioToFile(audioPath, {
|
|
5376
|
+
seconds: dur,
|
|
5377
|
+
recorder: cfg.recorder,
|
|
5378
|
+
device: cfg.device,
|
|
5379
|
+
sampleRate: cfg.sampleRate,
|
|
5380
|
+
});
|
|
5381
|
+
}
|
|
5382
|
+
|
|
5383
|
+
if (!rec.ok) {
|
|
5384
|
+
try { if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath); } catch {}
|
|
5385
|
+
return { ok: false, error: `Recording failed: ${rec.error}` };
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
5388
|
+
|
|
5389
|
+
const transSpinner = ora(chalk.cyan(`Transcribing with ${cfg.whisperBin} (${cfg.model.split('/').pop()})...`)).start();
|
|
5390
|
+
const result = await runWhisperCli(audioPath, {
|
|
5391
|
+
whisperBin: cfg.whisperBin,
|
|
5392
|
+
model: cfg.model,
|
|
5393
|
+
language: cfg.language,
|
|
5394
|
+
translate: cfg.translate,
|
|
5395
|
+
});
|
|
5396
|
+
transSpinner.stop();
|
|
5397
|
+
|
|
5398
|
+
// Archive the recording (audio + transcript) before cleaning up the temp file.
|
|
5399
|
+
let archived = null;
|
|
5400
|
+
if (result.ok && cfg.archive) {
|
|
5401
|
+
const archiveMode = mode || (audioFile ? 'file' : (pushToStop ? 'talk' : 'record'));
|
|
5402
|
+
archived = archiveVoiceRecording({
|
|
5403
|
+
sourceAudio: audioPath,
|
|
5404
|
+
transcript: result.text,
|
|
5405
|
+
mode: archiveMode,
|
|
5406
|
+
});
|
|
5407
|
+
if (archived && archived.ok) {
|
|
5408
|
+
const where = archived.audio || archived.transcript;
|
|
5409
|
+
if (where) console.log(UI.slate(` Archived → ${where.replace(getToolWorkingDirectory() + '/', '')}`));
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5413
|
+
if (cleanupAudio) {
|
|
5414
|
+
try { if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath); } catch {}
|
|
5415
|
+
}
|
|
5416
|
+
return { ...result, archived };
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5419
|
+
function runCapturedCommand(command, { cwd = getToolWorkingDirectory(), timeoutMs = 12000, maxOutput = 24000 } = {}) {
|
|
5420
|
+
return new Promise((resolve) => {
|
|
5421
|
+
const proc = spawn('sh', ['-c', command], { cwd });
|
|
5422
|
+
let stdout = '';
|
|
5423
|
+
let stderr = '';
|
|
5424
|
+
let stdoutTruncated = false;
|
|
5425
|
+
let stderrTruncated = false;
|
|
5426
|
+
let finished = false;
|
|
5427
|
+
|
|
5428
|
+
const appendLimited = (existing, chunkText, maxChars, setTruncated) => {
|
|
5429
|
+
if (existing.length >= maxChars) {
|
|
5430
|
+
setTruncated(true);
|
|
5431
|
+
return existing;
|
|
5432
|
+
}
|
|
5433
|
+
const remaining = maxChars - existing.length;
|
|
5434
|
+
if (chunkText.length > remaining) {
|
|
5435
|
+
setTruncated(true);
|
|
5436
|
+
return existing + chunkText.slice(0, remaining);
|
|
5437
|
+
}
|
|
5438
|
+
return existing + chunkText;
|
|
5439
|
+
};
|
|
5440
|
+
|
|
5441
|
+
const finish = (result) => {
|
|
5442
|
+
if (finished) return;
|
|
5443
|
+
finished = true;
|
|
5444
|
+
if (timer) clearTimeout(timer);
|
|
5445
|
+
const finalStdout = stdoutTruncated
|
|
5446
|
+
? `${stdout}\n... (stdout truncated at ${maxOutput.toLocaleString()} chars)`
|
|
5447
|
+
: stdout;
|
|
5448
|
+
const finalStderr = stderrTruncated
|
|
5449
|
+
? `${stderr}\n... (stderr truncated at ${maxOutput.toLocaleString()} chars)`
|
|
5450
|
+
: stderr;
|
|
5451
|
+
resolve({ ...result, stdout: finalStdout, stderr: finalStderr });
|
|
5452
|
+
};
|
|
5453
|
+
|
|
5454
|
+
const timer = timeoutMs > 0
|
|
5455
|
+
? setTimeout(() => {
|
|
5456
|
+
try { proc.kill('SIGTERM'); } catch (e) {}
|
|
5457
|
+
finish({ exitCode: 124, stdout, stderr: `${stderr}\nCommand timed out after ${timeoutMs}ms`.trim(), timedOut: true });
|
|
5458
|
+
}, timeoutMs)
|
|
5459
|
+
: null;
|
|
5460
|
+
|
|
5461
|
+
proc.stdout.on('data', (data) => {
|
|
5462
|
+
stdout = appendLimited(stdout, data.toString(), maxOutput, (value) => { stdoutTruncated = value; });
|
|
5463
|
+
});
|
|
5464
|
+
proc.stderr.on('data', (data) => {
|
|
5465
|
+
stderr = appendLimited(stderr, data.toString(), maxOutput, (value) => { stderrTruncated = value; });
|
|
5466
|
+
});
|
|
5467
|
+
|
|
5468
|
+
proc.on('error', (error) => {
|
|
5469
|
+
finish({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`.trim(), timedOut: false });
|
|
5470
|
+
});
|
|
5471
|
+
proc.on('close', (code) => {
|
|
5472
|
+
finish({ exitCode: code ?? 0, stdout, stderr, timedOut: false });
|
|
5473
|
+
});
|
|
5474
|
+
});
|
|
5475
|
+
}
|
|
5476
|
+
|
|
5477
|
+
function normalizeFetchedWebContent(rawContent = '') {
|
|
5478
|
+
const trimmed = String(rawContent ?? '').trim();
|
|
5479
|
+
if (!trimmed) return '';
|
|
5480
|
+
|
|
5481
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
5482
|
+
try {
|
|
5483
|
+
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
5484
|
+
} catch (error) {
|
|
5485
|
+
return trimmed;
|
|
5486
|
+
}
|
|
5487
|
+
}
|
|
5488
|
+
|
|
5489
|
+
if (trimmed.startsWith('<') || trimmed.toLowerCase().includes('<html')) {
|
|
5490
|
+
return htmlToText(trimmed);
|
|
5491
|
+
}
|
|
5492
|
+
|
|
5493
|
+
return trimmed;
|
|
5494
|
+
}
|
|
5495
|
+
|
|
5496
|
+
function keywordRecallMemory(query, embeddings, topK = 3) {
|
|
5497
|
+
const queryText = String(query ?? '').trim().toLowerCase();
|
|
5498
|
+
if (!queryText || !embeddings?.chunks?.length) return [];
|
|
5499
|
+
|
|
5500
|
+
const queryWords = Array.from(new Set(
|
|
5501
|
+
queryText.split(/[^a-z0-9_]+/i).map(word => word.trim()).filter(word => word.length >= 2)
|
|
5502
|
+
));
|
|
5503
|
+
const maxScore = Math.max(1, 4 + queryWords.length);
|
|
5504
|
+
|
|
5505
|
+
return embeddings.chunks
|
|
5506
|
+
.map((chunk) => {
|
|
5507
|
+
const text = String(chunk?.text ?? '');
|
|
5508
|
+
const lowered = text.toLowerCase();
|
|
5509
|
+
let score = 0;
|
|
5510
|
+
if (lowered.includes(queryText)) score += 4;
|
|
5511
|
+
for (const word of queryWords) {
|
|
5512
|
+
if (lowered.includes(word)) score += 1;
|
|
5513
|
+
}
|
|
5514
|
+
return { ...chunk, score: Math.min(1, score / maxScore) };
|
|
5515
|
+
})
|
|
5516
|
+
.filter(chunk => chunk.score > 0)
|
|
5517
|
+
.sort((a, b) => b.score - a.score || (b.timestamp || 0) - (a.timestamp || 0))
|
|
5518
|
+
.slice(0, topK);
|
|
5519
|
+
}
|
|
5520
|
+
|
|
5521
|
+
const tools = {
|
|
5522
|
+
read: (path) => {
|
|
5523
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
5524
|
+
if (!trimmedPath) return 'Error reading file: missing file path';
|
|
4019
5525
|
try { return fs.readFileSync(resolveToolPath(trimmedPath), 'utf8'); }
|
|
4020
5526
|
catch (error) { return `Error reading file: ${error.message}`; }
|
|
4021
5527
|
},
|
|
@@ -4191,7 +5697,7 @@ const tools = {
|
|
|
4191
5697
|
`${UI.slate('This will permanently delete the directory and its contents.')}`,
|
|
4192
5698
|
'Directory Removal', 'red'
|
|
4193
5699
|
));
|
|
4194
|
-
const confirm = await safeQuestion(confirmPrompt('Remove directory', 'error'));
|
|
5700
|
+
const confirm = await safeQuestion(confirmPrompt(promptLabel('questions.removeDirectory', 'Remove directory'), 'error'));
|
|
4195
5701
|
if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
|
|
4196
5702
|
return 'Directory removal blocked by user.';
|
|
4197
5703
|
}
|
|
@@ -4208,6 +5714,8 @@ const tools = {
|
|
|
4208
5714
|
tail: (path, lines) => readFileLineWindow(path, 'tail', lines),
|
|
4209
5715
|
grep: (pattern) => tools.search(pattern),
|
|
4210
5716
|
find: (pattern, startPath) => findPathsByName(pattern, startPath),
|
|
5717
|
+
regex: (pattern, include, startPath) => regexSearch(pattern, include, startPath),
|
|
5718
|
+
read_chunk: (path, start, end, context) => readChunk(path, start, end, context),
|
|
4211
5719
|
changes: async (path) => {
|
|
4212
5720
|
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4213
5721
|
const cwd = getToolWorkingDirectory();
|
|
@@ -4299,6 +5807,39 @@ const tools = {
|
|
|
4299
5807
|
|
|
4300
5808
|
return `Found ${relevant.length} memory match${relevant.length === 1 ? '' : 'es'} for: ${trimmedQuery}\n\n${formatted}`;
|
|
4301
5809
|
},
|
|
5810
|
+
save_memory_note: async (title, content, tags) => {
|
|
5811
|
+
const result = appendLongMemoryNote({
|
|
5812
|
+
title,
|
|
5813
|
+
content,
|
|
5814
|
+
tags,
|
|
5815
|
+
source: 'assistant-tool',
|
|
5816
|
+
});
|
|
5817
|
+
if (!result.ok) {
|
|
5818
|
+
return `Error saving memory note: ${result.error}`;
|
|
5819
|
+
}
|
|
5820
|
+
const tagText = result.tags.length ? ` [${result.tags.join(', ')}]` : '';
|
|
5821
|
+
return `Saved memory note: ${result.title}${tagText} (${result.timestamp}) in ${result.path}`;
|
|
5822
|
+
},
|
|
5823
|
+
search_memory_notes: async (query) => {
|
|
5824
|
+
const cleanQuery = String(query ?? '').trim();
|
|
5825
|
+
if (!cleanQuery) return 'Error searching memory notes: missing query';
|
|
5826
|
+
|
|
5827
|
+
const matches = searchLongMemoryNotes(cleanQuery, 5);
|
|
5828
|
+
if (!matches.length) {
|
|
5829
|
+
return `No markdown long-memory notes found for: ${cleanQuery}`;
|
|
5830
|
+
}
|
|
5831
|
+
|
|
5832
|
+
const formatted = matches.map((note, index) => {
|
|
5833
|
+
const preview = truncateToolText(note, 900);
|
|
5834
|
+
return `[${index + 1}]\n${preview}`;
|
|
5835
|
+
}).join('\n\n');
|
|
5836
|
+
|
|
5837
|
+
return `Found ${matches.length} markdown note match${matches.length === 1 ? '' : 'es'} for: ${cleanQuery}\n\n${formatted}`;
|
|
5838
|
+
},
|
|
5839
|
+
read_memory_notes: async () => {
|
|
5840
|
+
const text = loadLongMemoryText();
|
|
5841
|
+
return truncateToolText(text, 28000);
|
|
5842
|
+
},
|
|
4302
5843
|
open_url: async (url) => {
|
|
4303
5844
|
const trimmedUrl = String(url ?? '').trim();
|
|
4304
5845
|
if (!trimmedUrl) return 'Error opening URL: missing URL';
|
|
@@ -4312,7 +5853,7 @@ const tools = {
|
|
|
4312
5853
|
`${UI.slate('This will open the URL in your default browser.')}`,
|
|
4313
5854
|
'Open URL', 'red'
|
|
4314
5855
|
));
|
|
4315
|
-
const confirm = await safeQuestion(confirmPrompt('Open URL in browser', 'error'));
|
|
5856
|
+
const confirm = await safeQuestion(confirmPrompt(promptLabel('questions.openUrlInBrowser', 'Open URL in browser'), 'error'));
|
|
4316
5857
|
if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
|
|
4317
5858
|
return 'Open URL blocked by user.';
|
|
4318
5859
|
}
|
|
@@ -4357,7 +5898,7 @@ const tools = {
|
|
|
4357
5898
|
'Shell Approval', 'red'
|
|
4358
5899
|
));
|
|
4359
5900
|
while (true) {
|
|
4360
|
-
const confirmInput = await safeQuestion(confirmPrompt('Run shell command', 'error', '[y/N/f/e or text] '));
|
|
5901
|
+
const confirmInput = await safeQuestion(confirmPrompt(promptLabel('questions.runShellCommand', 'Run shell command'), 'error', '[y/N/f/e or text] '));
|
|
4361
5902
|
const confirmRaw = String(confirmInput ?? '').trim();
|
|
4362
5903
|
const confirm = confirmRaw.toLowerCase();
|
|
4363
5904
|
|
|
@@ -4449,8 +5990,8 @@ const tools = {
|
|
|
4449
5990
|
}
|
|
4450
5991
|
|
|
4451
5992
|
const approvalInstruction = await resolveApprovalInstruction(confirmRaw, {
|
|
4452
|
-
feedbackPrompt: 'Feedback for
|
|
4453
|
-
editPrompt: 'Edit instruction for
|
|
5993
|
+
feedbackPrompt: promptLabel('questions.feedbackForSapper', 'Feedback for Sapper: '),
|
|
5994
|
+
editPrompt: promptLabel('questions.editInstructionForSapper', 'Edit instruction for Sapper: '),
|
|
4454
5995
|
});
|
|
4455
5996
|
|
|
4456
5997
|
if (approvalInstruction) {
|
|
@@ -4547,10 +6088,25 @@ async function checkForUpdates() {
|
|
|
4547
6088
|
|
|
4548
6089
|
async function runSapper() {
|
|
4549
6090
|
console.clear();
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
6091
|
+
const clean = uiCleanMode();
|
|
6092
|
+
const ultra = uiUltraCleanMode();
|
|
6093
|
+
if (ultra) {
|
|
6094
|
+
console.log(`${chalk.white(promptLabel('ui.bannerTitle', 'Sapper'))} ${UI.slate(`v${CURRENT_VERSION}`)} ${UI.slate(safeCwd())}`);
|
|
6095
|
+
console.log(UI.slate(promptLabel('ui.ultraFrontendHint', 'ultra frontend active /ui style sapper to switch back')));
|
|
6096
|
+
} else if (clean) {
|
|
6097
|
+
console.log(`${chalk.white(promptLabel('ui.bannerTitle', 'Sapper'))} ${UI.slate(`v${CURRENT_VERSION}`)} ${UI.slate('·')} ${UI.slate(safeCwd())}`);
|
|
6098
|
+
console.log(UI.slate(promptLabel('ui.cleanFrontendHint', 'clean frontend active · /ui style sapper to switch back')));
|
|
6099
|
+
console.log(divider('─', 'gray', terminalWidth(70)));
|
|
6100
|
+
} else {
|
|
6101
|
+
console.log(bannerText());
|
|
6102
|
+
console.log(`${UI.slate(safeCwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
|
|
6103
|
+
console.log(divider());
|
|
6104
|
+
console.log(sectionTitle(
|
|
6105
|
+
promptLabel('ui.quickStartTitle', 'Quick Start'),
|
|
6106
|
+
promptLabel('ui.quickStartSubtitle', '@file attach · /commands palette · /agents modes'),
|
|
6107
|
+
'gray'
|
|
6108
|
+
));
|
|
6109
|
+
}
|
|
4554
6110
|
console.log();
|
|
4555
6111
|
|
|
4556
6112
|
// Check for updates
|
|
@@ -4602,27 +6158,46 @@ async function runSapper() {
|
|
|
4602
6158
|
? Math.max(0, Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60))
|
|
4603
6159
|
: 0;
|
|
4604
6160
|
const startupLines = [
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
6161
|
+
piRow('workspace', `${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`),
|
|
6162
|
+
piRow('memory', `${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`),
|
|
6163
|
+
piRow('prompt', UI.slate(hasCustomPromptConfig() ? 'custom prompt on' : 'default prompt')),
|
|
6164
|
+
piRow('thinking', UI.slate(`mode ${thinkingMode()}`)),
|
|
6165
|
+
piRow('tools', UI.slate(`limit ${toolRoundLimit()} rounds`)),
|
|
6166
|
+
piRow('shell', `${UI.slate(`stream ${shellStreamToModelEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${UI.slate(`${activeShellSessionCount()} active`)}`),
|
|
6167
|
+
piRow('stream', `${UI.slate(`heartbeat ${streamHeartbeatEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`phases ${streamPhaseStatusEnabled() ? 'on' : 'off'}`)}`),
|
|
6168
|
+
piRow('summary', `${UI.slate(`phases ${summaryPhasesEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`trigger ${summaryTriggerPercent()}%`)}`),
|
|
6169
|
+
piRow('modes', `${chalk.white(`agents ${agentCount}`)} ${UI.slate('·')} ${chalk.white(`skills ${skillCount}`)}`),
|
|
4614
6170
|
];
|
|
4615
6171
|
if (newlyCreated > 0) {
|
|
4616
6172
|
startupLines.push(UI.slate(`${newlyCreated} default agents or skills created in .sapper/`));
|
|
4617
6173
|
}
|
|
4618
|
-
|
|
6174
|
+
if (ultra) {
|
|
6175
|
+
const condensed = [
|
|
6176
|
+
`${chalk.white(`${workspaceFileCount} files`)} ${UI.slate(`${workspaceSymbolCount} symbols`)}`,
|
|
6177
|
+
`${UI.slate('agents')} ${chalk.white(agentCount)} ${UI.slate('skills')} ${chalk.white(skillCount)} ${UI.slate('summary')} ${chalk.white(`${summaryTriggerPercent()}%`)}`,
|
|
6178
|
+
];
|
|
6179
|
+
if (newlyCreated > 0) condensed.push(UI.slate(`${newlyCreated} defaults created in .sapper/`));
|
|
6180
|
+
console.log(condensed.join('\n'));
|
|
6181
|
+
} else if (clean) {
|
|
6182
|
+
const condensed = [
|
|
6183
|
+
`${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
|
|
6184
|
+
`${UI.slate('tools')} ${chalk.white(`limit ${toolRoundLimit()}`)} ${UI.slate('·')} ${UI.slate('summary')} ${chalk.white(`${summaryTriggerPercent()}%`)}`,
|
|
6185
|
+
`${UI.slate('modes')} ${chalk.white(`agents ${agentCount}`)} ${UI.slate('·')} ${chalk.white(`skills ${skillCount}`)} ${UI.slate('·')} ${UI.slate('shell')} ${chalk.white(shellBackgroundMode())}`,
|
|
6186
|
+
];
|
|
6187
|
+
if (newlyCreated > 0) {
|
|
6188
|
+
condensed.push(UI.slate(`${newlyCreated} default agents or skills created in .sapper/`));
|
|
6189
|
+
}
|
|
6190
|
+
console.log(box(condensed.join('\n'), 'Session', 'gray'));
|
|
6191
|
+
} else {
|
|
6192
|
+
console.log(box(startupLines.join('\n'), 'Session Dashboard', 'gray'));
|
|
6193
|
+
}
|
|
4619
6194
|
console.log();
|
|
4620
6195
|
|
|
4621
6196
|
let messages = [];
|
|
4622
6197
|
if (fs.existsSync(CONTEXT_FILE)) {
|
|
4623
6198
|
console.log(divider());
|
|
4624
6199
|
console.log(UI.ink('Previous session found in .sapper/context.json'));
|
|
4625
|
-
const resume = await safeQuestion(confirmPrompt('Resume session', 'success'));
|
|
6200
|
+
const resume = await safeQuestion(confirmPrompt(promptLabel('questions.resumeSession', 'Resume session'), 'success'));
|
|
4626
6201
|
if (resume.toLowerCase() === 'y') {
|
|
4627
6202
|
messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
|
|
4628
6203
|
console.log(chalk.green(' ✓ Session restored\n'));
|
|
@@ -4717,9 +6292,9 @@ async function runSapper() {
|
|
|
4717
6292
|
contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
|
|
4718
6293
|
}
|
|
4719
6294
|
console.log(box(
|
|
4720
|
-
`${
|
|
4721
|
-
`${
|
|
4722
|
-
`${
|
|
6295
|
+
`${piRow('model', chalk.white.bold(selectedModel))}\n` +
|
|
6296
|
+
`${piRow('tools', UI.ink(toolModeLabel))}\n` +
|
|
6297
|
+
`${piRow('context', UI.ink(contextLabel))}`,
|
|
4723
6298
|
'Session', 'cyan'
|
|
4724
6299
|
));
|
|
4725
6300
|
console.log();
|
|
@@ -4899,6 +6474,39 @@ async function runSapper() {
|
|
|
4899
6474
|
}
|
|
4900
6475
|
}
|
|
4901
6476
|
},
|
|
6477
|
+
{
|
|
6478
|
+
type: 'function',
|
|
6479
|
+
function: {
|
|
6480
|
+
name: 'regex_search',
|
|
6481
|
+
description: 'Advanced regex search across source code. Accepts JS-style /pattern/flags or a raw pattern (default flag: case-insensitive). Returns file:line:col with capture groups. Optional filter limits to extensions (js,ts) or path substrings (src/).',
|
|
6482
|
+
parameters: {
|
|
6483
|
+
type: 'object',
|
|
6484
|
+
properties: {
|
|
6485
|
+
pattern: { type: 'string', description: 'Regex pattern, e.g. "TODO[:\\s]" or "/function\\s+(\\w+)/i"' },
|
|
6486
|
+
include: { type: 'string', description: 'Optional comma-separated extensions (js,ts,py) or path substrings (src/,routes/)' },
|
|
6487
|
+
path: { type: 'string', description: 'Directory to search from (default current tool working directory)' }
|
|
6488
|
+
},
|
|
6489
|
+
required: ['pattern']
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
},
|
|
6493
|
+
{
|
|
6494
|
+
type: 'function',
|
|
6495
|
+
function: {
|
|
6496
|
+
name: 'read_chunk',
|
|
6497
|
+
description: 'Read a specific line range from a file (e.g. lines 40-80) instead of the whole file. Use this after regex_search/grep to inspect the surrounding context without loading huge files into context. Always preferred for files larger than ~400 lines.',
|
|
6498
|
+
parameters: {
|
|
6499
|
+
type: 'object',
|
|
6500
|
+
properties: {
|
|
6501
|
+
path: { type: 'string', description: 'File path to read' },
|
|
6502
|
+
start: { type: 'number', description: 'Starting line (1-based, inclusive)' },
|
|
6503
|
+
end: { type: 'number', description: 'Ending line (1-based, inclusive). Defaults to start + 60.' },
|
|
6504
|
+
context: { type: 'number', description: 'Extra lines to include above & below the range (default 0)' }
|
|
6505
|
+
},
|
|
6506
|
+
required: ['path', 'start']
|
|
6507
|
+
}
|
|
6508
|
+
}
|
|
6509
|
+
},
|
|
4902
6510
|
{
|
|
4903
6511
|
type: 'function',
|
|
4904
6512
|
function: {
|
|
@@ -4979,6 +6587,47 @@ async function runSapper() {
|
|
|
4979
6587
|
}
|
|
4980
6588
|
}
|
|
4981
6589
|
},
|
|
6590
|
+
{
|
|
6591
|
+
type: 'function',
|
|
6592
|
+
function: {
|
|
6593
|
+
name: 'save_memory_note',
|
|
6594
|
+
description: 'Save a durable markdown note in .sapper/long-memory.md for reusable project patterns, decisions, or fixes.',
|
|
6595
|
+
parameters: {
|
|
6596
|
+
type: 'object',
|
|
6597
|
+
properties: {
|
|
6598
|
+
title: { type: 'string', description: 'Short title for the note' },
|
|
6599
|
+
content: { type: 'string', description: 'Main note content to store' },
|
|
6600
|
+
tags: { type: 'string', description: 'Optional comma-separated tags like bugfix,cli,pattern' }
|
|
6601
|
+
},
|
|
6602
|
+
required: ['content']
|
|
6603
|
+
}
|
|
6604
|
+
}
|
|
6605
|
+
},
|
|
6606
|
+
{
|
|
6607
|
+
type: 'function',
|
|
6608
|
+
function: {
|
|
6609
|
+
name: 'search_memory_notes',
|
|
6610
|
+
description: 'Search markdown long-memory notes in .sapper/long-memory.md.',
|
|
6611
|
+
parameters: {
|
|
6612
|
+
type: 'object',
|
|
6613
|
+
properties: {
|
|
6614
|
+
query: { type: 'string', description: 'Search query for notes' }
|
|
6615
|
+
},
|
|
6616
|
+
required: ['query']
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
},
|
|
6620
|
+
{
|
|
6621
|
+
type: 'function',
|
|
6622
|
+
function: {
|
|
6623
|
+
name: 'read_memory_notes',
|
|
6624
|
+
description: 'Read the markdown long-memory file at .sapper/long-memory.md.',
|
|
6625
|
+
parameters: {
|
|
6626
|
+
type: 'object',
|
|
6627
|
+
properties: {}
|
|
6628
|
+
}
|
|
6629
|
+
}
|
|
6630
|
+
},
|
|
4982
6631
|
{
|
|
4983
6632
|
type: 'function',
|
|
4984
6633
|
function: {
|
|
@@ -5062,33 +6711,61 @@ async function runSapper() {
|
|
|
5062
6711
|
|
|
5063
6712
|
// Build prompt label with active agent/skills
|
|
5064
6713
|
const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
|
|
5065
|
-
const
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
6714
|
+
const cleanPrompt = uiCleanMode();
|
|
6715
|
+
const ultraPrompt = uiUltraCleanMode();
|
|
6716
|
+
let promptText;
|
|
6717
|
+
|
|
6718
|
+
if (ultraPrompt) {
|
|
6719
|
+
const modelShort = selectedModel.split(':')[0] || selectedModel;
|
|
6720
|
+
const modeBits = [chalk.white(modelShort)];
|
|
6721
|
+
if (currentAgent) modeBits.push(UI.slate(currentAgent));
|
|
6722
|
+
if (contextPercent !== null) modeBits.push(UI.slate(`${contextPercent}%`));
|
|
6723
|
+
promptText = `\n${modeBits.join(' ')} ${UI.accent('> ' )}`;
|
|
6724
|
+
} else if (cleanPrompt) {
|
|
6725
|
+
const modelShort = selectedModel.split(':')[0] || selectedModel;
|
|
6726
|
+
const modeLineParts = [chalk.white(modelShort)];
|
|
6727
|
+
if (currentAgent) {
|
|
6728
|
+
modeLineParts.push(UI.slate(`agent:${currentAgent}`));
|
|
6729
|
+
} else {
|
|
6730
|
+
modeLineParts.push(UI.slate('default'));
|
|
6731
|
+
}
|
|
6732
|
+
if (contextPercent !== null) {
|
|
6733
|
+
modeLineParts.push(UI.slate(`${contextPercent}% ctx`));
|
|
6734
|
+
}
|
|
5077
6735
|
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
6736
|
+
const detail = ctxLen
|
|
6737
|
+
? `${meter(estimatedTokens, ctxLen, 16)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
|
|
6738
|
+
: UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
|
|
6739
|
+
promptText = `\n${UI.slate(modeLineParts.join(' · '))}\n${detail}\n${UI.accent('› ')} `;
|
|
6740
|
+
} else {
|
|
6741
|
+
const promptParts = [
|
|
6742
|
+
statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
|
|
6743
|
+
activeAgentPromptBadge(),
|
|
6744
|
+
];
|
|
6745
|
+
const skillsBadge = activeSkillsPromptBadge();
|
|
6746
|
+
if (skillsBadge) {
|
|
6747
|
+
promptParts.push(skillsBadge);
|
|
6748
|
+
}
|
|
6749
|
+
if (contextPercent !== null) {
|
|
6750
|
+
const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
|
|
6751
|
+
promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
|
|
6752
|
+
}
|
|
6753
|
+
|
|
6754
|
+
const promptDetailLines = [ctxLen
|
|
6755
|
+
? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
|
|
6756
|
+
: UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`)
|
|
6757
|
+
];
|
|
6758
|
+
const modeSummary = activeModeSummary({ includeAgent: true, maxSkills: 3 });
|
|
6759
|
+
if (modeSummary) {
|
|
6760
|
+
promptDetailLines.push(UI.slate(modeSummary));
|
|
6761
|
+
}
|
|
6762
|
+
const promptDetail = promptDetailLines.join('\n');
|
|
6763
|
+
promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
|
|
5085
6764
|
}
|
|
5086
|
-
const promptDetail = promptDetailLines.join('\n');
|
|
5087
6765
|
|
|
5088
|
-
|
|
5089
|
-
const input = await safeQuestion(promptText);
|
|
6766
|
+
let input = await safeQuestion(promptText);
|
|
5090
6767
|
clearPromptEcho(promptText, input);
|
|
5091
|
-
|
|
6768
|
+
|
|
5092
6769
|
// Block empty prompts
|
|
5093
6770
|
if (!input.trim()) {
|
|
5094
6771
|
continue;
|
|
@@ -5140,53 +6817,41 @@ async function runSapper() {
|
|
|
5140
6817
|
continue;
|
|
5141
6818
|
}
|
|
5142
6819
|
|
|
6820
|
+
// Handle model switch command
|
|
6821
|
+
if (input.toLowerCase().startsWith('/model')) {
|
|
6822
|
+
const arg = input.slice(6).trim();
|
|
6823
|
+
const modelList = await ollama.list();
|
|
6824
|
+
if (!modelList.models || modelList.models.length === 0) {
|
|
6825
|
+
console.log(chalk.red('No local models found. Pull one with: ollama pull <model>'));
|
|
6826
|
+
} else if (arg) {
|
|
6827
|
+
// Direct name — try exact match then prefix
|
|
6828
|
+
const match = modelList.models.find(m => m.name === arg) ||
|
|
6829
|
+
modelList.models.find(m => m.name.startsWith(arg));
|
|
6830
|
+
if (match) {
|
|
6831
|
+
selectedModel = match.name;
|
|
6832
|
+
sapperConfig.defaultModel = selectedModel;
|
|
6833
|
+
saveConfig(sapperConfig);
|
|
6834
|
+
console.log(chalk.green(`✅ Switched to ${chalk.bold(selectedModel)}`));
|
|
6835
|
+
} else {
|
|
6836
|
+
console.log(chalk.red(`Model "${arg}" not found locally.`));
|
|
6837
|
+
console.log(chalk.gray('Available: ' + modelList.models.map(m => m.name).join(', ')));
|
|
6838
|
+
}
|
|
6839
|
+
} else {
|
|
6840
|
+
const picked = await pickModel(modelList.models);
|
|
6841
|
+
if (picked) {
|
|
6842
|
+
selectedModel = picked;
|
|
6843
|
+
sapperConfig.defaultModel = selectedModel;
|
|
6844
|
+
saveConfig(sapperConfig);
|
|
6845
|
+
console.log(chalk.green(`\n✅ Switched to ${chalk.bold(selectedModel)}\n`));
|
|
6846
|
+
}
|
|
6847
|
+
}
|
|
6848
|
+
continue;
|
|
6849
|
+
}
|
|
6850
|
+
|
|
5143
6851
|
// Handle help command
|
|
5144
|
-
if (input.toLowerCase() === '/help') {
|
|
6852
|
+
if (input.toLowerCase() === '/help' || input.toLowerCase() === '/commands' || input.toLowerCase() === '/cmd') {
|
|
5145
6853
|
console.log();
|
|
5146
|
-
console.log(
|
|
5147
|
-
console.log(commandRow('@ or /attach', 'Pick files to attach interactively'));
|
|
5148
|
-
console.log(commandRow('@file', 'Attach a file inline, for example @src/app.js'));
|
|
5149
|
-
console.log(commandRow('/scan', 'Scan the codebase into context'));
|
|
5150
|
-
console.log(commandRow('/index', 'Rebuild the workspace graph'));
|
|
5151
|
-
console.log(commandRow('/graph file', 'Show related files from the graph'));
|
|
5152
|
-
console.log(commandRow('/symbol name', 'Search indexed functions and classes'));
|
|
5153
|
-
console.log(commandRow('/auto', 'Toggle automatic related-file attach'));
|
|
5154
|
-
console.log();
|
|
5155
|
-
console.log(sectionTitle('Context', 'memory and visibility', 'cyan'));
|
|
5156
|
-
console.log(commandRow('/recall', 'Search memory for relevant context'));
|
|
5157
|
-
console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
|
|
5158
|
-
console.log(commandRow('/reset /clear', 'Clear all current context'));
|
|
5159
|
-
console.log(commandRow('/prune', 'Summarize long context and store memory'));
|
|
5160
|
-
console.log(commandRow('/summary', 'Show or change auto-summary settings'));
|
|
5161
|
-
console.log(commandRow('/shell', 'Inspect shell config and background sessions'));
|
|
5162
|
-
console.log(commandRow('/shell read <id>', 'Read output from a tracked shell session'));
|
|
5163
|
-
console.log(commandRow('/shell stop <id>', 'Stop a tracked shell session'));
|
|
5164
|
-
console.log(commandRow('/context', 'Inspect token usage, summary trigger, and model window'));
|
|
5165
|
-
console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
|
|
5166
|
-
console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
|
|
5167
|
-
console.log(commandRow('/log', 'Show the session activity timeline'));
|
|
5168
|
-
console.log(commandRow('/log stats', 'Show session statistics'));
|
|
5169
|
-
console.log(commandRow('/log file', 'Show log file path and history'));
|
|
5170
|
-
console.log(commandRow('/help', 'Open this command view again'));
|
|
5171
|
-
console.log(commandRow('exit', 'Quit Sapper'));
|
|
5172
|
-
console.log(UI.slate(' Summary settings: /summary | /summary phases off | /summary trigger 60'));
|
|
5173
|
-
console.log(UI.slate(' Tool config: .sapper/config.json -> toolRoundLimit (default 40)'));
|
|
5174
|
-
console.log(UI.slate(' Shell config: .sapper/config.json -> shell.streamToModel, shell.backgroundMode [off|auto|on], shell.backgroundAfterSeconds, shell.outputChunkChars'));
|
|
5175
|
-
console.log(UI.slate(' Want to see all live shell output? Set shell.backgroundMode to off. thinking.mode only controls model reasoning.'));
|
|
5176
|
-
console.log(UI.slate(' Streaming config: .sapper/config.json -> streaming.showPhaseStatus, streaming.showHeartbeat, streaming.idleNoticeSeconds'));
|
|
5177
|
-
console.log(UI.slate(' Thinking config: .sapper/config.json -> thinking.mode [auto|on|off]'));
|
|
5178
|
-
console.log(UI.slate(' Prompt config: .sapper/config.json -> prompt.prepend, prompt.append, prompt.coreOverride'));
|
|
5179
|
-
console.log();
|
|
5180
|
-
console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
|
|
5181
|
-
console.log(commandRow('/agents', 'List available agents'));
|
|
5182
|
-
console.log(commandRow('/skills', 'List available skills'));
|
|
5183
|
-
console.log(commandRow('/agentname', 'Switch to an agent such as /reviewer'));
|
|
5184
|
-
console.log(commandRow('/default', 'Return to the default Sapper role'));
|
|
5185
|
-
console.log(commandRow('/use skill', 'Load a skill into the session'));
|
|
5186
|
-
console.log(commandRow('/unload skill', 'Unload a previously loaded skill'));
|
|
5187
|
-
console.log(commandRow('/newagent', 'Create a new agent'));
|
|
5188
|
-
console.log(commandRow('/newskill', 'Create a new skill'));
|
|
5189
|
-
console.log(divider());
|
|
6854
|
+
console.log(renderCommandPalette());
|
|
5190
6855
|
console.log();
|
|
5191
6856
|
continue;
|
|
5192
6857
|
}
|
|
@@ -5226,12 +6891,14 @@ async function runSapper() {
|
|
|
5226
6891
|
`${chalk.cyan('Methods:')} ${methods.length}\n` +
|
|
5227
6892
|
chalk.gray('─'.repeat(30)) + '\n' +
|
|
5228
6893
|
chalk.gray('Usage: /symbol <name> to search'),
|
|
5229
|
-
'📦 Symbol Index', 'cyan'
|
|
6894
|
+
uiCleanMode() ? 'Symbol Index' : '📦 Symbol Index', 'cyan'
|
|
5230
6895
|
));
|
|
5231
6896
|
continue;
|
|
5232
6897
|
}
|
|
5233
6898
|
|
|
5234
|
-
console.log(
|
|
6899
|
+
console.log(uiCleanMode()
|
|
6900
|
+
? chalk.cyan(`\nSearching for: "${query}"...\n`)
|
|
6901
|
+
: chalk.cyan(`\n🔍 Searching for: "${query}"...\n`));
|
|
5235
6902
|
const results = searchSymbol(query, workspace);
|
|
5236
6903
|
|
|
5237
6904
|
if (results.length === 0) {
|
|
@@ -5243,9 +6910,14 @@ async function runSapper() {
|
|
|
5243
6910
|
console.log(chalk.green(`Found ${results.length} symbol${results.length !== 1 ? 's' : ''}:\n`));
|
|
5244
6911
|
|
|
5245
6912
|
for (const sym of results.slice(0, 15)) {
|
|
5246
|
-
const
|
|
5247
|
-
|
|
5248
|
-
|
|
6913
|
+
const clean = uiCleanMode();
|
|
6914
|
+
const typeIcon = sym.type === 'function'
|
|
6915
|
+
? (clean ? chalk.yellow('f') : chalk.yellow('𝑓'))
|
|
6916
|
+
: sym.type === 'class'
|
|
6917
|
+
? (clean ? chalk.blue('C') : chalk.blue('◆'))
|
|
6918
|
+
: sym.type === 'method'
|
|
6919
|
+
? (clean ? chalk.cyan('m') : chalk.cyan('○'))
|
|
6920
|
+
: (clean ? chalk.gray('-') : chalk.gray('◇'));
|
|
5249
6921
|
const asyncTag = sym.async ? chalk.magenta('async ') : '';
|
|
5250
6922
|
const params = sym.params !== undefined ? chalk.gray(`(${sym.params})`) : '';
|
|
5251
6923
|
|
|
@@ -5260,7 +6932,7 @@ async function runSapper() {
|
|
|
5260
6932
|
// Offer to add file to context
|
|
5261
6933
|
if (results.length > 0) {
|
|
5262
6934
|
console.log();
|
|
5263
|
-
const addToCtx = await safeQuestion(chalk.yellow('Add first match file to context?
|
|
6935
|
+
const addToCtx = await safeQuestion(chalk.yellow(promptLabel('questions.addFirstMatchFileToContext', 'Add first match file to context? (y/n): ')));
|
|
5264
6936
|
if (addToCtx.toLowerCase() === 'y') {
|
|
5265
6937
|
const targetFile = results[0].file;
|
|
5266
6938
|
try {
|
|
@@ -5310,15 +6982,15 @@ async function runSapper() {
|
|
|
5310
6982
|
chalk.gray('─'.repeat(40)) + '\n' +
|
|
5311
6983
|
`${chalk.white('Related files:')}\n` +
|
|
5312
6984
|
(related.length > 0
|
|
5313
|
-
? related.map(r => ` 📄 ${r}`).join('\n')
|
|
6985
|
+
? related.map(r => uiCleanMode() ? ` - ${r}` : ` 📄 ${r}`).join('\n')
|
|
5314
6986
|
: chalk.gray(' (no related files found)')),
|
|
5315
|
-
'🔗 File Graph', 'cyan'
|
|
6987
|
+
uiCleanMode() ? 'File Graph' : '🔗 File Graph', 'cyan'
|
|
5316
6988
|
));
|
|
5317
6989
|
console.log();
|
|
5318
6990
|
|
|
5319
6991
|
// Offer to add to context
|
|
5320
6992
|
if (related.length > 0) {
|
|
5321
|
-
const addRelated = await safeQuestion(chalk.yellow('Add this file + related to context?
|
|
6993
|
+
const addRelated = await safeQuestion(chalk.yellow(promptLabel('questions.addFileAndRelatedToContext', 'Add this file + related to context? (y/n): ')));
|
|
5322
6994
|
if (addRelated.toLowerCase() === 'y') {
|
|
5323
6995
|
let contextContent = `\n📄 ${matchingFile}:\n`;
|
|
5324
6996
|
contextContent += fs.readFileSync(matchingFile, 'utf8');
|
|
@@ -5344,7 +7016,9 @@ async function runSapper() {
|
|
|
5344
7016
|
if (input.toLowerCase() === '/auto') {
|
|
5345
7017
|
sapperConfig.autoAttach = !sapperConfig.autoAttach;
|
|
5346
7018
|
saveConfig(sapperConfig);
|
|
5347
|
-
console.log(
|
|
7019
|
+
console.log(uiCleanMode()
|
|
7020
|
+
? chalk.cyan(`\nAuto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`)
|
|
7021
|
+
: chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
5348
7022
|
if (sapperConfig.autoAttach) {
|
|
5349
7023
|
console.log(chalk.gray(' When you @file, related imports will be auto-included.'));
|
|
5350
7024
|
} else {
|
|
@@ -5384,36 +7058,243 @@ async function runSapper() {
|
|
|
5384
7058
|
console.log(chalk.yellow(` ⚠ Limit exceeds model's ${modelContextLength.toLocaleString()} context — may cause errors`));
|
|
5385
7059
|
}
|
|
5386
7060
|
}
|
|
5387
|
-
} else {
|
|
5388
|
-
// Show current setting
|
|
5389
|
-
const effective = effectiveContextLength();
|
|
5390
|
-
const custom = sapperConfig.contextLimit;
|
|
5391
|
-
const lines = [
|
|
5392
|
-
`model default ${chalk.white(modelContextLength ? modelContextLength.toLocaleString() : 'unknown')} tokens`,
|
|
5393
|
-
`custom limit ${custom ? chalk.cyan.bold(custom.toLocaleString() + ' tokens') : UI.slate('not set (using model default)')}`,
|
|
5394
|
-
`effective ${chalk.white.bold(effective ? effective.toLocaleString() + ' tokens' : 'unknown')}`,
|
|
5395
|
-
];
|
|
5396
|
-
console.log();
|
|
5397
|
-
console.log(box(lines.join('\n'), 'Context Limit', 'cyan'));
|
|
5398
|
-
console.log(UI.slate(' Set: /ctx 64k | /ctx 32768 | /ctx reset'));
|
|
7061
|
+
} else {
|
|
7062
|
+
// Show current setting
|
|
7063
|
+
const effective = effectiveContextLength();
|
|
7064
|
+
const custom = sapperConfig.contextLimit;
|
|
7065
|
+
const lines = [
|
|
7066
|
+
`model default ${chalk.white(modelContextLength ? modelContextLength.toLocaleString() : 'unknown')} tokens`,
|
|
7067
|
+
`custom limit ${custom ? chalk.cyan.bold(custom.toLocaleString() + ' tokens') : UI.slate('not set (using model default)')}`,
|
|
7068
|
+
`effective ${chalk.white.bold(effective ? effective.toLocaleString() + ' tokens' : 'unknown')}`,
|
|
7069
|
+
];
|
|
7070
|
+
console.log();
|
|
7071
|
+
console.log(box(lines.join('\n'), 'Context Limit', 'cyan'));
|
|
7072
|
+
console.log(UI.slate(' Set: /ctx 64k | /ctx 32768 | /ctx reset'));
|
|
7073
|
+
}
|
|
7074
|
+
continue;
|
|
7075
|
+
}
|
|
7076
|
+
|
|
7077
|
+
if (input.toLowerCase().startsWith('/summary')) {
|
|
7078
|
+
const arg = input.substring(8).trim();
|
|
7079
|
+
|
|
7080
|
+
if (!arg) {
|
|
7081
|
+
const effective = effectiveContextLength();
|
|
7082
|
+
const threshold = summaryTokenThreshold(effective);
|
|
7083
|
+
const lines = [
|
|
7084
|
+
`phases ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`,
|
|
7085
|
+
`trigger ${chalk.white.bold(summaryTriggerPercent() + '%')} ${UI.slate(`(~${threshold.toLocaleString()} tokens)`)}`,
|
|
7086
|
+
`config file ${chalk.white(CONFIG_FILE)}`,
|
|
7087
|
+
];
|
|
7088
|
+
console.log();
|
|
7089
|
+
console.log(box(lines.join('\n'), 'Summary Settings', 'cyan'));
|
|
7090
|
+
console.log(UI.slate(' Usage: /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
|
|
7091
|
+
continue;
|
|
7092
|
+
}
|
|
7093
|
+
|
|
7094
|
+
const [subcommandRaw, ...rest] = arg.split(/\s+/);
|
|
7095
|
+
const subcommand = subcommandRaw.toLowerCase();
|
|
7096
|
+
const value = rest.join(' ').trim();
|
|
7097
|
+
|
|
7098
|
+
if (subcommand === 'reset' || subcommand === 'default') {
|
|
7099
|
+
sapperConfig.summaryPhases = DEFAULT_CONFIG.summaryPhases;
|
|
7100
|
+
sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
|
|
7101
|
+
saveConfig(sapperConfig);
|
|
7102
|
+
console.log(chalk.green(`✅ Summary settings reset: phases ${summaryPhasesEnabled() ? 'ON' : 'OFF'}, trigger ${summaryTriggerPercent()}%`));
|
|
7103
|
+
continue;
|
|
7104
|
+
}
|
|
7105
|
+
|
|
7106
|
+
if (subcommand === 'phases' || subcommand === 'phase') {
|
|
7107
|
+
let nextValue = null;
|
|
7108
|
+
|
|
7109
|
+
if (!value) {
|
|
7110
|
+
nextValue = !summaryPhasesEnabled();
|
|
7111
|
+
} else {
|
|
7112
|
+
const normalized = value.toLowerCase();
|
|
7113
|
+
if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(normalized)) {
|
|
7114
|
+
nextValue = true;
|
|
7115
|
+
} else if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(normalized)) {
|
|
7116
|
+
nextValue = false;
|
|
7117
|
+
} else if (['toggle', 'flip'].includes(normalized)) {
|
|
7118
|
+
nextValue = !summaryPhasesEnabled();
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
|
|
7122
|
+
if (nextValue === null) {
|
|
7123
|
+
console.log(chalk.yellow('Usage: /summary phases [on|off]'));
|
|
7124
|
+
continue;
|
|
7125
|
+
}
|
|
7126
|
+
|
|
7127
|
+
sapperConfig.summaryPhases = nextValue;
|
|
7128
|
+
saveConfig(sapperConfig);
|
|
7129
|
+
console.log(chalk.green(`✅ Summary phases: ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
7130
|
+
continue;
|
|
7131
|
+
}
|
|
7132
|
+
|
|
7133
|
+
if (subcommand === 'trigger' || subcommand === 'percent' || subcommand === 'threshold') {
|
|
7134
|
+
if (!value) {
|
|
7135
|
+
console.log(chalk.yellow('Usage: /summary trigger <percent>'));
|
|
7136
|
+
console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
|
|
7137
|
+
continue;
|
|
7138
|
+
}
|
|
7139
|
+
|
|
7140
|
+
const parsedTrigger = parseSummaryTriggerInput(value);
|
|
7141
|
+
if (parsedTrigger === null) {
|
|
7142
|
+
console.log(chalk.yellow(`Invalid summary trigger: ${value}`));
|
|
7143
|
+
console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
|
|
7144
|
+
continue;
|
|
7145
|
+
}
|
|
7146
|
+
|
|
7147
|
+
sapperConfig.summarizeTriggerPercent = parsedTrigger;
|
|
7148
|
+
saveConfig(sapperConfig);
|
|
7149
|
+
const effective = effectiveContextLength();
|
|
7150
|
+
const threshold = summaryTokenThreshold(effective);
|
|
7151
|
+
console.log(chalk.green(`✅ Summary trigger set to ${chalk.white.bold(summaryTriggerPercent() + '%')}`));
|
|
7152
|
+
console.log(chalk.gray(` Auto-summary will start near ${threshold.toLocaleString()} tokens.`));
|
|
7153
|
+
continue;
|
|
7154
|
+
}
|
|
7155
|
+
|
|
7156
|
+
console.log(chalk.yellow(`Unknown summary option: ${subcommand}`));
|
|
7157
|
+
console.log(chalk.gray(' Usage: /summary | /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
|
|
7158
|
+
continue;
|
|
7159
|
+
}
|
|
7160
|
+
|
|
7161
|
+
if (input.toLowerCase().startsWith('/chunking') || input.toLowerCase().startsWith('/chunk ') || input.toLowerCase() === '/chunk') {
|
|
7162
|
+
const arg = input.replace(/^\/chunk(ing)?/i, '').trim();
|
|
7163
|
+
|
|
7164
|
+
if (!arg || ['status', 'show'].includes(arg.toLowerCase())) {
|
|
7165
|
+
const cfg = getChunkingConfig();
|
|
7166
|
+
const lines = [
|
|
7167
|
+
`enabled ${cfg.enabled ? chalk.green('ON') : chalk.red('OFF')}`,
|
|
7168
|
+
`context lines ${chalk.white.bold(cfg.contextLines)} ${UI.slate('(lines above & below each match)')}`,
|
|
7169
|
+
`max per file ${chalk.white.bold(cfg.maxChunksPerFile)} ${UI.slate('(chunk groups returned per file)')}`,
|
|
7170
|
+
`auto-suggest ${chalk.white.bold(cfg.autoChunkAboveLines)} ${UI.slate('(prefer chunking when files exceed this many lines)')}`,
|
|
7171
|
+
`config file ${chalk.white(CONFIG_FILE)}`,
|
|
7172
|
+
];
|
|
7173
|
+
console.log();
|
|
7174
|
+
console.log(box(lines.join('\n'), 'Chunked Reading', 'cyan'));
|
|
7175
|
+
console.log(UI.slate(' Usage: /chunking on|off | /chunking context <N> | /chunking max <N> | /chunking auto <N> | /chunking reset'));
|
|
7176
|
+
continue;
|
|
7177
|
+
}
|
|
7178
|
+
|
|
7179
|
+
const [subcommandRaw, ...rest] = arg.split(/\s+/);
|
|
7180
|
+
const subcommand = subcommandRaw.toLowerCase();
|
|
7181
|
+
const value = rest.join(' ').trim();
|
|
7182
|
+
const updateChunking = (patch) => {
|
|
7183
|
+
sapperConfig.chunking = { ...getChunkingConfig(), ...patch };
|
|
7184
|
+
saveConfig(sapperConfig);
|
|
7185
|
+
};
|
|
7186
|
+
|
|
7187
|
+
if (subcommand === 'reset' || subcommand === 'default') {
|
|
7188
|
+
sapperConfig.chunking = { ...DEFAULT_CONFIG.chunking };
|
|
7189
|
+
saveConfig(sapperConfig);
|
|
7190
|
+
console.log(chalk.green(`✅ Chunking reset to defaults: enabled=${chunkingEnabled() ? 'ON' : 'OFF'}, context=${chunkingContextLines()}, maxPerFile=${chunkingMaxPerFile()}`));
|
|
7191
|
+
continue;
|
|
7192
|
+
}
|
|
7193
|
+
|
|
7194
|
+
if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(subcommand)) {
|
|
7195
|
+
updateChunking({ enabled: true });
|
|
7196
|
+
console.log(chalk.green('✅ Chunked reading: ' + chalk.green('ON')));
|
|
7197
|
+
continue;
|
|
7198
|
+
}
|
|
7199
|
+
if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(subcommand)) {
|
|
7200
|
+
updateChunking({ enabled: false });
|
|
7201
|
+
console.log(chalk.yellow('✅ Chunked reading: ' + chalk.red('OFF') + chalk.gray(' (agent will read full files)')));
|
|
7202
|
+
continue;
|
|
7203
|
+
}
|
|
7204
|
+
if (subcommand === 'toggle' || subcommand === 'flip') {
|
|
7205
|
+
updateChunking({ enabled: !chunkingEnabled() });
|
|
7206
|
+
console.log(chalk.green(`✅ Chunked reading: ${chunkingEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
7207
|
+
continue;
|
|
7208
|
+
}
|
|
7209
|
+
|
|
7210
|
+
if (subcommand === 'context' || subcommand === 'lines') {
|
|
7211
|
+
const n = parseInt(value, 10);
|
|
7212
|
+
if (!Number.isFinite(n) || n < 0 || n > 200) {
|
|
7213
|
+
console.log(chalk.yellow('Usage: /chunking context <0-200>'));
|
|
7214
|
+
continue;
|
|
7215
|
+
}
|
|
7216
|
+
updateChunking({ contextLines: n });
|
|
7217
|
+
console.log(chalk.green(`✅ Chunk context lines set to ${chalk.white.bold(chunkingContextLines())}`));
|
|
7218
|
+
continue;
|
|
7219
|
+
}
|
|
7220
|
+
|
|
7221
|
+
if (subcommand === 'max' || subcommand === 'maxperfile') {
|
|
7222
|
+
const n = parseInt(value, 10);
|
|
7223
|
+
if (!Number.isFinite(n) || n < 1 || n > 50) {
|
|
7224
|
+
console.log(chalk.yellow('Usage: /chunking max <1-50>'));
|
|
7225
|
+
continue;
|
|
7226
|
+
}
|
|
7227
|
+
updateChunking({ maxChunksPerFile: n });
|
|
7228
|
+
console.log(chalk.green(`✅ Max chunks per file set to ${chalk.white.bold(chunkingMaxPerFile())}`));
|
|
7229
|
+
continue;
|
|
7230
|
+
}
|
|
7231
|
+
|
|
7232
|
+
if (subcommand === 'auto' || subcommand === 'autoabove') {
|
|
7233
|
+
const n = parseInt(value, 10);
|
|
7234
|
+
if (!Number.isFinite(n) || n < 50 || n > 10000) {
|
|
7235
|
+
console.log(chalk.yellow('Usage: /chunking auto <50-10000>'));
|
|
7236
|
+
continue;
|
|
7237
|
+
}
|
|
7238
|
+
updateChunking({ autoChunkAboveLines: n });
|
|
7239
|
+
console.log(chalk.green(`✅ Auto-chunk threshold set to ${chalk.white.bold(chunkingAutoAboveLines())} lines`));
|
|
7240
|
+
continue;
|
|
5399
7241
|
}
|
|
7242
|
+
|
|
7243
|
+
console.log(chalk.yellow(`Unknown chunking option: ${subcommand}`));
|
|
7244
|
+
console.log(chalk.gray(' Usage: /chunking | /chunking on|off | /chunking context <N> | /chunking max <N> | /chunking auto <N> | /chunking reset'));
|
|
5400
7245
|
continue;
|
|
5401
7246
|
}
|
|
5402
7247
|
|
|
5403
|
-
|
|
5404
|
-
const
|
|
7248
|
+
{
|
|
7249
|
+
const _voiceLower = input.toLowerCase();
|
|
7250
|
+
const _isVoiceCmd =
|
|
7251
|
+
_voiceLower === '/voice' || _voiceLower === '/v' ||
|
|
7252
|
+
_voiceLower.startsWith('/voice ') || _voiceLower.startsWith('/v ');
|
|
7253
|
+
if (_isVoiceCmd) {
|
|
7254
|
+
const arg = (_voiceLower.startsWith('/voice') ? input.substring(6) : input.substring(2)).trim();
|
|
7255
|
+
const cfg = getVoiceConfig();
|
|
7256
|
+
const updateVoice = (patch) => {
|
|
7257
|
+
sapperConfig.voice = { ...getVoiceConfig(), ...patch };
|
|
7258
|
+
saveConfig(sapperConfig);
|
|
7259
|
+
};
|
|
5405
7260
|
|
|
5406
|
-
if (!arg) {
|
|
5407
|
-
const
|
|
5408
|
-
|
|
7261
|
+
if (!arg || ['status', 'show'].includes(arg.toLowerCase())) {
|
|
7262
|
+
const archiveDirDisplay = isAbsolute(cfg.archiveDir)
|
|
7263
|
+
? cfg.archiveDir
|
|
7264
|
+
: pathResolve(getToolWorkingDirectory(), cfg.archiveDir);
|
|
5409
7265
|
const lines = [
|
|
5410
|
-
`
|
|
5411
|
-
`
|
|
5412
|
-
`
|
|
7266
|
+
`enabled ${cfg.enabled ? chalk.green('ON') : chalk.red('OFF')}`,
|
|
7267
|
+
`whisper bin ${chalk.white(cfg.whisperBin)}`,
|
|
7268
|
+
`model ${fs.existsSync(cfg.model) ? chalk.white(cfg.model) : chalk.red(cfg.model + ' (not found)')}`,
|
|
7269
|
+
`language ${chalk.white(cfg.language)}`,
|
|
7270
|
+
`recorder ${chalk.white(cfg.recorder)} ${UI.slate(`(device: ${cfg.device})`)}`,
|
|
7271
|
+
`record seconds ${chalk.white.bold(cfg.recordSeconds)}`,
|
|
7272
|
+
`sample rate ${chalk.white(cfg.sampleRate + ' Hz')}`,
|
|
7273
|
+
`translate ${cfg.translate ? chalk.green('ON') : chalk.red('OFF')} ${UI.slate('(force English output)')}`,
|
|
7274
|
+
`auto-send ${cfg.autoSend ? chalk.green('ON') : chalk.red('OFF')} ${UI.slate('(skip confirm before sending to AI)')}`,
|
|
7275
|
+
`archive ${cfg.archive ? chalk.green('ON') : chalk.red('OFF')} ${UI.slate(`→ ${archiveDirDisplay}/YYYY-MM-DD/`)}`,
|
|
5413
7276
|
];
|
|
5414
7277
|
console.log();
|
|
5415
|
-
console.log(box(lines.join('\n'), '
|
|
5416
|
-
console.log(UI.slate(' Usage:
|
|
7278
|
+
console.log(box(lines.join('\n'), '🎙️ Voice / Whisper', 'cyan'));
|
|
7279
|
+
console.log(UI.slate(' Usage:'));
|
|
7280
|
+
console.log(UI.slate(' /v live live preview while you speak, clean final transcript on stop'));
|
|
7281
|
+
console.log(UI.slate(' /v record record from mic — press any key to stop'));
|
|
7282
|
+
console.log(UI.slate(' /v record <seconds> record for a fixed duration'));
|
|
7283
|
+
console.log(UI.slate(' /v talk alias for push-to-stop recording'));
|
|
7284
|
+
console.log(UI.slate(' /v file <path> transcribe an existing audio file'));
|
|
7285
|
+
console.log(UI.slate(' /voice on|off toggle the feature'));
|
|
7286
|
+
console.log(UI.slate(' /voice model list available models and pick one'));
|
|
7287
|
+
console.log(UI.slate(' /voice model <N|path> pick model by number or set path'));
|
|
7288
|
+
console.log(UI.slate(' /voice lang <code|auto> set language'));
|
|
7289
|
+
console.log(UI.slate(' /voice seconds <n> default record duration'));
|
|
7290
|
+
console.log(UI.slate(' /voice device <id> mic device (e.g. :0)'));
|
|
7291
|
+
console.log(UI.slate(' /voice recorder ffmpeg|sox'));
|
|
7292
|
+
console.log(UI.slate(' /voice translate on|off translate to English'));
|
|
7293
|
+
console.log(UI.slate(' /voice autosend on|off skip confirm and send to AI'));
|
|
7294
|
+
console.log(UI.slate(' /voice archive on|off save recordings + transcripts to disk'));
|
|
7295
|
+
console.log(UI.slate(' /voice archive path <dir> set archive folder (default .sapper/voice)'));
|
|
7296
|
+
console.log(UI.slate(' /voice archive open reveal archive folder in Finder/Explorer'));
|
|
7297
|
+
console.log(UI.slate(' /voice reset restore defaults'));
|
|
5417
7298
|
continue;
|
|
5418
7299
|
}
|
|
5419
7300
|
|
|
@@ -5421,66 +7302,458 @@ async function runSapper() {
|
|
|
5421
7302
|
const subcommand = subcommandRaw.toLowerCase();
|
|
5422
7303
|
const value = rest.join(' ').trim();
|
|
5423
7304
|
|
|
7305
|
+
// Settings subcommands
|
|
5424
7306
|
if (subcommand === 'reset' || subcommand === 'default') {
|
|
5425
|
-
sapperConfig.
|
|
5426
|
-
sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
|
|
7307
|
+
sapperConfig.voice = { ...DEFAULT_CONFIG.voice };
|
|
5427
7308
|
saveConfig(sapperConfig);
|
|
5428
|
-
console.log(chalk.green(
|
|
7309
|
+
console.log(chalk.green('✅ Voice settings reset to defaults'));
|
|
5429
7310
|
continue;
|
|
5430
7311
|
}
|
|
7312
|
+
if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(subcommand)) {
|
|
7313
|
+
updateVoice({ enabled: true });
|
|
7314
|
+
console.log(chalk.green('✅ Voice input: ' + chalk.green('ON')));
|
|
7315
|
+
continue;
|
|
7316
|
+
}
|
|
7317
|
+
if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(subcommand)) {
|
|
7318
|
+
updateVoice({ enabled: false });
|
|
7319
|
+
console.log(chalk.yellow('✅ Voice input: ' + chalk.red('OFF')));
|
|
7320
|
+
continue;
|
|
7321
|
+
}
|
|
7322
|
+
if (subcommand === 'model' || subcommand === 'models') {
|
|
7323
|
+
// Build the list of candidate models from common locations
|
|
7324
|
+
const searchDirs = [
|
|
7325
|
+
join(os.homedir(), 'models'),
|
|
7326
|
+
join(os.homedir(), '.whisper'),
|
|
7327
|
+
join(os.homedir(), '.cache', 'whisper'),
|
|
7328
|
+
'/opt/homebrew/share/whisper.cpp/models',
|
|
7329
|
+
'/usr/local/share/whisper.cpp/models',
|
|
7330
|
+
'./models',
|
|
7331
|
+
];
|
|
7332
|
+
const seen = new Set();
|
|
7333
|
+
const found = [];
|
|
7334
|
+
for (const dir of searchDirs) {
|
|
7335
|
+
const abs = isAbsolute(dir) ? dir : pathResolve(getToolWorkingDirectory(), dir);
|
|
7336
|
+
try {
|
|
7337
|
+
if (!fs.existsSync(abs)) continue;
|
|
7338
|
+
for (const f of fs.readdirSync(abs)) {
|
|
7339
|
+
if (!/^ggml-.*\.bin$/i.test(f)) continue; // ggml-*.bin only
|
|
7340
|
+
if (/\.(tmp|partial|download)$/i.test(f)) continue; // skip stale tmp files
|
|
7341
|
+
const full = join(abs, f);
|
|
7342
|
+
if (seen.has(full)) continue;
|
|
7343
|
+
seen.add(full);
|
|
7344
|
+
try {
|
|
7345
|
+
const st = fs.statSync(full);
|
|
7346
|
+
if (st.size < 50 * 1024 * 1024) continue; // skip < 50MB (probably broken)
|
|
7347
|
+
found.push({ path: full, name: f, size: st.size, mtime: st.mtimeMs });
|
|
7348
|
+
} catch {}
|
|
7349
|
+
}
|
|
7350
|
+
} catch {}
|
|
7351
|
+
}
|
|
7352
|
+
found.sort((a, b) => a.name.localeCompare(b.name));
|
|
5431
7353
|
|
|
5432
|
-
|
|
5433
|
-
|
|
7354
|
+
const humanSize = (n) => n >= 1024 * 1024 * 1024
|
|
7355
|
+
? `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
|
|
7356
|
+
: `${(n / 1024 / 1024).toFixed(0)} MB`;
|
|
5434
7357
|
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
} else if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(normalized)) {
|
|
5442
|
-
nextValue = false;
|
|
5443
|
-
} else if (['toggle', 'flip'].includes(normalized)) {
|
|
5444
|
-
nextValue = !summaryPhasesEnabled();
|
|
7358
|
+
// Direct pick by number: /v model 2
|
|
7359
|
+
if (value && /^\d+$/.test(value)) {
|
|
7360
|
+
const idx = parseInt(value, 10) - 1;
|
|
7361
|
+
if (idx < 0 || idx >= found.length) {
|
|
7362
|
+
console.log(chalk.yellow(`No model #${value}. Run /v model (no args) to list.`));
|
|
7363
|
+
continue;
|
|
5445
7364
|
}
|
|
7365
|
+
updateVoice({ model: found[idx].path });
|
|
7366
|
+
console.log(chalk.green(`✅ Whisper model set to ${chalk.white(found[idx].path)}`));
|
|
7367
|
+
continue;
|
|
5446
7368
|
}
|
|
5447
7369
|
|
|
5448
|
-
|
|
5449
|
-
|
|
7370
|
+
// Direct pick by path: /v model /some/path.bin
|
|
7371
|
+
if (value) {
|
|
7372
|
+
const abs = isAbsolute(value) ? value : pathResolve(getToolWorkingDirectory(), value);
|
|
7373
|
+
if (!fs.existsSync(abs)) {
|
|
7374
|
+
console.log(chalk.yellow(`⚠️ File not found: ${abs} (saved anyway — fix the path before recording)`));
|
|
7375
|
+
}
|
|
7376
|
+
updateVoice({ model: abs });
|
|
7377
|
+
console.log(chalk.green(`✅ Whisper model set to ${chalk.white(abs)}`));
|
|
5450
7378
|
continue;
|
|
5451
7379
|
}
|
|
5452
7380
|
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
7381
|
+
// Interactive list + pick
|
|
7382
|
+
if (found.length === 0) {
|
|
7383
|
+
console.log();
|
|
7384
|
+
console.log(chalk.yellow('No ggml-*.bin Whisper models found.'));
|
|
7385
|
+
console.log(UI.slate(' Searched: ' + searchDirs.join(', ')));
|
|
7386
|
+
console.log(UI.slate(' Download one with:'));
|
|
7387
|
+
console.log(UI.slate(' curl -L -o ~/models/ggml-large-v3-turbo.bin \\'));
|
|
7388
|
+
console.log(UI.slate(' https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin'));
|
|
7389
|
+
continue;
|
|
7390
|
+
}
|
|
7391
|
+
|
|
7392
|
+
console.log();
|
|
7393
|
+
console.log(chalk.cyan(`Available Whisper models (${found.length}):`));
|
|
7394
|
+
const currentModel = cfg.model;
|
|
7395
|
+
const nameW = Math.min(40, Math.max(...found.map(m => m.name.length)));
|
|
7396
|
+
for (let i = 0; i < found.length; i++) {
|
|
7397
|
+
const m = found[i];
|
|
7398
|
+
const marker = m.path === currentModel ? chalk.green(' ← current') : '';
|
|
7399
|
+
const num = chalk.cyan(String(i + 1).padStart(2, ' ') + '.');
|
|
7400
|
+
const name = chalk.white(m.name.padEnd(nameW));
|
|
7401
|
+
const size = chalk.gray(humanSize(m.size).padStart(7));
|
|
7402
|
+
const dir = UI.slate(' ' + dirname(m.path));
|
|
7403
|
+
console.log(` ${num} ${name} ${size}${marker}`);
|
|
7404
|
+
console.log(` ${dir}`);
|
|
7405
|
+
}
|
|
7406
|
+
console.log();
|
|
7407
|
+
const pick = await safeQuestion(chalk.yellow(`Pick a model [1-${found.length}, Enter to cancel]: `));
|
|
7408
|
+
const pickN = parseInt(pick.trim(), 10);
|
|
7409
|
+
if (!Number.isFinite(pickN) || pickN < 1 || pickN > found.length) {
|
|
7410
|
+
console.log(UI.slate('Cancelled.'));
|
|
7411
|
+
continue;
|
|
7412
|
+
}
|
|
7413
|
+
updateVoice({ model: found[pickN - 1].path });
|
|
7414
|
+
console.log(chalk.green(`✅ Whisper model set to ${chalk.white(found[pickN - 1].path)}`));
|
|
7415
|
+
continue;
|
|
7416
|
+
}
|
|
7417
|
+
if (subcommand === 'lang' || subcommand === 'language') {
|
|
7418
|
+
if (!value) { console.log(chalk.yellow('Usage: /voice lang <code|auto>')); continue; }
|
|
7419
|
+
updateVoice({ language: value.toLowerCase() });
|
|
7420
|
+
console.log(chalk.green(`✅ Whisper language set to ${chalk.white(value.toLowerCase())}`));
|
|
7421
|
+
continue;
|
|
7422
|
+
}
|
|
7423
|
+
if (subcommand === 'seconds' || subcommand === 'duration') {
|
|
7424
|
+
const n = parseInt(value, 10);
|
|
7425
|
+
if (!Number.isFinite(n) || n < 1 || n > 300) { console.log(chalk.yellow('Usage: /voice seconds <1-300>')); continue; }
|
|
7426
|
+
updateVoice({ recordSeconds: n });
|
|
7427
|
+
console.log(chalk.green(`✅ Default record duration: ${chalk.white.bold(n)}s`));
|
|
7428
|
+
continue;
|
|
7429
|
+
}
|
|
7430
|
+
if (subcommand === 'device' || subcommand === 'mic') {
|
|
7431
|
+
if (!value) { console.log(chalk.yellow('Usage: /voice device <id> (e.g. :0 for ffmpeg avfoundation default mic)')); continue; }
|
|
7432
|
+
updateVoice({ device: value });
|
|
7433
|
+
console.log(chalk.green(`✅ Mic device set to ${chalk.white(value)}`));
|
|
7434
|
+
continue;
|
|
7435
|
+
}
|
|
7436
|
+
if (subcommand === 'recorder') {
|
|
7437
|
+
const r = value.toLowerCase();
|
|
7438
|
+
if (!['ffmpeg', 'sox'].includes(r)) { console.log(chalk.yellow('Usage: /voice recorder ffmpeg|sox')); continue; }
|
|
7439
|
+
updateVoice({ recorder: r });
|
|
7440
|
+
console.log(chalk.green(`✅ Recorder set to ${chalk.white(r)}`));
|
|
7441
|
+
continue;
|
|
7442
|
+
}
|
|
7443
|
+
if (subcommand === 'translate') {
|
|
7444
|
+
const on = ['on', 'true', 'yes', '1'].includes(value.toLowerCase());
|
|
7445
|
+
const off = ['off', 'false', 'no', '0'].includes(value.toLowerCase());
|
|
7446
|
+
if (!on && !off) { console.log(chalk.yellow('Usage: /voice translate on|off')); continue; }
|
|
7447
|
+
updateVoice({ translate: on });
|
|
7448
|
+
console.log(chalk.green(`✅ Translate to English: ${on ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
7449
|
+
continue;
|
|
7450
|
+
}
|
|
7451
|
+
if (subcommand === 'autosend' || subcommand === 'auto-send' || subcommand === 'auto') {
|
|
7452
|
+
const on = ['on', 'true', 'yes', '1'].includes(value.toLowerCase());
|
|
7453
|
+
const off = ['off', 'false', 'no', '0'].includes(value.toLowerCase());
|
|
7454
|
+
if (!on && !off) { console.log(chalk.yellow('Usage: /voice autosend on|off')); continue; }
|
|
7455
|
+
updateVoice({ autoSend: on });
|
|
7456
|
+
console.log(chalk.green(`✅ Auto-send transcript: ${on ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
7457
|
+
continue;
|
|
7458
|
+
}
|
|
7459
|
+
if (subcommand === 'archive') {
|
|
7460
|
+
const [archSubRaw, ...archRest] = (value || '').split(/\s+/);
|
|
7461
|
+
const archSub = (archSubRaw || '').toLowerCase();
|
|
7462
|
+
const archValue = archRest.join(' ').trim();
|
|
7463
|
+
const archiveDirAbs = isAbsolute(cfg.archiveDir)
|
|
7464
|
+
? cfg.archiveDir
|
|
7465
|
+
: pathResolve(getToolWorkingDirectory(), cfg.archiveDir);
|
|
7466
|
+
|
|
7467
|
+
if (!archSub || archSub === 'show' || archSub === 'status') {
|
|
7468
|
+
const exists = fs.existsSync(archiveDirAbs);
|
|
7469
|
+
console.log(chalk.cyan('Archive') + ': ' + (cfg.archive ? chalk.green('ON') : chalk.red('OFF')));
|
|
7470
|
+
console.log(chalk.cyan('Folder') + ': ' + chalk.white(archiveDirAbs) + (exists ? UI.slate(' (exists)') : UI.slate(' (will be created on next recording)')));
|
|
7471
|
+
if (exists) {
|
|
7472
|
+
try {
|
|
7473
|
+
const days = fs.readdirSync(archiveDirAbs).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().slice(0, 5);
|
|
7474
|
+
if (days.length) {
|
|
7475
|
+
console.log(chalk.cyan('Recent days') + ':');
|
|
7476
|
+
for (const d of days) {
|
|
7477
|
+
const files = fs.readdirSync(join(archiveDirAbs, d));
|
|
7478
|
+
const wavs = files.filter(f => f.endsWith('.wav')).length;
|
|
7479
|
+
const txts = files.filter(f => f.endsWith('.txt')).length;
|
|
7480
|
+
console.log(UI.slate(` ${d} → ${wavs} audio, ${txts} transcripts`));
|
|
7481
|
+
}
|
|
7482
|
+
}
|
|
7483
|
+
} catch {}
|
|
7484
|
+
}
|
|
7485
|
+
console.log(UI.slate(' /voice archive on|off toggle archiving'));
|
|
7486
|
+
console.log(UI.slate(' /voice archive path <dir> change archive folder'));
|
|
7487
|
+
console.log(UI.slate(' /voice archive open reveal folder in Finder/Explorer'));
|
|
7488
|
+
continue;
|
|
7489
|
+
}
|
|
7490
|
+
if (['on', 'true', 'yes', '1'].includes(archSub)) {
|
|
7491
|
+
updateVoice({ archive: true });
|
|
7492
|
+
console.log(chalk.green('✅ Voice archiving: ' + chalk.green('ON')));
|
|
7493
|
+
continue;
|
|
7494
|
+
}
|
|
7495
|
+
if (['off', 'false', 'no', '0'].includes(archSub)) {
|
|
7496
|
+
updateVoice({ archive: false });
|
|
7497
|
+
console.log(chalk.green('✅ Voice archiving: ' + chalk.red('OFF')));
|
|
7498
|
+
continue;
|
|
7499
|
+
}
|
|
7500
|
+
if (archSub === 'path' || archSub === 'dir' || archSub === 'folder') {
|
|
7501
|
+
if (!archValue) { console.log(chalk.yellow('Usage: /voice archive path <dir>')); continue; }
|
|
7502
|
+
updateVoice({ archiveDir: archValue });
|
|
7503
|
+
console.log(chalk.green(`✅ Archive folder set to ${chalk.white(archValue)}`));
|
|
7504
|
+
continue;
|
|
7505
|
+
}
|
|
7506
|
+
if (archSub === 'open' || archSub === 'reveal') {
|
|
7507
|
+
try { fs.mkdirSync(archiveDirAbs, { recursive: true }); } catch {}
|
|
7508
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
7509
|
+
: process.platform === 'win32' ? 'explorer'
|
|
7510
|
+
: 'xdg-open';
|
|
7511
|
+
try {
|
|
7512
|
+
spawn(opener, [archiveDirAbs], { detached: true, stdio: 'ignore' }).unref();
|
|
7513
|
+
console.log(chalk.green(`✅ Opening ${archiveDirAbs}`));
|
|
7514
|
+
} catch (err) {
|
|
7515
|
+
console.log(chalk.red(`Failed to open: ${err.message}`));
|
|
7516
|
+
}
|
|
7517
|
+
continue;
|
|
7518
|
+
}
|
|
7519
|
+
console.log(chalk.yellow(`Unknown archive option: ${archSub}`));
|
|
7520
|
+
console.log(chalk.gray(' Run "/voice archive" with no args to see usage.'));
|
|
5456
7521
|
continue;
|
|
5457
7522
|
}
|
|
5458
7523
|
|
|
5459
|
-
|
|
7524
|
+
// Action subcommands: record / rec / talk / live / file
|
|
7525
|
+
if (subcommand === 'live' || subcommand === 'stream') {
|
|
7526
|
+
if (!cfg.enabled) {
|
|
7527
|
+
console.log(chalk.yellow('Voice input is OFF. Enable with /voice on first.'));
|
|
7528
|
+
continue;
|
|
7529
|
+
}
|
|
7530
|
+
// Phase 1: live preview + save audio (no final text yet)
|
|
7531
|
+
const res = await runWhisperLive({
|
|
7532
|
+
whisperStreamBin: cfg.whisperStreamBin,
|
|
7533
|
+
model: cfg.model,
|
|
7534
|
+
language: cfg.language,
|
|
7535
|
+
translate: cfg.translate,
|
|
7536
|
+
stepMs: cfg.liveStepMs,
|
|
7537
|
+
lengthMs: cfg.liveLengthMs,
|
|
7538
|
+
keepMs: cfg.liveKeepMs,
|
|
7539
|
+
});
|
|
7540
|
+
if (!res.ok) { console.log(chalk.red(`❌ ${res.error}`)); continue; }
|
|
7541
|
+
if (!res.audioPath) {
|
|
7542
|
+
if (res.audioTmpDir) { try { fs.rmSync(res.audioTmpDir, { recursive: true, force: true }); } catch {} }
|
|
7543
|
+
console.log(chalk.yellow('No audio captured.'));
|
|
7544
|
+
continue;
|
|
7545
|
+
}
|
|
7546
|
+
|
|
7547
|
+
// Phase 2: single clean transcription pass on the saved WAV (this is
|
|
7548
|
+
// the trick — whisper-stream's sliding window produces overlapping
|
|
7549
|
+
// chunks, so we ignore that text and re-decode the whole file once).
|
|
7550
|
+
const finalSpinner = ora(chalk.cyan(`Transcribing with ${cfg.whisperBin} (${cfg.model.split('/').pop()})...`)).start();
|
|
7551
|
+
const finalRes = await runWhisperCli(res.audioPath, {
|
|
7552
|
+
whisperBin: cfg.whisperBin,
|
|
7553
|
+
model: cfg.model,
|
|
7554
|
+
language: cfg.language,
|
|
7555
|
+
translate: cfg.translate,
|
|
7556
|
+
});
|
|
7557
|
+
finalSpinner.stop();
|
|
7558
|
+
|
|
7559
|
+
if (!finalRes.ok) {
|
|
7560
|
+
if (res.audioTmpDir) { try { fs.rmSync(res.audioTmpDir, { recursive: true, force: true }); } catch {} }
|
|
7561
|
+
console.log(chalk.red(`❌ Final transcription failed: ${finalRes.error}`));
|
|
7562
|
+
continue;
|
|
7563
|
+
}
|
|
7564
|
+
|
|
7565
|
+
// Drop the [BLANK_AUDIO] marker whisper sometimes emits at the start
|
|
7566
|
+
let transcript = (finalRes.text || '')
|
|
7567
|
+
.replace(/\[BLANK_AUDIO\]/gi, '')
|
|
7568
|
+
.replace(/\s+/g, ' ')
|
|
7569
|
+
.trim();
|
|
7570
|
+
|
|
7571
|
+
if (!transcript) {
|
|
7572
|
+
if (res.audioTmpDir) { try { fs.rmSync(res.audioTmpDir, { recursive: true, force: true }); } catch {} }
|
|
7573
|
+
console.log(chalk.yellow('No speech detected.'));
|
|
7574
|
+
continue;
|
|
7575
|
+
}
|
|
7576
|
+
|
|
7577
|
+
// Archive (audio + clean transcript)
|
|
7578
|
+
if (cfg.archive) {
|
|
7579
|
+
const archived = archiveVoiceRecording({
|
|
7580
|
+
sourceAudio: res.audioPath,
|
|
7581
|
+
transcript,
|
|
7582
|
+
mode: 'live',
|
|
7583
|
+
});
|
|
7584
|
+
if (archived && archived.ok) {
|
|
7585
|
+
const where = archived.audio || archived.transcript;
|
|
7586
|
+
if (where) console.log(UI.slate(` Archived → ${where.replace(getToolWorkingDirectory() + '/', '')}`));
|
|
7587
|
+
}
|
|
7588
|
+
}
|
|
7589
|
+
// Cleanup the temp sa dir whether or not archive succeeded
|
|
7590
|
+
if (res.audioTmpDir) { try { fs.rmSync(res.audioTmpDir, { recursive: true, force: true }); } catch {} }
|
|
7591
|
+
|
|
7592
|
+
console.log();
|
|
7593
|
+
if (!transcript || !transcript.trim()) {
|
|
7594
|
+
console.log(box(
|
|
7595
|
+
chalk.gray('(empty — no speech detected or only hallucinations were filtered)\n') +
|
|
7596
|
+
(cfg.language === 'auto' || !cfg.language
|
|
7597
|
+
? chalk.yellow(' 💡 Try locking the language: ') + chalk.white('/v lang ar') + chalk.yellow(' or ') + chalk.white('/v lang en')
|
|
7598
|
+
: chalk.gray(` language was: ${cfg.language}`)),
|
|
7599
|
+
'🎙️ Final transcript', 'yellow'));
|
|
7600
|
+
continue;
|
|
7601
|
+
}
|
|
7602
|
+
console.log(box(chalk.white(transcript), '🎙️ Final transcript', 'cyan'));
|
|
7603
|
+
if (cfg.autoSend) {
|
|
7604
|
+
input = transcript;
|
|
7605
|
+
} else {
|
|
7606
|
+
const confirm = await safeQuestion(chalk.yellow('Send to AI? [Y/n/e=edit]: '));
|
|
7607
|
+
const ans = confirm.trim().toLowerCase();
|
|
7608
|
+
if (ans === 'n' || ans === 'no') { continue; }
|
|
7609
|
+
if (ans === 'e' || ans === 'edit') {
|
|
7610
|
+
const edited = await safeQuestion(chalk.cyan('Edit transcript: '));
|
|
7611
|
+
if (!edited.trim()) continue;
|
|
7612
|
+
input = edited.trim();
|
|
7613
|
+
} else {
|
|
7614
|
+
input = transcript;
|
|
7615
|
+
}
|
|
7616
|
+
}
|
|
7617
|
+
// Fall through to AI processing
|
|
7618
|
+
} else if (subcommand === 'record' || subcommand === 'rec' || subcommand === 'talk') {
|
|
7619
|
+
if (!cfg.enabled) {
|
|
7620
|
+
console.log(chalk.yellow('Voice input is OFF. Enable with /voice on first.'));
|
|
7621
|
+
continue;
|
|
7622
|
+
}
|
|
7623
|
+
// /v record → push-to-stop (press any key to stop)
|
|
7624
|
+
// /v record <N> → fixed N seconds
|
|
7625
|
+
// /v talk → also push-to-stop (alias)
|
|
7626
|
+
const secs = value ? parseInt(value, 10) : null;
|
|
7627
|
+
const usePushToStop = (subcommand === 'talk') || !Number.isFinite(secs);
|
|
7628
|
+
const t = await transcribeAudio({
|
|
7629
|
+
seconds: usePushToStop ? null : secs,
|
|
7630
|
+
pushToStop: usePushToStop,
|
|
7631
|
+
mode: subcommand === 'talk' ? 'talk' : 'record',
|
|
7632
|
+
});
|
|
7633
|
+
if (!t.ok) { console.log(chalk.red(`❌ ${t.error}`)); continue; }
|
|
7634
|
+
const transcript = (t.text || '').trim();
|
|
7635
|
+
if (!transcript) { console.log(chalk.yellow('No speech detected.')); continue; }
|
|
7636
|
+
console.log();
|
|
7637
|
+
console.log(box(chalk.white(transcript), '🎙️ Transcript', 'cyan'));
|
|
7638
|
+
if (cfg.autoSend) {
|
|
7639
|
+
input = transcript; // fall through to AI processing
|
|
7640
|
+
} else {
|
|
7641
|
+
const confirm = await safeQuestion(chalk.yellow('Send to AI? [Y/n/e=edit]: '));
|
|
7642
|
+
const ans = confirm.trim().toLowerCase();
|
|
7643
|
+
if (ans === 'n' || ans === 'no') { continue; }
|
|
7644
|
+
if (ans === 'e' || ans === 'edit') {
|
|
7645
|
+
const edited = await safeQuestion(chalk.cyan('Edit transcript: '));
|
|
7646
|
+
if (!edited.trim()) continue;
|
|
7647
|
+
input = edited.trim();
|
|
7648
|
+
} else {
|
|
7649
|
+
input = transcript;
|
|
7650
|
+
}
|
|
7651
|
+
}
|
|
7652
|
+
// Fall through — the loop body below will process `input` as a normal user prompt
|
|
7653
|
+
} else if (subcommand === 'file') {
|
|
7654
|
+
if (!cfg.enabled) {
|
|
7655
|
+
console.log(chalk.yellow('Voice input is OFF. Enable with /voice on first.'));
|
|
7656
|
+
continue;
|
|
7657
|
+
}
|
|
7658
|
+
if (!value) { console.log(chalk.yellow('Usage: /voice file <path-to-audio>')); continue; }
|
|
7659
|
+
const resolved = isAbsolute(value) ? value : pathResolve(getToolWorkingDirectory(), value);
|
|
7660
|
+
if (!fs.existsSync(resolved)) { console.log(chalk.red(`❌ File not found: ${resolved}`)); continue; }
|
|
7661
|
+
const t = await transcribeAudio({ audioFile: resolved, mode: 'file' });
|
|
7662
|
+
if (!t.ok) { console.log(chalk.red(`❌ ${t.error}`)); continue; }
|
|
7663
|
+
const transcript = (t.text || '').trim();
|
|
7664
|
+
if (!transcript) { console.log(chalk.yellow('No speech detected in file.')); continue; }
|
|
7665
|
+
console.log();
|
|
7666
|
+
console.log(box(chalk.white(transcript), `🎙️ Transcript (${value})`, 'cyan'));
|
|
7667
|
+
if (cfg.autoSend) {
|
|
7668
|
+
input = transcript;
|
|
7669
|
+
} else {
|
|
7670
|
+
const confirm = await safeQuestion(chalk.yellow('Send to AI? [Y/n/e=edit]: '));
|
|
7671
|
+
const ans = confirm.trim().toLowerCase();
|
|
7672
|
+
if (ans === 'n' || ans === 'no') { continue; }
|
|
7673
|
+
if (ans === 'e' || ans === 'edit') {
|
|
7674
|
+
const edited = await safeQuestion(chalk.cyan('Edit transcript: '));
|
|
7675
|
+
if (!edited.trim()) continue;
|
|
7676
|
+
input = edited.trim();
|
|
7677
|
+
} else {
|
|
7678
|
+
input = transcript;
|
|
7679
|
+
}
|
|
7680
|
+
}
|
|
7681
|
+
} else {
|
|
7682
|
+
console.log(chalk.yellow(`Unknown /voice option: ${subcommand}`));
|
|
7683
|
+
console.log(chalk.gray(' Run /voice with no args to see usage.'));
|
|
7684
|
+
continue;
|
|
7685
|
+
}
|
|
7686
|
+
// No `continue` here — `input` was set to the transcript, let the rest of the loop process it.
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
|
|
7690
|
+
if (input.toLowerCase().startsWith('/ui')) {
|
|
7691
|
+
const arg = input.substring(3).trim();
|
|
7692
|
+
const currentUI = getUIConfig();
|
|
7693
|
+
|
|
7694
|
+
if (!arg || ['status', 'show'].includes(arg.toLowerCase())) {
|
|
7695
|
+
const lines = [
|
|
7696
|
+
`style ${chalk.white(currentUI.style)}`,
|
|
7697
|
+
`compact ${chalk.white(currentUI.compactMode)}`,
|
|
7698
|
+
`render mode ${chalk.white(uiStyle())}`,
|
|
7699
|
+
];
|
|
7700
|
+
console.log();
|
|
7701
|
+
console.log(box(lines.join('\n'), 'UI Settings', 'cyan'));
|
|
7702
|
+
console.log(UI.slate(' Usage: /ui style [sapper|clean|ultra] | /ui compact [auto|on|off] | /ui reset'));
|
|
7703
|
+
continue;
|
|
7704
|
+
}
|
|
7705
|
+
|
|
7706
|
+
const [subcommandRaw, ...rest] = arg.split(/\s+/);
|
|
7707
|
+
const subcommand = subcommandRaw.toLowerCase();
|
|
7708
|
+
const value = rest.join(' ').trim();
|
|
7709
|
+
|
|
7710
|
+
if (subcommand === 'reset') {
|
|
7711
|
+
saveConfig({
|
|
7712
|
+
...sapperConfig,
|
|
7713
|
+
ui: { ...DEFAULT_CONFIG.ui },
|
|
7714
|
+
});
|
|
7715
|
+
console.log(chalk.green('✅ UI settings reset to defaults (style=sapper, compact=auto).'));
|
|
7716
|
+
continue;
|
|
7717
|
+
}
|
|
7718
|
+
|
|
7719
|
+
if (subcommand === 'style') {
|
|
5460
7720
|
if (!value) {
|
|
5461
|
-
console.log(chalk.yellow('Usage: /
|
|
5462
|
-
console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
|
|
7721
|
+
console.log(chalk.yellow('Usage: /ui style [sapper|clean|ultra]'));
|
|
5463
7722
|
continue;
|
|
5464
7723
|
}
|
|
5465
7724
|
|
|
5466
|
-
const
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
7725
|
+
const nextStyle = normalizeUIStyle(value);
|
|
7726
|
+
saveConfig({
|
|
7727
|
+
...sapperConfig,
|
|
7728
|
+
ui: {
|
|
7729
|
+
...currentUI,
|
|
7730
|
+
style: nextStyle,
|
|
7731
|
+
},
|
|
7732
|
+
});
|
|
7733
|
+
console.log(chalk.green(`✅ UI style set to ${chalk.white(nextStyle)}.`));
|
|
7734
|
+
console.log(chalk.gray(' Restart Sapper to refresh startup screens. Prompt style updates immediately.'));
|
|
7735
|
+
continue;
|
|
7736
|
+
}
|
|
7737
|
+
|
|
7738
|
+
if (subcommand === 'compact') {
|
|
7739
|
+
if (!value) {
|
|
7740
|
+
console.log(chalk.yellow('Usage: /ui compact [auto|on|off]'));
|
|
5470
7741
|
continue;
|
|
5471
7742
|
}
|
|
5472
7743
|
|
|
5473
|
-
|
|
5474
|
-
saveConfig(
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
7744
|
+
const nextCompact = normalizeUICompactMode(value);
|
|
7745
|
+
saveConfig({
|
|
7746
|
+
...sapperConfig,
|
|
7747
|
+
ui: {
|
|
7748
|
+
...currentUI,
|
|
7749
|
+
compactMode: nextCompact,
|
|
7750
|
+
},
|
|
7751
|
+
});
|
|
7752
|
+
console.log(chalk.green(`✅ UI compact mode set to ${chalk.white(nextCompact)}.`));
|
|
5479
7753
|
continue;
|
|
5480
7754
|
}
|
|
5481
7755
|
|
|
5482
|
-
console.log(chalk.yellow(
|
|
5483
|
-
console.log(chalk.gray(' Usage: /summary | /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
|
|
7756
|
+
console.log(chalk.yellow('Usage: /ui style [sapper|clean|ultra] | /ui compact [auto|on|off] | /ui reset'));
|
|
5484
7757
|
continue;
|
|
5485
7758
|
}
|
|
5486
7759
|
|
|
@@ -5790,7 +8063,7 @@ async function runSapper() {
|
|
|
5790
8063
|
'🤖 New Agent', 'cyan'
|
|
5791
8064
|
));
|
|
5792
8065
|
|
|
5793
|
-
const agentName = await safeQuestion(
|
|
8066
|
+
const agentName = await safeQuestion(promptQuestion('questions.agentName', '\nAgent name (lowercase, no spaces): '));
|
|
5794
8067
|
if (!agentName.trim() || !/^[a-z0-9_-]+$/.test(agentName.trim())) {
|
|
5795
8068
|
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
5796
8069
|
continue;
|
|
@@ -5802,10 +8075,10 @@ async function runSapper() {
|
|
|
5802
8075
|
continue;
|
|
5803
8076
|
}
|
|
5804
8077
|
|
|
5805
|
-
const agentTitle = await safeQuestion(
|
|
5806
|
-
const agentExpertise = await safeQuestion(
|
|
5807
|
-
const agentStyle = await safeQuestion(
|
|
5808
|
-
const agentToolsInput = await safeQuestion(
|
|
8078
|
+
const agentTitle = await safeQuestion(promptQuestion('questions.agentTitle', 'Agent title/role: '));
|
|
8079
|
+
const agentExpertise = await safeQuestion(promptQuestion('questions.agentExpertise', 'Areas of expertise (comma-separated): '));
|
|
8080
|
+
const agentStyle = await safeQuestion(promptQuestion('questions.agentStyle', 'Communication style (e.g., professional, casual, technical): '));
|
|
8081
|
+
const agentToolsInput = await safeQuestion(promptQuestion('questions.agentTools', 'Allowed tools (comma-sep, or Enter for all): read,edit,write,list,ls,search,grep,find,shell,mkdir,rmdir,pwd,cd,cat,head,tail,changes,fetch,memory,open: '));
|
|
5809
8082
|
|
|
5810
8083
|
const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
|
|
5811
8084
|
const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
|
|
@@ -5828,7 +8101,7 @@ async function runSapper() {
|
|
|
5828
8101
|
'📘 New Skill', 'cyan'
|
|
5829
8102
|
));
|
|
5830
8103
|
|
|
5831
|
-
const skillName = await safeQuestion(
|
|
8104
|
+
const skillName = await safeQuestion(promptQuestion('questions.skillName', '\nSkill name (lowercase, no spaces): '));
|
|
5832
8105
|
if (!skillName.trim() || !/^[a-z0-9_-]+$/.test(skillName.trim())) {
|
|
5833
8106
|
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
5834
8107
|
continue;
|
|
@@ -5840,10 +8113,10 @@ async function runSapper() {
|
|
|
5840
8113
|
continue;
|
|
5841
8114
|
}
|
|
5842
8115
|
|
|
5843
|
-
const skillTitle = await safeQuestion(
|
|
5844
|
-
const skillDesc = await safeQuestion(
|
|
5845
|
-
const skillArgHint = await safeQuestion(
|
|
5846
|
-
const skillBody = await safeQuestion(
|
|
8116
|
+
const skillTitle = await safeQuestion(promptQuestion('questions.skillTitle', 'Skill title: '));
|
|
8117
|
+
const skillDesc = await safeQuestion(promptQuestion('questions.skillDescription', 'Brief description (for /skills listing): '));
|
|
8118
|
+
const skillArgHint = await safeQuestion(promptQuestion('questions.skillArgumentHint', 'Argument hint (optional, e.g. "Describe what to do"): '));
|
|
8119
|
+
const skillBody = await safeQuestion(promptQuestion('questions.skillKnowledge', 'Skill knowledge (or Enter for template): '));
|
|
5847
8120
|
|
|
5848
8121
|
const descLine = skillDesc.trim() || skillTitle.trim() || skillName;
|
|
5849
8122
|
const argHintLine = skillArgHint.trim() ? `\nargument-hint: "${skillArgHint.trim()}"` : '';
|
|
@@ -5916,7 +8189,7 @@ async function runSapper() {
|
|
|
5916
8189
|
continue;
|
|
5917
8190
|
}
|
|
5918
8191
|
try {
|
|
5919
|
-
const fetchSpinner = ora({ text: chalk.cyan(
|
|
8192
|
+
const fetchSpinner = ora({ text: chalk.cyan(`${uiCleanMode() ? 'Fetching' : '🌐 Fetching'} ${url}...`), spinner: 'dots' }).start();
|
|
5920
8193
|
const rawContent = await fetchUrl(url);
|
|
5921
8194
|
fetchSpinner.stop();
|
|
5922
8195
|
|
|
@@ -5932,12 +8205,17 @@ async function runSapper() {
|
|
|
5932
8205
|
}
|
|
5933
8206
|
|
|
5934
8207
|
if (text.trim().length > 0) {
|
|
5935
|
-
const
|
|
8208
|
+
const webTitle = uiCleanMode() ? 'WEB PAGE CONTENT' : '🌐 WEB PAGE CONTENT';
|
|
8209
|
+
const webContent = `\n\n══════════════════════════════════════\n${webTitle}\n══════════════════════════════════════\n\nURL: ${url}\n\n${text}\n`;
|
|
5936
8210
|
messages.push({ role: 'user', content: `I fetched this web page for reference:\n${webContent}\n\nUse this information to help me.` });
|
|
5937
8211
|
ensureSapperDir();
|
|
5938
8212
|
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
5939
|
-
console.log(
|
|
5940
|
-
|
|
8213
|
+
console.log(uiCleanMode()
|
|
8214
|
+
? chalk.green(`Fetched: ${url} (${Math.round(text.length/1024)}KB)`)
|
|
8215
|
+
: chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
|
|
8216
|
+
console.log(uiCleanMode()
|
|
8217
|
+
? chalk.gray('Added to context. AI can now reference this page.\n')
|
|
8218
|
+
: chalk.gray('📝 Added to context. AI can now reference this page.\n'));
|
|
5941
8219
|
} else {
|
|
5942
8220
|
console.log(chalk.yellow('⚠️ No readable content found on that page.'));
|
|
5943
8221
|
}
|
|
@@ -5961,7 +8239,9 @@ async function runSapper() {
|
|
|
5961
8239
|
continue;
|
|
5962
8240
|
}
|
|
5963
8241
|
|
|
5964
|
-
console.log(
|
|
8242
|
+
console.log(uiCleanMode()
|
|
8243
|
+
? chalk.cyan(`\nSearching memory for: "${query}"...`)
|
|
8244
|
+
: chalk.cyan(`\n🔍 Searching memory for: "${query}"...`));
|
|
5965
8245
|
const relevant = await findRelevantContext(query, embeddings, 3);
|
|
5966
8246
|
|
|
5967
8247
|
if (relevant.length === 0) {
|
|
@@ -5979,7 +8259,7 @@ async function runSapper() {
|
|
|
5979
8259
|
});
|
|
5980
8260
|
|
|
5981
8261
|
// Optionally add to context
|
|
5982
|
-
const addToContext = await safeQuestion(chalk.yellow('Add to current context?
|
|
8262
|
+
const addToContext = await safeQuestion(chalk.yellow(promptLabel('questions.addMemoryToCurrentContext', 'Add to current context? (y/n): ')));
|
|
5983
8263
|
if (addToContext.toLowerCase() === 'y') {
|
|
5984
8264
|
const contextAddition = relevant.map(c => c.text).join('\n---\n');
|
|
5985
8265
|
messages.push({
|
|
@@ -5991,10 +8271,99 @@ async function runSapper() {
|
|
|
5991
8271
|
}
|
|
5992
8272
|
continue;
|
|
5993
8273
|
}
|
|
8274
|
+
|
|
8275
|
+
if (input.toLowerCase().startsWith('/memory')) {
|
|
8276
|
+
const rawArgs = input.slice('/memory'.length).trim();
|
|
8277
|
+
|
|
8278
|
+
if (!rawArgs) {
|
|
8279
|
+
const latestNotes = listLongMemoryNotes(6);
|
|
8280
|
+
console.log(chalk.cyan('\nMarkdown Long Memory'));
|
|
8281
|
+
console.log(chalk.gray(`File: ${LONG_MEMORY_FILE}`));
|
|
8282
|
+
if (latestNotes.length) {
|
|
8283
|
+
console.log(chalk.green(`Recent notes (${latestNotes.length}):`));
|
|
8284
|
+
for (const note of latestNotes) {
|
|
8285
|
+
console.log(` - ${note}`);
|
|
8286
|
+
}
|
|
8287
|
+
} else {
|
|
8288
|
+
console.log(chalk.gray('No notes yet. Save one with /memory add title ::: note'));
|
|
8289
|
+
}
|
|
8290
|
+
console.log(chalk.gray('Commands: /memory add title ::: note ::: tags | /memory save note | /memory search query | /memory show'));
|
|
8291
|
+
continue;
|
|
8292
|
+
}
|
|
8293
|
+
|
|
8294
|
+
const lowerArgs = rawArgs.toLowerCase();
|
|
8295
|
+
if (lowerArgs === 'show' || lowerArgs === 'read') {
|
|
8296
|
+
const text = loadLongMemoryText();
|
|
8297
|
+
console.log();
|
|
8298
|
+
console.log(box(truncateToolText(text, 16000), 'Long Memory (.md)', 'magenta'));
|
|
8299
|
+
console.log();
|
|
8300
|
+
continue;
|
|
8301
|
+
}
|
|
8302
|
+
|
|
8303
|
+
if (lowerArgs.startsWith('search ')) {
|
|
8304
|
+
const query = rawArgs.slice(7).trim();
|
|
8305
|
+
if (!query) {
|
|
8306
|
+
console.log(chalk.yellow('Usage: /memory search <query>'));
|
|
8307
|
+
continue;
|
|
8308
|
+
}
|
|
8309
|
+
const matches = searchLongMemoryNotes(query, 5);
|
|
8310
|
+
if (!matches.length) {
|
|
8311
|
+
console.log(chalk.yellow(`No markdown notes found for: ${query}`));
|
|
8312
|
+
continue;
|
|
8313
|
+
}
|
|
8314
|
+
|
|
8315
|
+
console.log(chalk.green(`Found ${matches.length} note match${matches.length === 1 ? '' : 'es'}:\n`));
|
|
8316
|
+
matches.forEach((match, index) => {
|
|
8317
|
+
console.log(box(truncateToolText(match, 1200), `Note ${index + 1}`, 'magenta'));
|
|
8318
|
+
console.log();
|
|
8319
|
+
});
|
|
8320
|
+
continue;
|
|
8321
|
+
}
|
|
8322
|
+
|
|
8323
|
+
if (lowerArgs.startsWith('save ')) {
|
|
8324
|
+
const note = rawArgs.slice(5).trim();
|
|
8325
|
+
if (!note) {
|
|
8326
|
+
console.log(chalk.yellow('Usage: /memory save <note>'));
|
|
8327
|
+
continue;
|
|
8328
|
+
}
|
|
8329
|
+
const saved = appendLongMemoryNote({ content: note, source: 'manual-save' });
|
|
8330
|
+
if (!saved.ok) {
|
|
8331
|
+
console.log(chalk.red(`Failed to save note: ${saved.error}`));
|
|
8332
|
+
continue;
|
|
8333
|
+
}
|
|
8334
|
+
console.log(chalk.green(`Saved note: ${saved.title}`));
|
|
8335
|
+
continue;
|
|
8336
|
+
}
|
|
8337
|
+
|
|
8338
|
+
if (lowerArgs.startsWith('add ')) {
|
|
8339
|
+
const payload = rawArgs.slice(4).trim();
|
|
8340
|
+
const parts = payload.split(':::').map(part => part.trim());
|
|
8341
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) {
|
|
8342
|
+
console.log(chalk.yellow('Usage: /memory add <title> ::: <note> ::: <optional tags>'));
|
|
8343
|
+
continue;
|
|
8344
|
+
}
|
|
8345
|
+
const saved = appendLongMemoryNote({
|
|
8346
|
+
title: parts[0],
|
|
8347
|
+
content: parts[1],
|
|
8348
|
+
tags: parts[2] || '',
|
|
8349
|
+
source: 'manual-add',
|
|
8350
|
+
});
|
|
8351
|
+
if (!saved.ok) {
|
|
8352
|
+
console.log(chalk.red(`Failed to save note: ${saved.error}`));
|
|
8353
|
+
continue;
|
|
8354
|
+
}
|
|
8355
|
+
const tagPart = saved.tags.length ? ` [${saved.tags.join(', ')}]` : '';
|
|
8356
|
+
console.log(chalk.green(`Saved note: ${saved.title}${tagPart}`));
|
|
8357
|
+
continue;
|
|
8358
|
+
}
|
|
8359
|
+
|
|
8360
|
+
console.log(chalk.yellow('Usage: /memory | /memory show | /memory search <query> | /memory save <note> | /memory add <title> ::: <note> ::: <optional tags>'));
|
|
8361
|
+
continue;
|
|
8362
|
+
}
|
|
5994
8363
|
|
|
5995
8364
|
// Handle codebase scan command
|
|
5996
8365
|
if (input.toLowerCase() === '/scan') {
|
|
5997
|
-
console.log(chalk.cyan('\n🔍 Scanning codebase...'));
|
|
8366
|
+
console.log(uiCleanMode() ? chalk.cyan('\nScanning codebase...') : chalk.cyan('\n🔍 Scanning codebase...'));
|
|
5998
8367
|
const scanResult = scanCodebase('.');
|
|
5999
8368
|
|
|
6000
8369
|
if (scanResult.files.length === 0) {
|
|
@@ -6006,9 +8375,13 @@ async function runSapper() {
|
|
|
6006
8375
|
const includedCount = scanResult.files.filter(f => !f.skipped).length;
|
|
6007
8376
|
const skippedCount = scanResult.files.filter(f => f.skipped).length;
|
|
6008
8377
|
|
|
6009
|
-
console.log(
|
|
8378
|
+
console.log(uiCleanMode()
|
|
8379
|
+
? chalk.green(`Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`)
|
|
8380
|
+
: chalk.green(`✅ Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`));
|
|
6010
8381
|
if (skippedCount > 0) {
|
|
6011
|
-
console.log(
|
|
8382
|
+
console.log(uiCleanMode()
|
|
8383
|
+
? chalk.yellow(`Skipped ${skippedCount} files (too large or limit reached)`)
|
|
8384
|
+
: chalk.yellow(`⏭️ Skipped ${skippedCount} files (too large or limit reached)`));
|
|
6012
8385
|
}
|
|
6013
8386
|
|
|
6014
8387
|
// Add scan to context
|
|
@@ -6019,7 +8392,32 @@ async function runSapper() {
|
|
|
6019
8392
|
|
|
6020
8393
|
ensureSapperDir();
|
|
6021
8394
|
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
6022
|
-
console.log(
|
|
8395
|
+
console.log(uiCleanMode()
|
|
8396
|
+
? chalk.gray('Codebase added to context. AI now has full picture.\n')
|
|
8397
|
+
: chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
|
|
8398
|
+
continue;
|
|
8399
|
+
}
|
|
8400
|
+
|
|
8401
|
+
if (input.startsWith('/') && !input.startsWith('//') && !agentHandled) {
|
|
8402
|
+
const commandToken = input.slice(1).trim().split(/\s+/)[0] || '';
|
|
8403
|
+
const suggestions = suggestSlashCommands(commandToken, 5);
|
|
8404
|
+
if (uiCleanMode()) {
|
|
8405
|
+
const lines = [
|
|
8406
|
+
`${chalk.white(input)}`,
|
|
8407
|
+
suggestions.length > 0 ? UI.slate(`did you mean: ${suggestions.join(', ')}`) : UI.slate('no close command suggestions'),
|
|
8408
|
+
UI.slate('use /commands to view the full command palette'),
|
|
8409
|
+
UI.slate('for literal text starting with /, prefix with //'),
|
|
8410
|
+
];
|
|
8411
|
+
console.log();
|
|
8412
|
+
console.log(box(lines.join('\n'), 'Unknown Command', 'yellow'));
|
|
8413
|
+
} else {
|
|
8414
|
+
console.log(chalk.yellow(`Unknown command: ${input}`));
|
|
8415
|
+
if (suggestions.length > 0) {
|
|
8416
|
+
console.log(UI.slate(`Did you mean: ${suggestions.join(', ')}`));
|
|
8417
|
+
}
|
|
8418
|
+
console.log(UI.slate('Use /commands to view the full command palette.'));
|
|
8419
|
+
console.log(UI.slate('If you meant literal text that starts with /, prefix it with //'));
|
|
8420
|
+
}
|
|
6023
8421
|
continue;
|
|
6024
8422
|
}
|
|
6025
8423
|
|
|
@@ -6062,7 +8460,7 @@ async function runSapper() {
|
|
|
6062
8460
|
|
|
6063
8461
|
// Ask for the prompt to go with these files
|
|
6064
8462
|
console.log();
|
|
6065
|
-
const prompt = await safeQuestion(
|
|
8463
|
+
const prompt = await safeQuestion(promptQuestion('questions.promptForFiles', 'Your prompt for these files: '));
|
|
6066
8464
|
|
|
6067
8465
|
if (!prompt.trim()) {
|
|
6068
8466
|
console.log(chalk.gray('Cancelled.'));
|
|
@@ -6076,7 +8474,7 @@ async function runSapper() {
|
|
|
6076
8474
|
// Continue to AI response (don't use 'continue' here)
|
|
6077
8475
|
} else {
|
|
6078
8476
|
// Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
|
|
6079
|
-
let processedInput = input;
|
|
8477
|
+
let processedInput = input.startsWith('//') ? input.slice(1) : input;
|
|
6080
8478
|
const fileAttachments = [];
|
|
6081
8479
|
const attachRegex = /@([\w.\/\-_]+)/g;
|
|
6082
8480
|
let attachMatch;
|
|
@@ -6227,7 +8625,7 @@ async function runSapper() {
|
|
|
6227
8625
|
|
|
6228
8626
|
let active = true;
|
|
6229
8627
|
while (active) {
|
|
6230
|
-
if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
|
|
8628
|
+
if (stepMode) await safeQuestion(chalk.gray(promptLabel('questions.stepContinue', '[STEP] Press Enter to let AI think...')));
|
|
6231
8629
|
|
|
6232
8630
|
spinner.start('Thinking...');
|
|
6233
8631
|
const aiStartTime = Date.now();
|
|
@@ -6576,6 +8974,14 @@ async function runSapper() {
|
|
|
6576
8974
|
result = tools.find(args.pattern, args.path ?? '.');
|
|
6577
8975
|
logEntry('tool', { toolType: 'FIND', path: args.pattern, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6578
8976
|
break;
|
|
8977
|
+
case 'regex_search':
|
|
8978
|
+
result = tools.regex(args.pattern, args.include ?? '', args.path ?? '.');
|
|
8979
|
+
logEntry('tool', { toolType: 'REGEX', path: args.pattern, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
8980
|
+
break;
|
|
8981
|
+
case 'read_chunk':
|
|
8982
|
+
result = tools.read_chunk(args.path, args.start, args.end, args.context);
|
|
8983
|
+
logEntry('file', { action: 'read', path: `${args.path}#L${args.start}-${args.end ?? ''}`, size: result?.length || 0 });
|
|
8984
|
+
break;
|
|
6579
8985
|
case 'pwd':
|
|
6580
8986
|
result = tools.pwd();
|
|
6581
8987
|
break;
|
|
@@ -6598,6 +9004,18 @@ async function runSapper() {
|
|
|
6598
9004
|
result = await tools.recall_memory(args.query);
|
|
6599
9005
|
logEntry('tool', { toolType: 'MEMORY', path: args.query, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6600
9006
|
break;
|
|
9007
|
+
case 'save_memory_note':
|
|
9008
|
+
result = await tools.save_memory_note(args.title, args.content, args.tags);
|
|
9009
|
+
logEntry('tool', { toolType: 'MEMORY', path: args.title || 'memory-note', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9010
|
+
break;
|
|
9011
|
+
case 'search_memory_notes':
|
|
9012
|
+
result = await tools.search_memory_notes(args.query);
|
|
9013
|
+
logEntry('tool', { toolType: 'MEMORY', path: args.query, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9014
|
+
break;
|
|
9015
|
+
case 'read_memory_notes':
|
|
9016
|
+
result = await tools.read_memory_notes();
|
|
9017
|
+
logEntry('tool', { toolType: 'MEMORY', path: LONG_MEMORY_FILE, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9018
|
+
break;
|
|
6601
9019
|
case 'open_url':
|
|
6602
9020
|
result = await tools.open_url(args.url);
|
|
6603
9021
|
logEntry('tool', { toolType: 'OPEN', path: args.url, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
|
|
@@ -6803,6 +9221,41 @@ async function runSapper() {
|
|
|
6803
9221
|
result = tools.find(path, content);
|
|
6804
9222
|
logEntry('tool', { toolType: 'FIND', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6805
9223
|
}
|
|
9224
|
+
else if (type.toLowerCase() === 'regex') {
|
|
9225
|
+
// Content can be "include" filter (e.g. "js,ts") or "include:::startPath"
|
|
9226
|
+
let include = '';
|
|
9227
|
+
let startPath = '.';
|
|
9228
|
+
if (content) {
|
|
9229
|
+
const idx = content.indexOf(':::');
|
|
9230
|
+
if (idx > -1) {
|
|
9231
|
+
include = content.substring(0, idx).trim();
|
|
9232
|
+
startPath = content.substring(idx + 3).trim() || '.';
|
|
9233
|
+
} else {
|
|
9234
|
+
include = content.trim();
|
|
9235
|
+
}
|
|
9236
|
+
}
|
|
9237
|
+
result = tools.regex(path, include, startPath);
|
|
9238
|
+
logEntry('tool', { toolType: 'REGEX', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9239
|
+
}
|
|
9240
|
+
else if (type.toLowerCase() === 'chunk' || type.toLowerCase() === 'read_chunk') {
|
|
9241
|
+
// Content format: "start-end" or "start:end" or "start,end" (end optional). Extra ":::context" appends context lines.
|
|
9242
|
+
let start = 1, end = null, ctx = 0;
|
|
9243
|
+
if (content) {
|
|
9244
|
+
let main = content.trim();
|
|
9245
|
+
const ctxIdx = main.indexOf(':::');
|
|
9246
|
+
if (ctxIdx > -1) {
|
|
9247
|
+
ctx = Number(main.substring(ctxIdx + 3).trim()) || 0;
|
|
9248
|
+
main = main.substring(0, ctxIdx).trim();
|
|
9249
|
+
}
|
|
9250
|
+
const rangeMatch = main.match(/^(\d+)\s*[-:,]\s*(\d+)?$/) || main.match(/^(\d+)$/);
|
|
9251
|
+
if (rangeMatch) {
|
|
9252
|
+
start = parseInt(rangeMatch[1], 10);
|
|
9253
|
+
end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : null;
|
|
9254
|
+
}
|
|
9255
|
+
}
|
|
9256
|
+
result = tools.read_chunk(path, start, end, ctx);
|
|
9257
|
+
logEntry('file', { action: 'read', path: `${path}#L${start}-${end ?? ''}`, size: result?.length || 0 });
|
|
9258
|
+
}
|
|
6806
9259
|
else if (type.toLowerCase() === 'changes') {
|
|
6807
9260
|
result = await tools.changes(path);
|
|
6808
9261
|
logEntry('tool', { toolType: 'CHANGES', path: path || '.', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
@@ -6815,6 +9268,19 @@ async function runSapper() {
|
|
|
6815
9268
|
result = await tools.recall_memory(path);
|
|
6816
9269
|
logEntry('tool', { toolType: 'MEMORY', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6817
9270
|
}
|
|
9271
|
+
else if (type.toLowerCase() === 'memory_note_save') {
|
|
9272
|
+
const [noteContent = '', tagText = ''] = String(content ?? '').split(':::');
|
|
9273
|
+
result = await tools.save_memory_note(path, noteContent, tagText);
|
|
9274
|
+
logEntry('tool', { toolType: 'MEMORY', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9275
|
+
}
|
|
9276
|
+
else if (type.toLowerCase() === 'memory_note_search') {
|
|
9277
|
+
result = await tools.search_memory_notes(path);
|
|
9278
|
+
logEntry('tool', { toolType: 'MEMORY', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9279
|
+
}
|
|
9280
|
+
else if (type.toLowerCase() === 'memory_note_read') {
|
|
9281
|
+
result = await tools.read_memory_notes();
|
|
9282
|
+
logEntry('tool', { toolType: 'MEMORY', path: LONG_MEMORY_FILE, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
9283
|
+
}
|
|
6818
9284
|
else if (type.toLowerCase() === 'open') {
|
|
6819
9285
|
result = await tools.open_url(path);
|
|
6820
9286
|
logEntry('tool', { toolType: 'OPEN', path, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
|
|
@@ -6826,7 +9292,7 @@ async function runSapper() {
|
|
|
6826
9292
|
}
|
|
6827
9293
|
|
|
6828
9294
|
// Log tool execution (for non-shell, non-file specific ones)
|
|
6829
|
-
if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'changes', 'fetch', 'memory', 'open', 'shell'].includes(type.toLowerCase())) {
|
|
9295
|
+
if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'regex', 'chunk', 'read_chunk', 'changes', 'fetch', 'memory', 'memory_note_save', 'memory_note_search', 'memory_note_read', 'open', 'shell'].includes(type.toLowerCase())) {
|
|
6830
9296
|
logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
|
|
6831
9297
|
}
|
|
6832
9298
|
|