specmem-hardwicksoftware 3.7.35 → 3.7.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +11 -15
  3. package/bin/specmem-autoclaude.cjs +12 -1
  4. package/bin/specmem-cli.cjs +1077 -11
  5. package/bin/specmem-console.cjs +890 -63
  6. package/bootstrap.cjs +10 -2
  7. package/claude-hooks/agent-loading-hook.cjs +16 -16
  8. package/claude-hooks/agent-loading-hook.js +28 -21
  9. package/claude-hooks/agent-type-matcher.js +1 -1
  10. package/claude-hooks/background-completion-silencer.js +1 -1
  11. package/claude-hooks/file-claim-enforcer.cjs +37 -36
  12. package/claude-hooks/output-cleaner.cjs +1 -1
  13. package/claude-hooks/refusal-detector-hook.cjs +53 -0
  14. package/claude-hooks/settings.json +64 -4
  15. package/claude-hooks/smart-search-interceptor.js +1 -1
  16. package/claude-hooks/specmem-search-enforcer.cjs +2 -11
  17. package/claude-hooks/specmem-team-member-inject.js +1 -1
  18. package/claude-hooks/specmem-unified-hook.py +1 -1
  19. package/claude-hooks/subagent-loading-hook.cjs +1 -1
  20. package/claude-hooks/task-progress-hook.cjs +7 -7
  21. package/claude-hooks/task-progress-hook.js +3 -3
  22. package/claude-hooks/team-comms-enforcer.cjs +113 -47
  23. package/claude-hooks/use-code-pointers.cjs +1 -1
  24. package/dist/claude-sessions/sessionParser.js +5 -0
  25. package/dist/cli/deploy-to-claude.js +9 -2
  26. package/dist/codebase/codebaseIndexer.js +48 -17
  27. package/dist/codebase/exclusions.js +3 -4
  28. package/dist/codebase/index.js +4 -0
  29. package/dist/codebase/pdfExtractor.js +298 -0
  30. package/dist/dashboard/api/taskTeamMembers.js +2 -2
  31. package/dist/db/bigBrainMigrations.js +29 -0
  32. package/dist/hooks/hookManager.js +4 -4
  33. package/dist/hooks/teamFramingCli.js +1 -1
  34. package/dist/hooks/teamMemberPrepromptHook.js +5 -5
  35. package/dist/index.js +49 -12
  36. package/dist/init/claudeConfigInjector.js +27 -8
  37. package/dist/installer/autoInstall.js +7 -1
  38. package/dist/mcp/compactionProxy.js +1052 -192
  39. package/dist/mcp/compactionProxyDaemon.js +112 -37
  40. package/dist/mcp/contextVault.js +439 -0
  41. package/dist/mcp/embeddingServerManager.js +151 -17
  42. package/dist/mcp/mcpProtocolHandler.js +6 -1
  43. package/dist/mcp/miniCOTServerManager.js +82 -8
  44. package/dist/mcp/specMemServer.js +45 -10
  45. package/dist/mcp/toolRegistry.js +6 -0
  46. package/dist/startup/startupIndexing.js +14 -0
  47. package/dist/team-members/taskOrchestrator.js +3 -3
  48. package/dist/team-members/taskTeamMemberLogger.js +2 -2
  49. package/dist/tools/goofy/deployTeamMember.js +3 -3
  50. package/dist/tools/goofy/digInTheVault.js +81 -0
  51. package/dist/tools/goofy/findCodePointers.js +17 -0
  52. package/dist/tools/goofy/findWhatISaid.js +19 -0
  53. package/dist/tools/goofy/stashTheGoods.js +56 -0
  54. package/dist/tools/teamMemberDeployer.js +2 -2
  55. package/dist/watcher/changeHandler.js +65 -8
  56. package/dist/watcher/changeQueue.js +20 -1
  57. package/embedding-sandbox/frankenstein-embeddings.py +4 -3
  58. package/embedding-sandbox/mini-cot-service.py +11 -13
  59. package/embedding-sandbox/pdf-text-extract.py +208 -0
  60. package/package.json +1 -1
  61. package/scripts/deploy-hooks.cjs +12 -4
  62. package/scripts/fast-batch-embedder.cjs +2 -2
  63. package/scripts/force-retry.cjs +34 -0
  64. package/scripts/global-postinstall.cjs +97 -4
  65. package/scripts/poetic-abliteration.cjs +379 -0
  66. package/scripts/refusal-enforcer.cjs +88 -0
  67. package/scripts/specmem-init.cjs +222 -41
  68. package/specmem/model-config.json +6 -6
  69. package/specmem/supervisord.conf +1 -1
  70. package/svg-sections/readme-token-compaction.svg +246 -0
  71. package/claude-hooks/agent-chooser-hook.js +0 -179
@@ -954,12 +954,28 @@ process.on('unhandledRejection', (reason) => {
954
954
  console.error(`\n${c.red}Unhandled rejection:${c.reset}`, reason);
955
955
  });
956
956
 
957
+ function _killProxyDaemon() {
958
+ try {
959
+ const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
960
+ if (require('fs').existsSync(portFile)) {
961
+ const port = parseInt(require('fs').readFileSync(portFile, 'utf8').trim());
962
+ if (port > 0) {
963
+ const req = require('http').request({ hostname: '127.0.0.1', port, path: '/shutdown', method: 'POST' });
964
+ req.on('error', () => {});
965
+ req.end();
966
+ }
967
+ }
968
+ } catch {}
969
+ }
970
+
957
971
  process.on('SIGINT', () => {
972
+ _killProxyDaemon();
958
973
  restoreTerminalState();
959
974
  process.exit(0);
960
975
  });
961
976
 
962
977
  process.on('SIGTERM', () => {
978
+ _killProxyDaemon();
963
979
  restoreTerminalState();
964
980
  process.exit(0);
965
981
  });
@@ -1574,6 +1590,19 @@ class Controller {
1574
1590
  ];
1575
1591
  }
1576
1592
 
1593
+ /**
1594
+ * Get compaction proxy port if running
1595
+ */
1596
+ _getProxyPort() {
1597
+ try {
1598
+ const portFile = path.join(os.homedir(), '.claude', '.compaction-proxy-port');
1599
+ if (fs.existsSync(portFile)) {
1600
+ return parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10) || null;
1601
+ }
1602
+ } catch (e) {}
1603
+ return null;
1604
+ }
1605
+
1577
1606
  /**
1578
1607
  * Check if is running
1579
1608
  */
@@ -1597,10 +1626,13 @@ class Controller {
1597
1626
  // Uses screen hardcopy to tmpfs on-demand instead of continuous logging
1598
1627
  // -h 5000 sets scrollback buffer to 5000 lines for hardcopy capture
1599
1628
  // SPECMEM_DASHBOARD=1 tells claudefix to disable its footer (dashboard handles rendering)
1629
+ // COMPACTION: Route through proxy if running (overrides settings.json ANTHROPIC_BASE_URL)
1630
+ const proxyPort = this._getProxyPort();
1631
+ const proxyEnv = proxyPort ? `ANTHROPIC_BASE_URL="http://127.0.0.1:${proxyPort}" ` : '';
1600
1632
  const claudeBin = getClaudeBinary();
1601
1633
  const cmd = prompt
1602
- ? `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && SPECMEM_DASHBOARD=1 '${claudeBin}' '${prompt.replace(/'/g, "\\'")}' 2>&1; exec bash"`
1603
- : `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && SPECMEM_DASHBOARD=1 '${claudeBin}' 2>&1; exec bash"`;
1634
+ ? `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && ${proxyEnv}SPECMEM_DASHBOARD=1 '${claudeBin}' '${prompt.replace(/'/g, "\\'")}' 2>&1; exec bash"`
1635
+ : `screen -h 5000 -dmS ${this.claudeSession} bash -c "cd '${this.projectPath}' && ${proxyEnv}SPECMEM_DASHBOARD=1 '${claudeBin}' 2>&1; exec bash"`;
1604
1636
 
1605
1637
  execSync(cmd, { stdio: 'ignore' });
1606
1638
 
@@ -1883,7 +1915,12 @@ ${output}
1883
1915
  class SpecMemDirect {
1884
1916
  constructor(projectPath) {
1885
1917
  this.projectPath = projectPath;
1886
- this.socketPath = path.join(projectPath, 'specmem', 'sockets', 'embeddings.sock');
1918
+ // Check env var first (set by MCP server config), then try both socket locations
1919
+ this.socketPath = process.env.SPECMEM_EMBEDDING_SOCKET
1920
+ || [path.join(projectPath, 'specmem', 'run', 'embed.sock'),
1921
+ path.join(projectPath, 'specmem', 'sockets', 'embeddings.sock')]
1922
+ .find(p => fs.existsSync(p))
1923
+ || path.join(projectPath, 'specmem', 'run', 'embed.sock');
1887
1924
  this.configPath = path.join(projectPath, 'specmem', 'model-config.json');
1888
1925
  this.pool = null; // FIX HIGH-29: Database pool for direct queries
1889
1926
  this.schema = null; // Cached schema name
@@ -3914,9 +3951,15 @@ class SpecMemConsole {
3914
3951
  */
3915
3952
  async handleMiniCOTCommand(args) {
3916
3953
  const subCmd = args[0]?.toLowerCase() || 'status';
3917
- const sockPath = path.join(this.projectPath, 'specmem', 'sockets', 'minicot.sock');
3918
- const pidPath = path.join(this.projectPath, 'specmem', 'sockets', 'minicot.pid');
3919
- const stoppedPath = path.join(this.projectPath, 'specmem', 'sockets', 'minicot.stopped');
3954
+ // Check run/ first (Docker brain container), fall back to sockets/ (legacy host mode)
3955
+ const mcRunDir = path.join(this.projectPath, 'specmem', 'run');
3956
+ const mcSockDir = path.join(this.projectPath, 'specmem', 'sockets');
3957
+ const mcBaseDir = fs.existsSync(path.join(mcRunDir, 'minicot.sock')) ? mcRunDir
3958
+ : fs.existsSync(path.join(mcSockDir, 'minicot.sock')) ? mcSockDir
3959
+ : mcRunDir;
3960
+ const sockPath = path.join(mcBaseDir, 'minicot.sock');
3961
+ const pidPath = path.join(mcBaseDir, 'minicot.pid');
3962
+ const stoppedPath = path.join(mcBaseDir, 'minicot.stopped');
3920
3963
 
3921
3964
  switch (subCmd) {
3922
3965
  case 'status': {
@@ -3924,45 +3967,95 @@ class SpecMemConsole {
3924
3967
  let pid = null, isRunning = false;
3925
3968
  try {
3926
3969
  if (fs.existsSync(pidPath)) {
3927
- pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
3928
- try { process.kill(pid, 0); isRunning = true; } catch (e) {}
3970
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
3971
+ // PID file format is PID:TIMESTAMP
3972
+ pid = parseInt(content.split(':')[0], 10);
3973
+ if (!isNaN(pid)) {
3974
+ try { process.kill(pid, 0); isRunning = true; } catch (e) {
3975
+ // Process dead — stale PID file
3976
+ isRunning = false;
3977
+ }
3978
+ } else {
3979
+ pid = null;
3980
+ }
3929
3981
  }
3930
3982
  } catch (e) {}
3931
3983
  const isStopped = fs.existsSync(stoppedPath);
3932
3984
  const socketExists = fs.existsSync(sockPath);
3933
- console.log(` Status: ${isRunning ? `${c.green}Running${c.reset}` : `${c.red}Stopped${c.reset}`}`);
3934
- console.log(` PID: ${pid || '(none)'}`);
3935
- console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'}`);
3985
+ const statusLabel = isRunning ? `${c.green}Running${c.reset}` :
3986
+ (pid && !isRunning) ? `${c.red}Dead (stale PID ${pid})${c.reset}` : `${c.red}Stopped${c.reset}`;
3987
+ console.log(` Status: ${statusLabel}`);
3988
+ console.log(` PID: ${isRunning ? pid : '(none)'}`);
3989
+ console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'} (${sockPath})`);
3936
3990
  console.log(` Auto-start: ${isStopped ? `${c.yellow}Disabled${c.reset}` : `${c.green}Enabled${c.reset}`}`);
3991
+ if (pid && !isRunning) {
3992
+ console.log(` ${c.yellow}Hint: stale PID file detected. Run 'minicot stop' to clean up.${c.reset}`);
3993
+ }
3937
3994
  break;
3938
3995
  }
3939
3996
  case 'start': {
3940
3997
  if (fs.existsSync(stoppedPath)) fs.unlinkSync(stoppedPath);
3941
- const scriptPath = path.join(__dirname, '..', 'mini-cot-service.py');
3998
+ // Clean stale PID/socket before starting
3999
+ if (fs.existsSync(pidPath)) {
4000
+ try {
4001
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
4002
+ const oldPid = parseInt(content.split(':')[0], 10);
4003
+ if (!isNaN(oldPid)) {
4004
+ try { process.kill(oldPid, 0); console.log(`${c.yellow}Already running (PID ${oldPid})${c.reset}`); break; } catch (_e) {}
4005
+ }
4006
+ fs.unlinkSync(pidPath);
4007
+ } catch (_e) {}
4008
+ }
4009
+ if (fs.existsSync(sockPath)) {
4010
+ try { fs.unlinkSync(sockPath); } catch (_e) {}
4011
+ }
4012
+ const scriptPath = path.join(__dirname, '..', 'embedding-sandbox', 'mini-cot-service.py');
3942
4013
  if (fs.existsSync(scriptPath)) {
3943
4014
  // Task #22 fix: Use getPythonPath() instead of hardcoded 'python3'
3944
4015
  const pythonPath = getPythonPath();
3945
4016
  const proc = require('child_process').spawn(pythonPath, [scriptPath], {
3946
4017
  cwd: this.projectPath, detached: true, stdio: 'ignore',
3947
- env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath }
4018
+ env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath, SPECMEM_MINICOT_SOCKET: sockPath }
3948
4019
  });
3949
4020
  proc.unref();
4021
+ // Write PID file atomically (PID:TIMESTAMP format)
4022
+ const pidDir = path.dirname(pidPath);
4023
+ fs.mkdirSync(pidDir, { recursive: true });
4024
+ const tmpPid = pidPath + '.tmp';
4025
+ fs.writeFileSync(tmpPid, `${proc.pid}:${Date.now()}`, 'utf8');
4026
+ fs.renameSync(tmpPid, pidPath);
3950
4027
  console.log(`${c.green}Mini COT starting (PID: ${proc.pid})${c.reset}`);
3951
4028
  } else {
3952
- console.log(`${c.yellow}mini-cot-service.py not found${c.reset}`);
4029
+ console.log(`${c.yellow}mini-cot-service.py not found at ${scriptPath}${c.reset}`);
3953
4030
  }
3954
4031
  break;
3955
4032
  }
3956
4033
  case 'stop': {
3957
4034
  fs.mkdirSync(path.dirname(stoppedPath), { recursive: true });
3958
4035
  fs.writeFileSync(stoppedPath, new Date().toISOString());
4036
+ let killed = false;
3959
4037
  if (fs.existsSync(pidPath)) {
3960
4038
  try {
3961
- const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
3962
- process.kill(pid, 'SIGTERM');
3963
- fs.unlinkSync(pidPath);
3964
- console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
3965
- } catch (e) { console.log(`${c.yellow}Already stopped${c.reset}`); }
4039
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
4040
+ const pid = parseInt(content.split(':')[0], 10);
4041
+ if (!isNaN(pid)) {
4042
+ process.kill(pid, 'SIGTERM');
4043
+ console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
4044
+ killed = true;
4045
+ }
4046
+ } catch (e) { console.log(`${c.yellow}Process already dead${c.reset}`); }
4047
+ // Always clean up PID file
4048
+ try { fs.unlinkSync(pidPath); } catch (_e) {}
4049
+ // Also clean up .tmp from atomic writes
4050
+ try { fs.unlinkSync(pidPath + '.tmp'); } catch (_e) {}
4051
+ }
4052
+ // Clean up stale socket
4053
+ if (fs.existsSync(sockPath)) {
4054
+ try { fs.unlinkSync(sockPath); } catch (_e) {}
4055
+ console.log(` Cleaned up socket: ${sockPath}`);
4056
+ }
4057
+ if (!killed && !fs.existsSync(pidPath)) {
4058
+ console.log(`${c.yellow}Already stopped${c.reset}`);
3966
4059
  }
3967
4060
  break;
3968
4061
  }
@@ -4061,10 +4154,16 @@ class SpecMemConsole {
4061
4154
  */
4062
4155
  async handleEmbeddingCommand(args) {
4063
4156
  const subCmd = args[0]?.toLowerCase() || 'status';
4064
- const sockPath = path.join(this.projectPath, 'specmem', 'sockets', 'embedding.sock');
4065
- const pidPath = path.join(this.projectPath, 'specmem', 'sockets', 'embedding.pid');
4066
- const stoppedPath = path.join(this.projectPath, 'specmem', 'sockets', 'embedding.stopped');
4067
- const logPath = path.join(this.projectPath, 'specmem', 'sockets', 'embedding.log');
4157
+ // Check run/ first (Docker brain container), fall back to sockets/ (legacy host mode)
4158
+ const runDir = path.join(this.projectPath, 'specmem', 'run');
4159
+ const socketsDir = path.join(this.projectPath, 'specmem', 'sockets');
4160
+ const baseDir = fs.existsSync(path.join(runDir, 'embed.sock')) ? runDir
4161
+ : fs.existsSync(path.join(socketsDir, 'embedding.sock')) ? socketsDir
4162
+ : runDir;
4163
+ const sockPath = baseDir === runDir ? path.join(runDir, 'embed.sock') : path.join(socketsDir, 'embedding.sock');
4164
+ const pidPath = path.join(baseDir, 'embedding.pid');
4165
+ const stoppedPath = path.join(baseDir, 'embedding.stopped');
4166
+ const logPath = path.join(baseDir, 'embedding.log');
4068
4167
 
4069
4168
  switch (subCmd) {
4070
4169
  case 'status': {
@@ -4906,7 +5005,33 @@ class SpecMemConsole {
4906
5005
  } catch (e) { /* no container runtime */ }
4907
5006
 
4908
5007
  if (isContainerMode && brainContainerName) {
4909
- // Container mode: find embedding PID inside container and send SIGUSR1
5008
+ // Container mode: update container cgroup limits via docker update
5009
+ try {
5010
+ const configPath = path.join(this.projectPath, 'specmem', 'model-config.json');
5011
+ if (fs.existsSync(configPath)) {
5012
+ const modelCfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
5013
+ const res = modelCfg.resources || {};
5014
+ const updateArgs = [];
5015
+ // RAM: convert MB to docker memory format
5016
+ if (res.ramMaxMb && res.ramMaxMb >= 256) {
5017
+ updateArgs.push(`--memory=${res.ramMaxMb}m`);
5018
+ // Memory swap = 2x memory to allow some swap headroom
5019
+ updateArgs.push(`--memory-swap=${res.ramMaxMb * 2}m`);
5020
+ }
5021
+ // CPU: convert cpuCoreMax to docker --cpus (float)
5022
+ if (res.cpuCoreMax && res.cpuCoreMax > 0) {
5023
+ updateArgs.push(`--cpus=${res.cpuCoreMax}`);
5024
+ }
5025
+ if (updateArgs.length > 0) {
5026
+ execSync(`${rtCmd} update ${updateArgs.join(' ')} ${brainContainerName}`, { timeout: 10000 });
5027
+ console.log(` ${c.green}${icons.success}${c.reset} Container limits updated: ${updateArgs.join(' ')}`);
5028
+ }
5029
+ }
5030
+ } catch (e) {
5031
+ console.log(` ${c.dim}docker update failed: ${e.message} — container restart may be needed${c.reset}`);
5032
+ }
5033
+
5034
+ // Signal embedding server PID inside container for config reload
4910
5035
  try {
4911
5036
  const pid = execSync(`${rtCmd} exec ${brainContainerName} cat /data/specmem/run/embedding.pid 2>/dev/null || ${rtCmd} exec ${brainContainerName} cat /data/specmem/sockets/embedding.pid 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim().split(':')[0];
4912
5037
  if (pid && !isNaN(parseInt(pid))) {
@@ -5291,10 +5416,14 @@ class SpecMemConsole {
5291
5416
  try { process.kill(parseInt(pid, 10), 'SIGTERM'); killed++; } catch (e) { /* gone */ }
5292
5417
  }
5293
5418
  } catch (e) { /* ok */ }
5294
- // Clean socket files
5419
+ // Clean socket files from both legacy (sockets/) and new (run/) directories
5420
+ const runDir = path.join(this.projectPath, 'specmem', 'run');
5295
5421
  for (const f of ['embeddings.sock', 'embedding.pid', 'embedding.lock', 'translate.sock', 'translate.pid', 'minicot.sock', 'minicot.pid']) {
5296
5422
  try { fs.unlinkSync(path.join(socketsDir, f)); } catch (e) { /* ok */ }
5297
5423
  }
5424
+ for (const f of ['embed.sock', 'embedding.pid', 'embedding.lock', 'translate.sock', 'translate.pid', 'minicot.sock', 'minicot.pid']) {
5425
+ try { fs.unlinkSync(path.join(runDir, f)); } catch (e) { /* ok */ }
5426
+ }
5298
5427
  if (killed > 0) console.log(` ${c.green}${icons.success}${c.reset} Host services killed`);
5299
5428
  else console.log(` ${c.dim}(not running)${c.reset}`);
5300
5429
  }
@@ -6865,7 +6994,9 @@ ${last500}
6865
6994
  const updatePythiaDisplay = (maxWidth) => {
6866
6995
  try {
6867
6996
  // Check if MiniCOT is running by looking for its socket or process
6868
- const miniCotSocket = path.join(self.projectPath, 'specmem/sockets/minicot.sock');
6997
+ const miniCotSocket = fs.existsSync(path.join(self.projectPath, 'specmem/run/minicot.sock'))
6998
+ ? path.join(self.projectPath, 'specmem/run/minicot.sock')
6999
+ : path.join(self.projectPath, 'specmem/sockets/minicot.sock');
6869
7000
  const isRunning = fs.existsSync(miniCotSocket);
6870
7001
 
6871
7002
  if (isRunning) {
@@ -8315,7 +8446,9 @@ const TUI_TABS = [
8315
8446
  },
8316
8447
  {
8317
8448
  name: 'Proxy', key: '6',
8318
- commands: [],
8449
+ commands: [
8450
+ { label: 'Restart Proxy', cmd: 'proxy-restart' },
8451
+ ],
8319
8452
  type: 'proxy',
8320
8453
  },
8321
8454
  {
@@ -8363,7 +8496,15 @@ class ConsoleTUI {
8363
8496
  ];
8364
8497
  this._proxyLogLines = []; // scrollable debug log buffer
8365
8498
  this._proxyLogSince = ''; // ISO timestamp for incremental log fetch
8499
+ this._proxyPreviewHistory = []; // accumulated preview entries for In/Out panels
8500
+ this._proxyPreviewSince = ''; // ISO timestamp for incremental preview fetch
8366
8501
  this._proxySystemPrompt = null;
8502
+ this._proxyCustomSysPrompt = null;
8503
+ // Smart scroll state — prevent setScrollPerc(100) from dragging user back down
8504
+ this._proxyLastInHash = null;
8505
+ this._proxyLastOutHash = null;
8506
+ this._proxyUserScrolledIn = false;
8507
+ this._proxyUserScrolledOut = false;
8367
8508
  const sysCores = os.cpus().length;
8368
8509
  const sysTotalMb = Math.round(os.totalmem() / 1024 / 1024);
8369
8510
  const ramStep = sysTotalMb > 16000 ? 500 : sysTotalMb > 4000 ? 250 : 100;
@@ -8648,6 +8789,7 @@ class ConsoleTUI {
8648
8789
  tags: true,
8649
8790
  scrollable: true,
8650
8791
  alwaysScroll: true,
8792
+ scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
8651
8793
  keys: true,
8652
8794
  mouse: true,
8653
8795
  content: '',
@@ -8663,12 +8805,17 @@ class ConsoleTUI {
8663
8805
  tags: true,
8664
8806
  scrollable: true,
8665
8807
  alwaysScroll: true,
8808
+ scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
8666
8809
  keys: true,
8667
8810
  mouse: true,
8668
8811
  content: '',
8669
8812
  label: ' OUT (compacted) ',
8670
8813
  });
8671
8814
 
8815
+ // Scroll event listeners — detect when user manually scrolls up to prevent auto-scroll drag-down
8816
+ w.proxyIn.on('scroll', () => { this._proxyUserScrolledIn = true; });
8817
+ w.proxyOut.on('scroll', () => { this._proxyUserScrolledOut = true; });
8818
+
8672
8819
  // Logs panel (hidden, shown on Logs tab — full width)
8673
8820
  w.logsBox = blessed.log({
8674
8821
  parent: this._screen,
@@ -8715,8 +8862,15 @@ class ConsoleTUI {
8715
8862
  });
8716
8863
 
8717
8864
  // ── Key bindings ──
8718
- this._screen.key(['q', 'C-c'], () => {
8865
+ this._screen.key(['q'], () => {
8866
+ if (this._commandMode || this._filterMode) return;
8867
+ this.stop();
8868
+ });
8869
+ // Ctrl+C: copy selection if in editor, otherwise quit
8870
+ this._screen.key(['C-c'], () => {
8719
8871
  if (this._commandMode || this._filterMode) return;
8872
+ // If editor overlay active, don't kill - let editor handle it
8873
+ if (this._editorActive) return;
8720
8874
  this.stop();
8721
8875
  });
8722
8876
 
@@ -8877,6 +9031,13 @@ class ConsoleTUI {
8877
9031
  }
8878
9032
  });
8879
9033
 
9034
+ // Customize system prompt modal (P key on proxy tab)
9035
+ this._screen.key(['p'], () => {
9036
+ if (TUI_TABS[this._activeTabIdx].type === 'proxy') {
9037
+ this._showCustomSysPromptModal();
9038
+ }
9039
+ });
9040
+
8880
9041
  // ── Screen resize handler ──
8881
9042
  // Panels use percentage widths and bottom-based height, so blessed
8882
9043
  // auto-recalculates geometry. We just need a full repaint.
@@ -9236,6 +9397,18 @@ class ConsoleTUI {
9236
9397
  return;
9237
9398
  }
9238
9399
 
9400
+ // Handle proxy-restart
9401
+ if (cmdDef.cmd === 'proxy-restart') {
9402
+ (async () => {
9403
+ this._debug('Restarting proxy daemon...');
9404
+ await this._killProxyDaemon();
9405
+ await new Promise(r => setTimeout(r, 600));
9406
+ await this._spawnProxyDaemon();
9407
+ this._renderProxyTab();
9408
+ })();
9409
+ return;
9410
+ }
9411
+
9239
9412
  // Handle confirm type
9240
9413
  if (cmdDef.type === 'confirm') {
9241
9414
  this._showConfirmDialog(cmdDef.confirmText || 'Are you sure?', () => {
@@ -9466,26 +9639,58 @@ class ConsoleTUI {
9466
9639
 
9467
9640
  _startProxyPolling() {
9468
9641
  if (this._proxyPollTimer) return;
9642
+ this._proxyResetDone = false;
9643
+ this._proxySpawnAttempted = false;
9469
9644
  const poll = async () => {
9470
9645
  try {
9471
9646
  if (TUI_TABS[this._activeTabIdx]?.type !== 'proxy') return;
9472
- const [stats, preview, config, logData, sysPrompt] = await Promise.all([
9647
+ // Auto-spawn proxy daemon if not running and port file missing
9648
+ if (!this._proxySpawnAttempted) {
9649
+ const port = this._getProxyPort();
9650
+ if (!port) {
9651
+ this._proxySpawnAttempted = true;
9652
+ this._debug('Proxy not running — spawning daemon...');
9653
+ await this._spawnProxyDaemon();
9654
+ }
9655
+ }
9656
+ // First poll: reset proxy state to clear stale data from previous session
9657
+ if (!this._proxyResetDone) {
9658
+ this._proxyResetDone = true;
9659
+ this._proxyPreviewHistory = [];
9660
+ this._proxyLogLines = [];
9661
+ this._proxyLogSince = '';
9662
+ this._proxyPreviewSince = '';
9663
+ try { await this._proxyHttpPost('/reset'); } catch {}
9664
+ }
9665
+ const [stats, preview, config, logData, sysPrompt, customSysPrompt] = await Promise.all([
9473
9666
  this._proxyHttpGet('/stats'),
9474
- this._proxyHttpGet('/preview'),
9667
+ this._proxyHttpGet(`/preview?since=${this._proxyPreviewSince || ''}`),
9475
9668
  this._proxyHttpGet('/config'),
9476
9669
  this._proxyHttpGet(`/log?limit=50&since=${this._proxyLogSince || ''}`),
9477
9670
  this._proxyHttpGet('/system-prompt'),
9671
+ this._proxyHttpGet('/custom-system-prompt'),
9478
9672
  ]);
9479
9673
  this._proxyStats = stats;
9674
+ // Accumulate preview history instead of replacing each cycle
9675
+ if (preview?.history?.length) {
9676
+ for (const entry of preview.history) {
9677
+ if (!this._proxyPreviewSince || entry.timestamp > this._proxyPreviewSince) {
9678
+ this._proxyPreviewHistory.push(entry);
9679
+ }
9680
+ }
9681
+ if (this._proxyPreviewHistory.length > 20) {
9682
+ this._proxyPreviewHistory = this._proxyPreviewHistory.slice(-20);
9683
+ }
9684
+ this._proxyPreviewSince = preview.history[preview.history.length - 1].timestamp;
9685
+ }
9480
9686
  this._proxyPreview = preview?.preview || null;
9481
9687
  this._proxyConfig = config;
9482
9688
  this._proxySystemPrompt = sysPrompt;
9483
- // Append new log entries
9689
+ this._proxyCustomSysPrompt = customSysPrompt;
9484
9690
  if (logData?.entries?.length) {
9485
9691
  for (const entry of logData.entries) {
9486
9692
  this._proxyLogLines.push(entry);
9487
9693
  }
9488
- // Keep last 200 lines
9489
9694
  if (this._proxyLogLines.length > 200) {
9490
9695
  this._proxyLogLines = this._proxyLogLines.slice(-200);
9491
9696
  }
@@ -9493,13 +9698,68 @@ class ConsoleTUI {
9493
9698
  }
9494
9699
  this._renderProxyTab();
9495
9700
  } catch (e) {
9496
- this._debug(`proxyPoll error: ${e.message}`);
9701
+ this._debug(`Proxy poll error: ${e.message}`);
9497
9702
  }
9498
9703
  };
9499
9704
  poll();
9500
9705
  this._proxyPollTimer = setInterval(poll, 2000);
9501
9706
  }
9502
9707
 
9708
+ async _spawnProxyDaemon() {
9709
+ try {
9710
+ const { spawn } = require('child_process');
9711
+ const fs = require('fs');
9712
+ const path = require('path');
9713
+ // Resolve daemon path relative to this file (bin/ → ../dist/mcp/)
9714
+ // Works whether running from /specmem/bin/ (dev) or /usr/lib/node_modules/.../bin/ (installed)
9715
+ let daemonPath = path.resolve(__dirname, '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
9716
+ if (!fs.existsSync(daemonPath)) {
9717
+ // Fallback: try resolving via the real path of this script
9718
+ try {
9719
+ const realBin = fs.realpathSync(__filename);
9720
+ daemonPath = path.resolve(path.dirname(realBin), '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
9721
+ } catch {}
9722
+ }
9723
+ if (!fs.existsSync(daemonPath)) {
9724
+ throw new Error(`Daemon not found at ${daemonPath}`);
9725
+ }
9726
+ this._debug(`Spawning proxy daemon: ${daemonPath}`);
9727
+ const child = spawn(process.execPath, [daemonPath], {
9728
+ detached: true,
9729
+ stdio: 'ignore',
9730
+ env: { ...process.env, SPECMEM_DAEMON: '1' }
9731
+ });
9732
+ child.unref();
9733
+ await new Promise(r => setTimeout(r, 2000));
9734
+ this._proxyPort = null; // force re-read port file
9735
+ this._debug('Proxy daemon spawned, waiting for port file...');
9736
+ } catch (e) {
9737
+ this._debug(`Proxy spawn failed: ${e.message}`);
9738
+ }
9739
+ }
9740
+
9741
+ async _killProxyDaemon() {
9742
+ try {
9743
+ const pidFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy.pid');
9744
+ const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
9745
+ try {
9746
+ const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);
9747
+ if (pid > 0) {
9748
+ try { process.kill(pid, 'SIGTERM'); } catch {}
9749
+ await new Promise(r => setTimeout(r, 500));
9750
+ try { process.kill(pid, 'SIGKILL'); } catch {}
9751
+ }
9752
+ } catch {}
9753
+ try { require('fs').unlinkSync(pidFile); } catch {}
9754
+ try { require('fs').unlinkSync(portFile); } catch {}
9755
+ this._proxyPort = null;
9756
+ this._proxySpawnAttempted = false;
9757
+ this._debug('Proxy daemon killed.');
9758
+ } catch (e) {
9759
+ this._debug(`Proxy kill failed: ${e.message}`);
9760
+ }
9761
+ }
9762
+
9503
9763
  _renderProxyTab() {
9504
9764
  const w = this._widgets;
9505
9765
  if (!w.proxyBox || w.proxyBox.hidden) return;
@@ -9600,6 +9860,12 @@ class ConsoleTUI {
9600
9860
  ctrl += _stat('Size', `~${_fmtNum(sp.estTokens)} tok`) + '\n';
9601
9861
  ctrl += _stat('Model', sp.model || '?') + '\n';
9602
9862
  ctrl += _stat('Messages', `${sp.messageCount || 0}`) + '\n';
9863
+ // Custom prompt indicator + edit hint
9864
+ if (this._proxyCustomSysPrompt?.hasCustom) {
9865
+ ctrl += ` {#e056a0-fg}★ Custom Active{/#e056a0-fg} {#6c6c80-fg}(P=edit){/#6c6c80-fg}\n`;
9866
+ } else {
9867
+ ctrl += ` {#6c6c80-fg}(P) Customize Sys Prompt{/#6c6c80-fg}\n`;
9868
+ }
9603
9869
  }
9604
9870
 
9605
9871
  if (w.proxyControls) w.proxyControls.setContent(ctrl);
@@ -9638,58 +9904,619 @@ class ConsoleTUI {
9638
9904
  if (w.proxyStats) w.proxyStats.setContent(st);
9639
9905
 
9640
9906
  // ═══════════════════════════════════════════════════════════════
9641
- // TOP-RIGHT: Raw IN text (what Claude sends)
9907
+ // TOP-RIGHT: Raw IN text — accumulated history (what Claude sends)
9642
9908
  // ═══════════════════════════════════════════════════════════════
9643
9909
  let inText = '';
9644
- if (preview?.original) {
9645
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9646
- inText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}\n`;
9647
- // Raw text — strip blessed tags only, show everything else as-is
9648
- inText += preview.original.replace(/\{[^}]*\}/g, '');
9910
+ const previewHistory = this._proxyPreviewHistory;
9911
+ if (previewHistory.length > 0) {
9912
+ for (const entry of previewHistory) {
9913
+ if (!entry?.original) continue;
9914
+ const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
9915
+ const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
9916
+ inText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
9917
+ // Truncate each entry to keep panel readable (first 2000 chars)
9918
+ const rawContent = (entry.original || '').replace(/\{[^}]*\}/g, '');
9919
+ inText += rawContent.slice(0, 2000) + (rawContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n\n';
9920
+ }
9649
9921
  } else {
9650
9922
  inText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
9651
9923
  }
9652
9924
 
9925
+ // Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
9653
9926
  if (w.proxyIn) {
9654
- w.proxyIn.setContent(inText);
9655
- w.proxyIn.setScrollPerc(100);
9927
+ const inHash = crypto.createHash('md5').update(inText).digest('hex').slice(0, 12);
9928
+ if (inHash !== this._proxyLastInHash) {
9929
+ this._proxyLastInHash = inHash;
9930
+ w.proxyIn.setContent(inText);
9931
+ if (!this._proxyUserScrolledIn) {
9932
+ w.proxyIn.setScrollPerc(100);
9933
+ }
9934
+ this._proxyUserScrolledIn = false; // reset for next new-content cycle
9935
+ }
9656
9936
  }
9657
9937
 
9658
9938
  // ═══════════════════════════════════════════════════════════════
9659
- // BOTTOM-RIGHT: Translation samples + compacted output
9939
+ // BOTTOM-RIGHT: Compacted OUT accumulated history
9660
9940
  // ═══════════════════════════════════════════════════════════════
9661
9941
  let outText = '';
9662
- if (preview?.samples && preview.samples.length > 0) {
9663
- // Show translation before→after samples prominently
9664
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9665
- const saved = preview.savings ? ` {#42d77d-fg}-${_fmtNum(preview.savings)} chars{/#42d77d-fg}` : '';
9666
- outText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}${saved} {#a55eea-fg}${preview.samples.length} blocks translated{/#a55eea-fg}\n`;
9667
- for (const s of preview.samples.slice(0, 8)) {
9668
- const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9669
- const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9670
- outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '' : ''}\n`;
9671
- outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '' : ''}\n`;
9672
- }
9673
- if (preview.samples.length > 8) {
9674
- outText += `{#4a4a60-fg}... +${preview.samples.length - 8} more{/#4a4a60-fg}\n`;
9675
- }
9676
- } else if (preview?.optimized) {
9677
- const ts = preview.timestamp ? new Date(preview.timestamp).toLocaleTimeString() : '';
9678
- const saved = preview.savings ? ` {#42d77d-fg}-${_fmtNum(preview.savings)} chars{/#42d77d-fg}` : '';
9679
- outText += `{#4a4a60-fg}${ts}{/#4a4a60-fg}${saved}\n`;
9680
- outText += preview.optimized.replace(/\{[^}]*\}/g, '');
9942
+ if (previewHistory.length > 0) {
9943
+ for (const entry of previewHistory) {
9944
+ const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
9945
+ const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
9946
+ // Show translation samples if available
9947
+ if (entry.samples && entry.samples.length > 0) {
9948
+ outText += `{#5a5a80-fg}── ${ts}${saved} {#a55eea-fg}${entry.samples.length} blocks{/#a55eea-fg} ──{/#5a5a80-fg}\n`;
9949
+ for (const s of entry.samples.slice(0, 4)) {
9950
+ const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9951
+ const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
9952
+ outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '…' : ''}\n`;
9953
+ outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '…' : ''}\n`;
9954
+ }
9955
+ if (entry.samples.length > 4) outText += `{#4a4a60-fg} +${entry.samples.length - 4} more{/#4a4a60-fg}\n`;
9956
+ } else if (entry.optimized) {
9957
+ outText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
9958
+ const optContent = (entry.optimized || '').replace(/\{[^}]*\}/g, '');
9959
+ outText += optContent.slice(0, 2000) + (optContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n';
9960
+ }
9961
+ outText += '\n';
9962
+ }
9681
9963
  } else {
9682
9964
  outText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
9683
9965
  }
9684
9966
 
9967
+ // Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
9685
9968
  if (w.proxyOut) {
9686
- w.proxyOut.setContent(outText);
9687
- w.proxyOut.setScrollPerc(100);
9969
+ const outHash = crypto.createHash('md5').update(outText).digest('hex').slice(0, 12);
9970
+ if (outHash !== this._proxyLastOutHash) {
9971
+ this._proxyLastOutHash = outHash;
9972
+ w.proxyOut.setContent(outText);
9973
+ if (!this._proxyUserScrolledOut) {
9974
+ w.proxyOut.setScrollPerc(100);
9975
+ }
9976
+ this._proxyUserScrolledOut = false;
9977
+ }
9688
9978
  }
9689
9979
 
9690
9980
  this._safeRender();
9691
9981
  }
9692
9982
 
9983
+ async _showCustomSysPromptModal() {
9984
+ this._editorActive = true;
9985
+ const blessed = this._blessed;
9986
+ const screen = this._screen;
9987
+ if (!blessed || !screen) return;
9988
+
9989
+ // Fetch current state from proxy
9990
+ const data = await this._proxyHttpGet('/custom-system-prompt');
9991
+ if (!data) {
9992
+ this._updateStatusBar('{red-fg}✗ Cannot reach proxy{/red-fg}');
9993
+ return;
9994
+ }
9995
+
9996
+ const initialText = data.customPrompt || data.ogPrompt || '';
9997
+ if (!initialText) {
9998
+ this._updateStatusBar('{yellow-fg}No system prompt captured yet — send a request first{/yellow-fg}');
9999
+ return;
10000
+ }
10001
+
10002
+ const ogHash = data.ogHash;
10003
+ const self = this;
10004
+
10005
+ // ── Editor State ──
10006
+ const E = {
10007
+ lines: initialText.split('\n'),
10008
+ row: 0, col: 0, // cursor position
10009
+ scrollY: 0,
10010
+ selAnchor: null, // { row, col } — where selection started
10011
+ selEnd: null, // { row, col } — where selection ends
10012
+ clipboard: '',
10013
+ lastClickTime: 0,
10014
+ clickCount: 0,
10015
+ dirty: false,
10016
+ dragging: false, // mouse drag in progress
10017
+ };
10018
+
10019
+ // ── System clipboard helpers ──
10020
+ const { execSync: _execSync } = require('child_process');
10021
+ const _clipEnv = { ...process.env, DISPLAY: process.env.DISPLAY || ':1' };
10022
+ function _copyToSystemClipboard(text) {
10023
+ // OSC 52 — works in some terminals (iTerm2, kitty, alacritty)
10024
+ const b64 = Buffer.from(text).toString('base64');
10025
+ process.stdout.write(`\x1b]52;c;${b64}\x07`);
10026
+ // xclip/xsel with DISPLAY for VNC environments
10027
+ try {
10028
+ const tool = _execSync('command -v xclip >/dev/null 2>&1 && echo xclip || (command -v xsel >/dev/null 2>&1 && echo xsel)', { encoding: 'utf8', env: _clipEnv }).trim();
10029
+ if (tool === 'xclip') _execSync('xclip -selection clipboard', { input: text, timeout: 2000, env: _clipEnv });
10030
+ else if (tool === 'xsel') _execSync('xsel --clipboard --input', { input: text, timeout: 2000, env: _clipEnv });
10031
+ } catch {}
10032
+ }
10033
+ function _readSystemClipboard() {
10034
+ try {
10035
+ return _execSync('xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null', { encoding: 'utf8', timeout: 2000, env: _clipEnv });
10036
+ } catch { return null; }
10037
+ }
10038
+
10039
+ // ── Helpers ──
10040
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
10041
+ // Ensure E.col never exceeds current line length
10042
+ function clampCursor() {
10043
+ E.row = clamp(E.row, 0, E.lines.length - 1);
10044
+ E.col = clamp(E.col, 0, E.lines[E.row].length);
10045
+ }
10046
+
10047
+ function getText() {
10048
+ return E.lines.join('\n');
10049
+ }
10050
+
10051
+ function hasSelection() {
10052
+ return E.selAnchor !== null && E.selEnd !== null &&
10053
+ (E.selAnchor.row !== E.selEnd.row || E.selAnchor.col !== E.selEnd.col);
10054
+ }
10055
+
10056
+ // Normalized selection: start <= end
10057
+ function getSelRange() {
10058
+ if (!hasSelection()) return null;
10059
+ const a = E.selAnchor, b = E.selEnd;
10060
+ const before = (a.row < b.row) || (a.row === b.row && a.col <= b.col);
10061
+ return before ? { start: a, end: b } : { start: b, end: a };
10062
+ }
10063
+
10064
+ function clearSel() { E.selAnchor = null; E.selEnd = null; }
10065
+
10066
+ function getSelectedText() {
10067
+ const r = getSelRange();
10068
+ if (!r) return '';
10069
+ if (r.start.row === r.end.row) {
10070
+ return E.lines[r.start.row].slice(r.start.col, r.end.col);
10071
+ }
10072
+ let t = E.lines[r.start.row].slice(r.start.col) + '\n';
10073
+ for (let i = r.start.row + 1; i < r.end.row; i++) t += E.lines[i] + '\n';
10074
+ t += E.lines[r.end.row].slice(0, r.end.col);
10075
+ return t;
10076
+ }
10077
+
10078
+ function deleteSelection() {
10079
+ const r = getSelRange();
10080
+ if (!r) return;
10081
+ const before = E.lines[r.start.row].slice(0, r.start.col);
10082
+ const after = E.lines[r.end.row].slice(r.end.col);
10083
+ E.lines.splice(r.start.row, r.end.row - r.start.row + 1, before + after);
10084
+ E.row = r.start.row; E.col = r.start.col;
10085
+ clearSel();
10086
+ E.dirty = true;
10087
+ }
10088
+
10089
+ function insertTextAt(text) {
10090
+ if (hasSelection()) deleteSelection();
10091
+ const before = E.lines[E.row].slice(0, E.col);
10092
+ const after = E.lines[E.row].slice(E.col);
10093
+ const insertLines = text.split('\n');
10094
+ if (insertLines.length === 1) {
10095
+ E.lines[E.row] = before + insertLines[0] + after;
10096
+ E.col += insertLines[0].length;
10097
+ } else {
10098
+ E.lines[E.row] = before + insertLines[0];
10099
+ for (let i = 1; i < insertLines.length - 1; i++) {
10100
+ E.lines.splice(E.row + i, 0, insertLines[i]);
10101
+ }
10102
+ const lastLine = insertLines[insertLines.length - 1];
10103
+ E.lines.splice(E.row + insertLines.length - 1, 0, lastLine + after);
10104
+ E.row += insertLines.length - 1;
10105
+ E.col = lastLine.length;
10106
+ }
10107
+ E.dirty = true;
10108
+ }
10109
+
10110
+ function isInSel(row, col) {
10111
+ const r = getSelRange();
10112
+ if (!r) return false;
10113
+ if (row < r.start.row || row > r.end.row) return false;
10114
+ if (row === r.start.row && col < r.start.col) return false;
10115
+ if (row === r.end.row && col >= r.end.col) return false;
10116
+ if (row === r.start.row && row === r.end.row) return col >= r.start.col && col < r.end.col;
10117
+ if (row === r.start.row) return col >= r.start.col;
10118
+ if (row === r.end.row) return col < r.end.col;
10119
+ return true;
10120
+ }
10121
+
10122
+ function selectWord(row, col) {
10123
+ const line = E.lines[row] || '';
10124
+ if (col >= line.length) { E.selAnchor = { row, col: 0 }; E.selEnd = { row, col: line.length }; return; }
10125
+ let start = col, end = col;
10126
+ const isWordChar = c => /\w/.test(c);
10127
+ const charType = isWordChar(line[col]);
10128
+ while (start > 0 && isWordChar(line[start - 1]) === charType) start--;
10129
+ while (end < line.length && isWordChar(line[end]) === charType) end++;
10130
+ E.selAnchor = { row, col: start };
10131
+ E.selEnd = { row, col: end };
10132
+ E.col = end;
10133
+ }
10134
+
10135
+ function selectLine(row) {
10136
+ E.selAnchor = { row, col: 0 };
10137
+ E.selEnd = { row, col: E.lines[row].length };
10138
+ E.col = E.lines[row].length;
10139
+ }
10140
+
10141
+ // ── Escape blessed tags in content ──
10142
+ function esc(ch) {
10143
+ if (ch === '{') return '{open}';
10144
+ if (ch === '}') return '{close}';
10145
+ return ch;
10146
+ }
10147
+
10148
+ function escStr(s) {
10149
+ return s.replace(/[{}]/g, c => c === '{' ? '{open}' : '{close}');
10150
+ }
10151
+
10152
+ // ── Render ──
10153
+ // Build visual rows from logical lines with soft word wrap.
10154
+ // Returns array of { logRow, colStart, text } — one entry per visual row.
10155
+ function buildVisualRows(visW) {
10156
+ const vRows = [];
10157
+ for (let i = 0; i < E.lines.length; i++) {
10158
+ const line = E.lines[i];
10159
+ if (line.length === 0) {
10160
+ vRows.push({ logRow: i, colStart: 0, text: '' });
10161
+ } else {
10162
+ for (let off = 0; off < line.length; off += visW) {
10163
+ vRows.push({ logRow: i, colStart: off, text: line.slice(off, off + visW) });
10164
+ }
10165
+ }
10166
+ }
10167
+ return vRows;
10168
+ }
10169
+
10170
+ // Find visual row index where cursor lives
10171
+ function cursorVisRow(vRows) {
10172
+ for (let v = 0; v < vRows.length; v++) {
10173
+ const vr = vRows[v];
10174
+ if (vr.logRow === E.row && E.col >= vr.colStart && E.col < vr.colStart + Math.max(vr.text.length, 1)) return v;
10175
+ // Cursor at end-of-line on last wrap segment
10176
+ if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
10177
+ const next = vRows[v + 1];
10178
+ if (!next || next.logRow !== E.row) return v; // last segment of this line
10179
+ }
10180
+ }
10181
+ return vRows.length - 1;
10182
+ }
10183
+
10184
+ function render() {
10185
+ clampCursor();
10186
+ const visH = editorBox.height - 2;
10187
+ const visW = editorBox.width - 2;
10188
+ if (visW < 2) return;
10189
+
10190
+ const vRows = buildVisualRows(visW);
10191
+ const cVRow = cursorVisRow(vRows);
10192
+
10193
+ // Scroll to keep cursor visual row visible
10194
+ if (cVRow < E.scrollY) E.scrollY = cVRow;
10195
+ if (cVRow >= E.scrollY + visH) E.scrollY = cVRow - visH + 1;
10196
+ if (E.scrollY < 0) E.scrollY = 0;
10197
+
10198
+ let out = '';
10199
+ for (let v = E.scrollY; v < Math.min(vRows.length, E.scrollY + visH); v++) {
10200
+ const vr = vRows[v];
10201
+ let rendered = '';
10202
+ for (let j = 0; j < vr.text.length; j++) {
10203
+ const logCol = vr.colStart + j;
10204
+ const ch = esc(vr.text[j]);
10205
+ if (vr.logRow === E.row && logCol === E.col) {
10206
+ rendered += `{inverse}${ch}{/inverse}`;
10207
+ } else if (isInSel(vr.logRow, logCol)) {
10208
+ rendered += `{blue-bg}{white-fg}${ch}{/white-fg}{/blue-bg}`;
10209
+ } else {
10210
+ rendered += ch;
10211
+ }
10212
+ }
10213
+ // Cursor at end of this visual segment
10214
+ if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
10215
+ const next = vRows[v + 1];
10216
+ if (!next || next.logRow !== E.row) {
10217
+ rendered += '{inverse} {/inverse}';
10218
+ }
10219
+ }
10220
+ out += rendered + '\n';
10221
+ }
10222
+
10223
+ // Status line at bottom
10224
+ const charCount = getText().length;
10225
+ const ogLen = data.ogPrompt?.length || 0;
10226
+ const diff = ogLen - charCount;
10227
+ const diffStr = diff > 0 ? `{#42d77d-fg}-${diff.toLocaleString()}{/#42d77d-fg}` : `{#ff4757-fg}+${Math.abs(diff).toLocaleString()}{/#ff4757-fg}`;
10228
+
10229
+ header.setContent(
10230
+ ` {#6c6c80-fg}OG:{/#6c6c80-fg} ${ogLen.toLocaleString()} `
10231
+ + `{#6c6c80-fg}Now:{/#6c6c80-fg} ${charCount.toLocaleString()} (${diffStr}) `
10232
+ + `{#6c6c80-fg}Ln:{/#6c6c80-fg} ${E.row + 1}/${E.lines.length} `
10233
+ + `{#6c6c80-fg}Col:{/#6c6c80-fg} ${E.col + 1}`
10234
+ + (E.dirty ? ' {#e056a0-fg}●modified{/#e056a0-fg}' : '') + '\n'
10235
+ + ` {#42d77d-fg}Ctrl+S{/#42d77d-fg}=Save {#ff4757-fg}Ctrl+R{/#ff4757-fg}=Reset {yellow-fg}Esc{/yellow-fg}=Cancel `
10236
+ + `{#6c6c80-fg}Ctrl+A{/#6c6c80-fg}=SelAll {#6c6c80-fg}Ctrl+C/X/V{/#6c6c80-fg}=Clipboard`
10237
+ );
10238
+
10239
+ editorBox.setContent(out);
10240
+ // Prevent blessed from resetting scroll position on setContent
10241
+ editorBox.childBase = 0;
10242
+ editorBox.childOffset = 0;
10243
+ self._safeRender();
10244
+ }
10245
+
10246
+ // ── Create UI ──
10247
+ const overlay = blessed.box({
10248
+ parent: screen,
10249
+ top: 'center', left: 'center',
10250
+ width: '90%', height: '90%',
10251
+ grabKeys: true, // prevent key leaking to elements behind overlay
10252
+ border: { type: 'line' },
10253
+ style: { fg: '#c0c0d0', bg: '#0a0a15', border: { fg: '#e056a0' }, label: { fg: '#e056a0' } },
10254
+ tags: true,
10255
+ label: data.hasCustom ? ' ★ Edit Custom System Prompt ' : ' Customize System Prompt ',
10256
+ });
10257
+
10258
+ const header = blessed.box({
10259
+ parent: overlay,
10260
+ top: 0, left: 1, right: 1, height: 3,
10261
+ style: { fg: '#a0a0b0', bg: '#0a0a15' },
10262
+ tags: true,
10263
+ });
10264
+
10265
+ const editorBox = blessed.box({
10266
+ parent: overlay,
10267
+ top: 3, left: 1, right: 1, bottom: 0,
10268
+ border: { type: 'line' },
10269
+ style: { fg: '#c0c0d0', bg: '#0f0f1a', border: { fg: '#3a3a50' } },
10270
+ scrollable: false, // we handle scrolling manually
10271
+ mouse: true,
10272
+ keys: true,
10273
+ tags: true,
10274
+ keyable: true,
10275
+ });
10276
+
10277
+ let _destroyed = false;
10278
+ // Block named key events (screen.key() bindings like 'key q', 'key tab') from reaching
10279
+ // dashboard while editor is open. We keep 'keypress' flowing so editorBox still gets input.
10280
+ const _origEmit = screen.emit.bind(screen);
10281
+ screen.emit = function(event, ...args) {
10282
+ if (typeof event === 'string' && event.startsWith('key ') && event !== 'keypress') return false;
10283
+ return _origEmit(event, ...args);
10284
+ };
10285
+
10286
+ const cleanup = () => {
10287
+ if (_destroyed) return;
10288
+ _destroyed = true;
10289
+ screen.emit = _origEmit; // restore original emit so dashboard keys work again
10290
+ editorBox.removeAllListeners();
10291
+ overlay.removeAllListeners();
10292
+ overlay.detach();
10293
+ self._editorActive = false;
10294
+ self._safeRender();
10295
+ process.nextTick(() => overlay.destroy());
10296
+ };
10297
+
10298
+ // ── Mouse Handling (click, drag-select) ──
10299
+ function mouseToPos(mouse) {
10300
+ const localY = mouse.y - (editorBox.atop || 0) - 1;
10301
+ const localX = mouse.x - (editorBox.aleft || 0) - 1;
10302
+ const visW = editorBox.width - 2;
10303
+ const vRows = buildVisualRows(visW);
10304
+ const vIdx = clamp(localY + E.scrollY, 0, vRows.length - 1);
10305
+ const vr = vRows[vIdx];
10306
+ const col = clamp(vr.colStart + localX, 0, E.lines[vr.logRow].length);
10307
+ return { row: vr.logRow, col };
10308
+ }
10309
+
10310
+ editorBox.on('mousedown', (mouse) => {
10311
+ const { row, col } = mouseToPos(mouse);
10312
+ const now = Date.now();
10313
+ if (now - E.lastClickTime < 350 && E.clickCount < 3) {
10314
+ E.clickCount++;
10315
+ } else {
10316
+ E.clickCount = 1;
10317
+ }
10318
+ E.lastClickTime = now;
10319
+
10320
+ if (E.clickCount === 3) {
10321
+ selectLine(row);
10322
+ E.dragging = false;
10323
+ } else if (E.clickCount === 2) {
10324
+ selectWord(row, col);
10325
+ E.dragging = false;
10326
+ } else {
10327
+ E.row = row; E.col = col;
10328
+ clearSel();
10329
+ E.dragging = true;
10330
+ }
10331
+ render();
10332
+ });
10333
+
10334
+ editorBox.on('mousemove', (mouse) => {
10335
+ if (!E.dragging) return;
10336
+ const { row, col } = mouseToPos(mouse);
10337
+ // Start selection from cursor if not already started
10338
+ if (!hasSelection()) {
10339
+ E.selAnchor = { row: E.row, col: E.col };
10340
+ }
10341
+ E.selEnd = { row, col };
10342
+ E.row = row; E.col = col;
10343
+ render();
10344
+ });
10345
+
10346
+ editorBox.on('mouseup', () => {
10347
+ E.dragging = false;
10348
+ });
10349
+
10350
+ // Scroll wheel
10351
+ editorBox.on('wheeldown', () => { E.scrollY = Math.min(E.scrollY + 3, Math.max(0, E.lines.length - 5)); render(); });
10352
+ editorBox.on('wheelup', () => { E.scrollY = Math.max(0, E.scrollY - 3); render(); });
10353
+
10354
+ // ── Keyboard Handling ──
10355
+ editorBox.on('keypress', (ch, key) => {
10356
+ if (!key) return;
10357
+ const ctrl = key.ctrl;
10358
+ const shift = key.shift;
10359
+ const name = key.name;
10360
+
10361
+ // ── Ctrl combos ──
10362
+ if (ctrl && name === 's') {
10363
+ const text = getText();
10364
+ if (!text.trim()) { self._updateStatusBar('{red-fg}Cannot save empty prompt{/red-fg}'); return; }
10365
+ self._proxyHttpPost('/custom-system-prompt', { prompt: text, ogHash }).then((res) => {
10366
+ if (res?.ok) self._updateStatusBar(`{#42d77d-fg}✓ Custom prompt saved (${text.length} chars){/#42d77d-fg}`);
10367
+ else self._updateStatusBar(`{red-fg}✗ Save failed: ${res?.error || 'unknown'}{/red-fg}`);
10368
+ cleanup();
10369
+ });
10370
+ return;
10371
+ }
10372
+ if (ctrl && name === 'r') {
10373
+ self._proxyHttpPost('/custom-system-prompt', { reset: true }).then((res) => {
10374
+ if (res?.ok) self._updateStatusBar('{#42d77d-fg}✓ System prompt reset to original{/#42d77d-fg}');
10375
+ cleanup();
10376
+ });
10377
+ return;
10378
+ }
10379
+ if (ctrl && name === 'a') { // Select All
10380
+ E.selAnchor = { row: 0, col: 0 };
10381
+ E.selEnd = { row: E.lines.length - 1, col: E.lines[E.lines.length - 1].length };
10382
+ E.row = E.selEnd.row; E.col = E.selEnd.col;
10383
+ render(); return;
10384
+ }
10385
+ if (ctrl && name === 'c') { // Copy
10386
+ if (hasSelection()) {
10387
+ E.clipboard = getSelectedText();
10388
+ _copyToSystemClipboard(E.clipboard);
10389
+ }
10390
+ render(); return;
10391
+ }
10392
+ if (ctrl && name === 'x') { // Cut
10393
+ if (hasSelection()) {
10394
+ E.clipboard = getSelectedText();
10395
+ _copyToSystemClipboard(E.clipboard);
10396
+ deleteSelection();
10397
+ }
10398
+ render(); return;
10399
+ }
10400
+ if (ctrl && name === 'v') { // Paste
10401
+ // Try system clipboard first, fallback to internal
10402
+ const sysCb = _readSystemClipboard();
10403
+ if (sysCb) { E.clipboard = sysCb; insertTextAt(sysCb); }
10404
+ else if (E.clipboard) insertTextAt(E.clipboard);
10405
+ render(); return;
10406
+ }
10407
+
10408
+ // ── Escape ──
10409
+ if (name === 'escape') {
10410
+ // Prevent event propagation before cleanup
10411
+ editorBox.removeAllListeners('keypress');
10412
+ cleanup();
10413
+ return;
10414
+ }
10415
+
10416
+ // ── Arrow keys (with shift-select) ──
10417
+ if (name === 'left' || name === 'right' || name === 'up' || name === 'down'
10418
+ || name === 'home' || name === 'end' || name === 'pageup' || name === 'pagedown') {
10419
+ const prevRow = E.row, prevCol = E.col;
10420
+
10421
+ if (name === 'left') {
10422
+ if (E.col > 0) E.col--;
10423
+ else if (E.row > 0) { E.row--; E.col = E.lines[E.row].length; }
10424
+ } else if (name === 'right') {
10425
+ if (E.col < E.lines[E.row].length) E.col++;
10426
+ else if (E.row < E.lines.length - 1) { E.row++; E.col = 0; }
10427
+ } else if (name === 'up') {
10428
+ if (E.row > 0) { E.row--; E.col = Math.min(E.col, E.lines[E.row].length); }
10429
+ } else if (name === 'down') {
10430
+ if (E.row < E.lines.length - 1) { E.row++; E.col = Math.min(E.col, E.lines[E.row].length); }
10431
+ } else if (name === 'home') {
10432
+ E.col = 0;
10433
+ } else if (name === 'end') {
10434
+ E.col = E.lines[E.row].length;
10435
+ } else if (name === 'pageup') {
10436
+ const visH = editorBox.height - 2;
10437
+ E.row = Math.max(0, E.row - visH);
10438
+ E.col = Math.min(E.col, E.lines[E.row].length);
10439
+ } else if (name === 'pagedown') {
10440
+ const visH = editorBox.height - 2;
10441
+ E.row = Math.min(E.lines.length - 1, E.row + visH);
10442
+ E.col = Math.min(E.col, E.lines[E.row].length);
10443
+ }
10444
+
10445
+ if (shift) {
10446
+ if (!E.selAnchor) E.selAnchor = { row: prevRow, col: prevCol };
10447
+ E.selEnd = { row: E.row, col: E.col };
10448
+ } else {
10449
+ clearSel();
10450
+ }
10451
+ render(); return;
10452
+ }
10453
+
10454
+ // ── Backspace ──
10455
+ if (name === 'backspace') {
10456
+ if (hasSelection()) { deleteSelection(); }
10457
+ else if (E.col > 0) {
10458
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col - 1) + E.lines[E.row].slice(E.col);
10459
+ E.col--;
10460
+ E.dirty = true;
10461
+ } else if (E.row > 0) {
10462
+ E.col = E.lines[E.row - 1].length;
10463
+ E.lines[E.row - 1] += E.lines[E.row];
10464
+ E.lines.splice(E.row, 1);
10465
+ E.row--;
10466
+ E.dirty = true;
10467
+ }
10468
+ render(); return;
10469
+ }
10470
+
10471
+ // ── Delete ──
10472
+ if (name === 'delete') {
10473
+ if (hasSelection()) { deleteSelection(); }
10474
+ else if (E.col < E.lines[E.row].length) {
10475
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col) + E.lines[E.row].slice(E.col + 1);
10476
+ E.dirty = true;
10477
+ } else if (E.row < E.lines.length - 1) {
10478
+ E.lines[E.row] += E.lines[E.row + 1];
10479
+ E.lines.splice(E.row + 1, 1);
10480
+ E.dirty = true;
10481
+ }
10482
+ render(); return;
10483
+ }
10484
+
10485
+ // ── Enter ──
10486
+ if (name === 'enter' || name === 'return') {
10487
+ if (hasSelection()) deleteSelection();
10488
+ const after = E.lines[E.row].slice(E.col);
10489
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col);
10490
+ E.lines.splice(E.row + 1, 0, after);
10491
+ E.row++; E.col = 0;
10492
+ E.dirty = true;
10493
+ render(); return;
10494
+ }
10495
+
10496
+ // ── Tab ──
10497
+ if (name === 'tab') {
10498
+ if (hasSelection()) deleteSelection();
10499
+ insertTextAt(' ');
10500
+ render(); return;
10501
+ }
10502
+
10503
+ // ── Printable character ──
10504
+ if (ch && ch.length === 1 && !ctrl && !key.meta) {
10505
+ if (hasSelection()) deleteSelection();
10506
+ E.lines[E.row] = E.lines[E.row].slice(0, E.col) + ch + E.lines[E.row].slice(E.col);
10507
+ E.col++;
10508
+ E.dirty = true;
10509
+ render(); return;
10510
+ }
10511
+ });
10512
+
10513
+ // Prevent blessed's built-in key/scroll handling from moving content
10514
+ editorBox.scrollable = false;
10515
+ editorBox.focus();
10516
+ screen.on('keypress', _blockKeys);
10517
+ render();
10518
+ }
10519
+
9693
10520
  _adjustProxySlider(direction) {
9694
10521
  const def = this._proxySliderDefs[this._proxySliderIdx];
9695
10522
  if (!def || !this._proxyConfig) return;