sapper-iq 1.1.39 → 1.2.0

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.
Files changed (4) hide show
  1. package/README.md +255 -149
  2. package/package.json +7 -3
  3. package/sapper-ui.mjs +1577 -1863
  4. 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:** \`${process.cwd()}\`\n\n`;
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() || `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
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: join(os.homedir(), 'models', 'ggml-medium.bin'), // Path to a ggml-*.bin (whisper.cpp format) — use /v model to pick
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: ${dateStr}, ${timeStr}
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
- let prompt = promptPrepend
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
- } else {
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
- if (agentContent) {
1078
- prompt += `\n\n═══ ACTIVE AGENT ROLE ═══\n${agentContent}\n═══ END AGENT ROLE ═══\n\nIMPORTANT: You are now operating as the agent described above. Adopt its persona, expertise, and communication style while still having access to Sapper tools.`;
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 (skillContents.length > 0) {
1090
- prompt += `\n\n═══ LOADED SKILLS ═══`;
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
- if (promptAppend) {
1098
- prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
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
- return {
1254
- ...DEFAULT_CONFIG.prompt,
1255
- append: normalizePromptText(promptConfig),
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
- return {
1264
- prepend: normalizePromptText(promptConfig.prepend),
1265
- append: normalizePromptText(promptConfig.append),
1266
- coreOverride: normalizePromptText(coreOverride),
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 rawConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
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, JSON.stringify(normalizedConfig, null, 2));
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, JSON.stringify(defaultConfig, null, 2));
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, JSON.stringify(normalizedConfig, null, 2));
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) exports.add(m[1]);
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 firstLevel = Array.from(related);
1767
- for (const f of firstLevel) {
1768
- const secondImports = workspace.graph[f] || [];
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
- const BANNER = [
2439
- `${chalk.hex('#c8ecff').bold('Sapper')} ${UI.slate('terminal workspace')}`,
2440
- UI.slate('Local models, live tools, and focused coding in one loop')
2441
- ].join('\n');
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
- return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
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(`Stop background shell session ${session.id}`, 'error', '[y/N] '));
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 = readline.createInterface({
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 = readline.createInterface({
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 pageSize = Math.max(5, Math.min(8, (process.stdout.rows || 24) - 14));
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
- BANNER,
3682
- `${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`,
3683
- divider(),
3684
- sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'),
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
- lines.push(box(
3721
- `${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
3722
- `${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
3723
- `${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
3724
- `${keyValue('Profile', UI.ink(family), 10)}\n` +
3725
- `${keyValue('Quant', UI.ink(quant), 10)}`,
3726
- 'Preview', 'gray'
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 = process.cwd();
4518
+ let toolWorkingDirectory = PROJECT_ROOT;
3778
4519
 
3779
4520
  function getToolWorkingDirectory() {
3780
- return toolWorkingDirectory || process.cwd();
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 = process.cwd();
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
- function truncateToolText(textValue = '', maxChars = 24000) {
3903
- const text = String(textValue ?? '').trim();
3904
- if (!text) return '';
3905
- if (text.length <= maxChars) return text;
3906
- return `${text.slice(0, maxChars)}\n... (output truncated at ${maxChars.toLocaleString()} chars)`;
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
- function shellQuote(value = '') {
3910
- return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
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
- function runCapturedCommand(command, { cwd = getToolWorkingDirectory(), timeoutMs = 12000, maxOutput = 24000 } = {}) {
3914
- return new Promise((resolve) => {
3915
- const proc = spawn('sh', ['-c', command], { cwd });
3916
- let stdout = '';
3917
- let stderr = '';
3918
- let stdoutTruncated = false;
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
- const appendLimited = (existing, chunkText, maxChars, setTruncated) => {
3923
- if (existing.length >= maxChars) {
3924
- setTruncated(true);
3925
- return existing;
3926
- }
3927
- const remaining = maxChars - existing.length;
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
- const finish = (result) => {
3936
- if (finished) return;
3937
- finished = true;
3938
- if (timer) clearTimeout(timer);
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
- const timer = timeoutMs > 0
3949
- ? setTimeout(() => {
3950
- try { proc.kill('SIGTERM'); } catch (e) {}
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
- proc.stdout.on('data', (data) => {
3956
- stdout = appendLimited(stdout, data.toString(), maxOutput, (value) => { stdoutTruncated = value; });
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
- proc.on('error', (error) => {
3963
- finish({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`.trim(), timedOut: false });
3964
- });
3965
- proc.on('close', (code) => {
3966
- finish({ exitCode: code ?? 0, stdout, stderr, timedOut: false });
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 normalizeFetchedWebContent(rawContent = '') {
3972
- const trimmed = String(rawContent ?? '').trim();
3973
- if (!trimmed) return '';
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
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
3976
- try {
3977
- return JSON.stringify(JSON.parse(trimmed), null, 2);
3978
- } catch (error) {
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
- if (trimmed.startsWith('<') || trimmed.toLowerCase().includes('<html')) {
3984
- return htmlToText(trimmed);
3985
- }
4764
+ const includeList = String(includeValue ?? '')
4765
+ .split(',')
4766
+ .map(s => s.trim())
4767
+ .filter(Boolean);
3986
4768
 
3987
- return trimmed;
3988
- }
4769
+ const useChunks = chunkingEnabled();
4770
+ const ctxLines = chunkingContextLines();
4771
+ const maxChunksFile = chunkingMaxPerFile();
3989
4772
 
3990
- function keywordRecallMemory(query, embeddings, topK = 3) {
3991
- const queryText = String(query ?? '').trim().toLowerCase();
3992
- if (!queryText || !embeddings?.chunks?.length) return [];
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 queryWords = Array.from(new Set(
3995
- queryText.split(/[^a-z0-9_]+/i).map(word => word.trim()).filter(word => word.length >= 2)
3996
- ));
3997
- const maxScore = Math.max(1, 4 + queryWords.length);
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
- return embeddings.chunks
4000
- .map((chunk) => {
4001
- const text = String(chunk?.text ?? '');
4002
- const lowered = text.toLowerCase();
4003
- let score = 0;
4004
- if (lowered.includes(queryText)) score += 4;
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 tools = {
4016
- read: (path) => {
4017
- const trimmedPath = typeof path === 'string' ? path.trim() : '';
4018
- if (!trimmedPath) return 'Error reading file: missing file path';
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 this command: ',
4453
- editPrompt: 'Edit instruction for this command: ',
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
- console.log(BANNER);
4551
- console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
4552
- console.log(divider());
4553
- console.log(sectionTitle('Quick start', '@file attach · /help commands · /agents modes', 'gray'));
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
- `${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
4606
- `${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
4607
- `${statusBadge('prompt', hasCustomPromptConfig() ? 'warning' : 'neutral')} ${UI.slate(hasCustomPromptConfig() ? 'custom prompt on' : 'default prompt')}`,
4608
- `${statusBadge('thinking', 'neutral')} ${UI.slate(`mode ${thinkingMode()}`)}`,
4609
- `${statusBadge('tools', 'action')} ${UI.slate(`limit ${toolRoundLimit()} rounds`)}`,
4610
- `${statusBadge('shell', 'info')} ${UI.slate(`stream ${shellStreamToModelEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${UI.slate(`${activeShellSessionCount()} active`)}`,
4611
- `${statusBadge('stream', 'neutral')} ${UI.slate(`heartbeat ${streamHeartbeatEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`phases ${streamPhaseStatusEnabled() ? 'on' : 'off'}`)}`,
4612
- `${statusBadge('summary', 'info')} ${UI.slate(`phases ${summaryPhasesEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`trigger ${summaryTriggerPercent()}%`)}`,
4613
- `${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
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
- console.log(box(startupLines.join('\n'), 'Workspace', 'gray'));
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
- `${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
4721
- `${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
4722
- `${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
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 promptParts = [
5066
- statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
5067
- activeAgentPromptBadge(),
5068
- ];
5069
- const skillsBadge = activeSkillsPromptBadge();
5070
- if (skillsBadge) {
5071
- promptParts.push(skillsBadge);
5072
- }
5073
- if (contextPercent !== null) {
5074
- const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
5075
- promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
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
- const promptDetailLines = [ctxLen
5079
- ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
5080
- : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`)
5081
- ];
5082
- const modeSummary = activeModeSummary({ includeAgent: true, maxSkills: 3 });
5083
- if (modeSummary) {
5084
- promptDetailLines.push(UI.slate(modeSummary));
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
- const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
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(sectionTitle('Core', 'daily workflow', 'cyan'));
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(chalk.cyan(`\n🔍 Searching for: "${query}"...\n`));
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 typeIcon = sym.type === 'function' ? chalk.yellow('𝑓') :
5247
- sym.type === 'class' ? chalk.blue('◆') :
5248
- sym.type === 'method' ? chalk.cyan('') : chalk.gray('');
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? ') + chalk.gray('(y/n): '));
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? ') + chalk.gray('(y/n): '));
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(chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
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
- if (input.toLowerCase().startsWith('/summary')) {
5404
- const arg = input.substring(8).trim();
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 effective = effectiveContextLength();
5408
- const threshold = summaryTokenThreshold(effective);
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
- `phases ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`,
5411
- `trigger ${chalk.white.bold(summaryTriggerPercent() + '%')} ${UI.slate(`(~${threshold.toLocaleString()} tokens)`)}`,
5412
- `config file ${chalk.white(CONFIG_FILE)}`,
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'), 'Summary Settings', 'cyan'));
5416
- console.log(UI.slate(' Usage: /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
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.summaryPhases = DEFAULT_CONFIG.summaryPhases;
5426
- sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
7307
+ sapperConfig.voice = { ...DEFAULT_CONFIG.voice };
5427
7308
  saveConfig(sapperConfig);
5428
- console.log(chalk.green(`✅ Summary settings reset: phases ${summaryPhasesEnabled() ? 'ON' : 'OFF'}, trigger ${summaryTriggerPercent()}%`));
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
- if (subcommand === 'phases' || subcommand === 'phase') {
5433
- let nextValue = null;
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
- if (!value) {
5436
- nextValue = !summaryPhasesEnabled();
5437
- } else {
5438
- const normalized = value.toLowerCase();
5439
- if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(normalized)) {
5440
- nextValue = true;
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
- if (nextValue === null) {
5449
- console.log(chalk.yellow('Usage: /summary phases [on|off]'));
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
- sapperConfig.summaryPhases = nextValue;
5454
- saveConfig(sapperConfig);
5455
- console.log(chalk.green(`✅ Summary phases: ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
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
- if (subcommand === 'trigger' || subcommand === 'percent' || subcommand === 'threshold') {
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: /summary trigger <percent>'));
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 parsedTrigger = parseSummaryTriggerInput(value);
5467
- if (parsedTrigger === null) {
5468
- console.log(chalk.yellow(`Invalid summary trigger: ${value}`));
5469
- console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
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
- sapperConfig.summarizeTriggerPercent = parsedTrigger;
5474
- saveConfig(sapperConfig);
5475
- const effective = effectiveContextLength();
5476
- const threshold = summaryTokenThreshold(effective);
5477
- console.log(chalk.green(`✅ Summary trigger set to ${chalk.white.bold(summaryTriggerPercent() + '%')}`));
5478
- console.log(chalk.gray(` Auto-summary will start near ${threshold.toLocaleString()} tokens.`));
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(`Unknown summary option: ${subcommand}`));
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(chalk.cyan('\nAgent name (lowercase, no spaces): '));
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(chalk.cyan('Agent title/role: '));
5806
- const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
5807
- const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
5808
- const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,ls,search,grep,find,shell,mkdir,rmdir,pwd,cd,cat,head,tail,changes,fetch,memory,open: '));
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(chalk.cyan('\nSkill name (lowercase, no spaces): '));
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(chalk.cyan('Skill title: '));
5844
- const skillDesc = await safeQuestion(chalk.cyan('Brief description (for /skills listing): '));
5845
- const skillArgHint = await safeQuestion(chalk.cyan('Argument hint (optional, e.g. "Describe what to do"): '));
5846
- const skillBody = await safeQuestion(chalk.cyan('Skill knowledge (or Enter for template): '));
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(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
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 webContent = `\n\n══════════════════════════════════════\n🌐 WEB PAGE CONTENT\n══════════════════════════════════════\n\nURL: ${url}\n\n${text}\n`;
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(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
5940
- console.log(chalk.gray('📝 Added to context. AI can now reference this page.\n'));
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(chalk.cyan(`\n🔍 Searching memory for: "${query}"...`));
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? ') + chalk.gray('(y/n): '));
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(chalk.green(`✅ Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`));
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(chalk.yellow(`⏭️ Skipped ${skippedCount} files (too large or limit reached)`));
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(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
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(chalk.cyan('Your prompt for these files: '));
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