specmem-hardwicksoftware 3.7.35 → 3.7.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +11 -15
- package/bin/specmem-console.cjs +839 -51
- package/claude-hooks/agent-chooser-hook.js +6 -6
- package/claude-hooks/agent-loading-hook.cjs +16 -16
- package/claude-hooks/agent-loading-hook.js +18 -18
- package/claude-hooks/agent-type-matcher.js +1 -1
- package/claude-hooks/background-completion-silencer.js +1 -1
- package/claude-hooks/file-claim-enforcer.cjs +37 -36
- package/claude-hooks/output-cleaner.cjs +1 -1
- package/claude-hooks/settings.json +27 -3
- package/claude-hooks/specmem-search-enforcer.cjs +2 -11
- package/claude-hooks/specmem-team-member-inject.js +1 -1
- package/claude-hooks/specmem-unified-hook.py +1 -1
- package/claude-hooks/subagent-loading-hook.cjs +1 -1
- package/claude-hooks/task-progress-hook.cjs +7 -7
- package/claude-hooks/task-progress-hook.js +3 -3
- package/claude-hooks/team-comms-enforcer.cjs +49 -47
- package/dist/claude-sessions/sessionParser.js +5 -0
- package/dist/codebase/codebaseIndexer.js +48 -17
- package/dist/codebase/exclusions.js +3 -4
- package/dist/codebase/index.js +4 -0
- package/dist/codebase/pdfExtractor.js +298 -0
- package/dist/dashboard/api/taskTeamMembers.js +2 -2
- package/dist/db/bigBrainMigrations.js +29 -0
- package/dist/hooks/hookManager.js +4 -4
- package/dist/hooks/teamFramingCli.js +1 -1
- package/dist/hooks/teamMemberPrepromptHook.js +5 -5
- package/dist/init/claudeConfigInjector.js +2 -2
- package/dist/mcp/compactionProxy.js +834 -186
- package/dist/mcp/compactionProxyDaemon.js +112 -37
- package/dist/mcp/contextVault.js +439 -0
- package/dist/mcp/embeddingServerManager.js +61 -1
- package/dist/mcp/mcpProtocolHandler.js +6 -1
- package/dist/mcp/miniCOTServerManager.js +82 -8
- package/dist/mcp/specMemServer.js +45 -10
- package/dist/mcp/toolRegistry.js +6 -0
- package/dist/startup/startupIndexing.js +14 -0
- package/dist/team-members/taskOrchestrator.js +3 -3
- package/dist/team-members/taskTeamMemberLogger.js +2 -2
- package/dist/tools/goofy/deployTeamMember.js +3 -3
- package/dist/tools/goofy/digInTheVault.js +81 -0
- package/dist/tools/goofy/stashTheGoods.js +56 -0
- package/dist/tools/teamMemberDeployer.js +2 -2
- package/dist/watcher/changeHandler.js +65 -8
- package/dist/watcher/changeQueue.js +20 -1
- package/embedding-sandbox/mini-cot-service.py +11 -13
- package/embedding-sandbox/pdf-text-extract.py +208 -0
- package/package.json +1 -1
- package/scripts/deploy-hooks.cjs +2 -2
- package/scripts/global-postinstall.cjs +2 -2
- package/scripts/specmem-init.cjs +130 -36
- package/specmem/model-config.json +6 -6
- package/specmem/supervisord.conf +1 -1
- package/svg-sections/readme-token-compaction.svg +246 -0
package/bin/specmem-console.cjs
CHANGED
|
@@ -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
|
});
|
|
@@ -3924,45 +3940,95 @@ class SpecMemConsole {
|
|
|
3924
3940
|
let pid = null, isRunning = false;
|
|
3925
3941
|
try {
|
|
3926
3942
|
if (fs.existsSync(pidPath)) {
|
|
3927
|
-
|
|
3928
|
-
|
|
3943
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
3944
|
+
// PID file format is PID:TIMESTAMP
|
|
3945
|
+
pid = parseInt(content.split(':')[0], 10);
|
|
3946
|
+
if (!isNaN(pid)) {
|
|
3947
|
+
try { process.kill(pid, 0); isRunning = true; } catch (e) {
|
|
3948
|
+
// Process dead — stale PID file
|
|
3949
|
+
isRunning = false;
|
|
3950
|
+
}
|
|
3951
|
+
} else {
|
|
3952
|
+
pid = null;
|
|
3953
|
+
}
|
|
3929
3954
|
}
|
|
3930
3955
|
} catch (e) {}
|
|
3931
3956
|
const isStopped = fs.existsSync(stoppedPath);
|
|
3932
3957
|
const socketExists = fs.existsSync(sockPath);
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
console.log(`
|
|
3958
|
+
const statusLabel = isRunning ? `${c.green}Running${c.reset}` :
|
|
3959
|
+
(pid && !isRunning) ? `${c.red}Dead (stale PID ${pid})${c.reset}` : `${c.red}Stopped${c.reset}`;
|
|
3960
|
+
console.log(` Status: ${statusLabel}`);
|
|
3961
|
+
console.log(` PID: ${isRunning ? pid : '(none)'}`);
|
|
3962
|
+
console.log(` Socket: ${socketExists ? `${c.green}Exists${c.reset}` : 'Not found'} (${sockPath})`);
|
|
3936
3963
|
console.log(` Auto-start: ${isStopped ? `${c.yellow}Disabled${c.reset}` : `${c.green}Enabled${c.reset}`}`);
|
|
3964
|
+
if (pid && !isRunning) {
|
|
3965
|
+
console.log(` ${c.yellow}Hint: stale PID file detected. Run 'minicot stop' to clean up.${c.reset}`);
|
|
3966
|
+
}
|
|
3937
3967
|
break;
|
|
3938
3968
|
}
|
|
3939
3969
|
case 'start': {
|
|
3940
3970
|
if (fs.existsSync(stoppedPath)) fs.unlinkSync(stoppedPath);
|
|
3941
|
-
|
|
3971
|
+
// Clean stale PID/socket before starting
|
|
3972
|
+
if (fs.existsSync(pidPath)) {
|
|
3973
|
+
try {
|
|
3974
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
3975
|
+
const oldPid = parseInt(content.split(':')[0], 10);
|
|
3976
|
+
if (!isNaN(oldPid)) {
|
|
3977
|
+
try { process.kill(oldPid, 0); console.log(`${c.yellow}Already running (PID ${oldPid})${c.reset}`); break; } catch (_e) {}
|
|
3978
|
+
}
|
|
3979
|
+
fs.unlinkSync(pidPath);
|
|
3980
|
+
} catch (_e) {}
|
|
3981
|
+
}
|
|
3982
|
+
if (fs.existsSync(sockPath)) {
|
|
3983
|
+
try { fs.unlinkSync(sockPath); } catch (_e) {}
|
|
3984
|
+
}
|
|
3985
|
+
const scriptPath = path.join(__dirname, '..', 'embedding-sandbox', 'mini-cot-service.py');
|
|
3942
3986
|
if (fs.existsSync(scriptPath)) {
|
|
3943
3987
|
// Task #22 fix: Use getPythonPath() instead of hardcoded 'python3'
|
|
3944
3988
|
const pythonPath = getPythonPath();
|
|
3945
3989
|
const proc = require('child_process').spawn(pythonPath, [scriptPath], {
|
|
3946
3990
|
cwd: this.projectPath, detached: true, stdio: 'ignore',
|
|
3947
|
-
env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath }
|
|
3991
|
+
env: { ...process.env, SPECMEM_PROJECT_PATH: this.projectPath, SPECMEM_MINICOT_SOCKET: sockPath }
|
|
3948
3992
|
});
|
|
3949
3993
|
proc.unref();
|
|
3994
|
+
// Write PID file atomically (PID:TIMESTAMP format)
|
|
3995
|
+
const pidDir = path.dirname(pidPath);
|
|
3996
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
3997
|
+
const tmpPid = pidPath + '.tmp';
|
|
3998
|
+
fs.writeFileSync(tmpPid, `${proc.pid}:${Date.now()}`, 'utf8');
|
|
3999
|
+
fs.renameSync(tmpPid, pidPath);
|
|
3950
4000
|
console.log(`${c.green}Mini COT starting (PID: ${proc.pid})${c.reset}`);
|
|
3951
4001
|
} else {
|
|
3952
|
-
console.log(`${c.yellow}mini-cot-service.py not found${c.reset}`);
|
|
4002
|
+
console.log(`${c.yellow}mini-cot-service.py not found at ${scriptPath}${c.reset}`);
|
|
3953
4003
|
}
|
|
3954
4004
|
break;
|
|
3955
4005
|
}
|
|
3956
4006
|
case 'stop': {
|
|
3957
4007
|
fs.mkdirSync(path.dirname(stoppedPath), { recursive: true });
|
|
3958
4008
|
fs.writeFileSync(stoppedPath, new Date().toISOString());
|
|
4009
|
+
let killed = false;
|
|
3959
4010
|
if (fs.existsSync(pidPath)) {
|
|
3960
4011
|
try {
|
|
3961
|
-
const
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
4012
|
+
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
4013
|
+
const pid = parseInt(content.split(':')[0], 10);
|
|
4014
|
+
if (!isNaN(pid)) {
|
|
4015
|
+
process.kill(pid, 'SIGTERM');
|
|
4016
|
+
console.log(`${c.green}Stopped PID ${pid}${c.reset}`);
|
|
4017
|
+
killed = true;
|
|
4018
|
+
}
|
|
4019
|
+
} catch (e) { console.log(`${c.yellow}Process already dead${c.reset}`); }
|
|
4020
|
+
// Always clean up PID file
|
|
4021
|
+
try { fs.unlinkSync(pidPath); } catch (_e) {}
|
|
4022
|
+
// Also clean up .tmp from atomic writes
|
|
4023
|
+
try { fs.unlinkSync(pidPath + '.tmp'); } catch (_e) {}
|
|
4024
|
+
}
|
|
4025
|
+
// Clean up stale socket
|
|
4026
|
+
if (fs.existsSync(sockPath)) {
|
|
4027
|
+
try { fs.unlinkSync(sockPath); } catch (_e) {}
|
|
4028
|
+
console.log(` Cleaned up socket: ${sockPath}`);
|
|
4029
|
+
}
|
|
4030
|
+
if (!killed && !fs.existsSync(pidPath)) {
|
|
4031
|
+
console.log(`${c.yellow}Already stopped${c.reset}`);
|
|
3966
4032
|
}
|
|
3967
4033
|
break;
|
|
3968
4034
|
}
|
|
@@ -4906,7 +4972,33 @@ class SpecMemConsole {
|
|
|
4906
4972
|
} catch (e) { /* no container runtime */ }
|
|
4907
4973
|
|
|
4908
4974
|
if (isContainerMode && brainContainerName) {
|
|
4909
|
-
// Container mode:
|
|
4975
|
+
// Container mode: update container cgroup limits via docker update
|
|
4976
|
+
try {
|
|
4977
|
+
const configPath = path.join(this.projectPath, 'specmem', 'model-config.json');
|
|
4978
|
+
if (fs.existsSync(configPath)) {
|
|
4979
|
+
const modelCfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
4980
|
+
const res = modelCfg.resources || {};
|
|
4981
|
+
const updateArgs = [];
|
|
4982
|
+
// RAM: convert MB to docker memory format
|
|
4983
|
+
if (res.ramMaxMb && res.ramMaxMb >= 256) {
|
|
4984
|
+
updateArgs.push(`--memory=${res.ramMaxMb}m`);
|
|
4985
|
+
// Memory swap = 2x memory to allow some swap headroom
|
|
4986
|
+
updateArgs.push(`--memory-swap=${res.ramMaxMb * 2}m`);
|
|
4987
|
+
}
|
|
4988
|
+
// CPU: convert cpuCoreMax to docker --cpus (float)
|
|
4989
|
+
if (res.cpuCoreMax && res.cpuCoreMax > 0) {
|
|
4990
|
+
updateArgs.push(`--cpus=${res.cpuCoreMax}`);
|
|
4991
|
+
}
|
|
4992
|
+
if (updateArgs.length > 0) {
|
|
4993
|
+
execSync(`${rtCmd} update ${updateArgs.join(' ')} ${brainContainerName}`, { timeout: 10000 });
|
|
4994
|
+
console.log(` ${c.green}${icons.success}${c.reset} Container limits updated: ${updateArgs.join(' ')}`);
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
} catch (e) {
|
|
4998
|
+
console.log(` ${c.dim}docker update failed: ${e.message} — container restart may be needed${c.reset}`);
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
// Signal embedding server PID inside container for config reload
|
|
4910
5002
|
try {
|
|
4911
5003
|
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
5004
|
if (pid && !isNaN(parseInt(pid))) {
|
|
@@ -8315,7 +8407,9 @@ const TUI_TABS = [
|
|
|
8315
8407
|
},
|
|
8316
8408
|
{
|
|
8317
8409
|
name: 'Proxy', key: '6',
|
|
8318
|
-
commands: [
|
|
8410
|
+
commands: [
|
|
8411
|
+
{ label: 'Restart Proxy', cmd: 'proxy-restart' },
|
|
8412
|
+
],
|
|
8319
8413
|
type: 'proxy',
|
|
8320
8414
|
},
|
|
8321
8415
|
{
|
|
@@ -8363,7 +8457,15 @@ class ConsoleTUI {
|
|
|
8363
8457
|
];
|
|
8364
8458
|
this._proxyLogLines = []; // scrollable debug log buffer
|
|
8365
8459
|
this._proxyLogSince = ''; // ISO timestamp for incremental log fetch
|
|
8460
|
+
this._proxyPreviewHistory = []; // accumulated preview entries for In/Out panels
|
|
8461
|
+
this._proxyPreviewSince = ''; // ISO timestamp for incremental preview fetch
|
|
8366
8462
|
this._proxySystemPrompt = null;
|
|
8463
|
+
this._proxyCustomSysPrompt = null;
|
|
8464
|
+
// Smart scroll state — prevent setScrollPerc(100) from dragging user back down
|
|
8465
|
+
this._proxyLastInHash = null;
|
|
8466
|
+
this._proxyLastOutHash = null;
|
|
8467
|
+
this._proxyUserScrolledIn = false;
|
|
8468
|
+
this._proxyUserScrolledOut = false;
|
|
8367
8469
|
const sysCores = os.cpus().length;
|
|
8368
8470
|
const sysTotalMb = Math.round(os.totalmem() / 1024 / 1024);
|
|
8369
8471
|
const ramStep = sysTotalMb > 16000 ? 500 : sysTotalMb > 4000 ? 250 : 100;
|
|
@@ -8648,6 +8750,7 @@ class ConsoleTUI {
|
|
|
8648
8750
|
tags: true,
|
|
8649
8751
|
scrollable: true,
|
|
8650
8752
|
alwaysScroll: true,
|
|
8753
|
+
scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
|
|
8651
8754
|
keys: true,
|
|
8652
8755
|
mouse: true,
|
|
8653
8756
|
content: '',
|
|
@@ -8663,12 +8766,17 @@ class ConsoleTUI {
|
|
|
8663
8766
|
tags: true,
|
|
8664
8767
|
scrollable: true,
|
|
8665
8768
|
alwaysScroll: true,
|
|
8769
|
+
scrollbar: { ch: '│', style: { fg: '#3a3a50' } },
|
|
8666
8770
|
keys: true,
|
|
8667
8771
|
mouse: true,
|
|
8668
8772
|
content: '',
|
|
8669
8773
|
label: ' OUT (compacted) ',
|
|
8670
8774
|
});
|
|
8671
8775
|
|
|
8776
|
+
// Scroll event listeners — detect when user manually scrolls up to prevent auto-scroll drag-down
|
|
8777
|
+
w.proxyIn.on('scroll', () => { this._proxyUserScrolledIn = true; });
|
|
8778
|
+
w.proxyOut.on('scroll', () => { this._proxyUserScrolledOut = true; });
|
|
8779
|
+
|
|
8672
8780
|
// Logs panel (hidden, shown on Logs tab — full width)
|
|
8673
8781
|
w.logsBox = blessed.log({
|
|
8674
8782
|
parent: this._screen,
|
|
@@ -8715,10 +8823,17 @@ class ConsoleTUI {
|
|
|
8715
8823
|
});
|
|
8716
8824
|
|
|
8717
8825
|
// ── Key bindings ──
|
|
8718
|
-
this._screen.key(['q'
|
|
8826
|
+
this._screen.key(['q'], () => {
|
|
8719
8827
|
if (this._commandMode || this._filterMode) return;
|
|
8720
8828
|
this.stop();
|
|
8721
8829
|
});
|
|
8830
|
+
// Ctrl+C: copy selection if in editor, otherwise quit
|
|
8831
|
+
this._screen.key(['C-c'], () => {
|
|
8832
|
+
if (this._commandMode || this._filterMode) return;
|
|
8833
|
+
// If editor overlay active, don't kill - let editor handle it
|
|
8834
|
+
if (this._editorActive) return;
|
|
8835
|
+
this.stop();
|
|
8836
|
+
});
|
|
8722
8837
|
|
|
8723
8838
|
this._screen.key(['tab'], () => {
|
|
8724
8839
|
const next = (this._activeTabIdx + 1) % TUI_TABS.length;
|
|
@@ -8877,6 +8992,13 @@ class ConsoleTUI {
|
|
|
8877
8992
|
}
|
|
8878
8993
|
});
|
|
8879
8994
|
|
|
8995
|
+
// Customize system prompt modal (P key on proxy tab)
|
|
8996
|
+
this._screen.key(['p'], () => {
|
|
8997
|
+
if (TUI_TABS[this._activeTabIdx].type === 'proxy') {
|
|
8998
|
+
this._showCustomSysPromptModal();
|
|
8999
|
+
}
|
|
9000
|
+
});
|
|
9001
|
+
|
|
8880
9002
|
// ── Screen resize handler ──
|
|
8881
9003
|
// Panels use percentage widths and bottom-based height, so blessed
|
|
8882
9004
|
// auto-recalculates geometry. We just need a full repaint.
|
|
@@ -9236,6 +9358,18 @@ class ConsoleTUI {
|
|
|
9236
9358
|
return;
|
|
9237
9359
|
}
|
|
9238
9360
|
|
|
9361
|
+
// Handle proxy-restart
|
|
9362
|
+
if (cmdDef.cmd === 'proxy-restart') {
|
|
9363
|
+
(async () => {
|
|
9364
|
+
this._debug('Restarting proxy daemon...');
|
|
9365
|
+
await this._killProxyDaemon();
|
|
9366
|
+
await new Promise(r => setTimeout(r, 600));
|
|
9367
|
+
await this._spawnProxyDaemon();
|
|
9368
|
+
this._renderProxyTab();
|
|
9369
|
+
})();
|
|
9370
|
+
return;
|
|
9371
|
+
}
|
|
9372
|
+
|
|
9239
9373
|
// Handle confirm type
|
|
9240
9374
|
if (cmdDef.type === 'confirm') {
|
|
9241
9375
|
this._showConfirmDialog(cmdDef.confirmText || 'Are you sure?', () => {
|
|
@@ -9466,26 +9600,58 @@ class ConsoleTUI {
|
|
|
9466
9600
|
|
|
9467
9601
|
_startProxyPolling() {
|
|
9468
9602
|
if (this._proxyPollTimer) return;
|
|
9603
|
+
this._proxyResetDone = false;
|
|
9604
|
+
this._proxySpawnAttempted = false;
|
|
9469
9605
|
const poll = async () => {
|
|
9470
9606
|
try {
|
|
9471
9607
|
if (TUI_TABS[this._activeTabIdx]?.type !== 'proxy') return;
|
|
9472
|
-
|
|
9608
|
+
// Auto-spawn proxy daemon if not running and port file missing
|
|
9609
|
+
if (!this._proxySpawnAttempted) {
|
|
9610
|
+
const port = this._getProxyPort();
|
|
9611
|
+
if (!port) {
|
|
9612
|
+
this._proxySpawnAttempted = true;
|
|
9613
|
+
this._debug('Proxy not running — spawning daemon...');
|
|
9614
|
+
await this._spawnProxyDaemon();
|
|
9615
|
+
}
|
|
9616
|
+
}
|
|
9617
|
+
// First poll: reset proxy state to clear stale data from previous session
|
|
9618
|
+
if (!this._proxyResetDone) {
|
|
9619
|
+
this._proxyResetDone = true;
|
|
9620
|
+
this._proxyPreviewHistory = [];
|
|
9621
|
+
this._proxyLogLines = [];
|
|
9622
|
+
this._proxyLogSince = '';
|
|
9623
|
+
this._proxyPreviewSince = '';
|
|
9624
|
+
try { await this._proxyHttpPost('/reset'); } catch {}
|
|
9625
|
+
}
|
|
9626
|
+
const [stats, preview, config, logData, sysPrompt, customSysPrompt] = await Promise.all([
|
|
9473
9627
|
this._proxyHttpGet('/stats'),
|
|
9474
|
-
this._proxyHttpGet(
|
|
9628
|
+
this._proxyHttpGet(`/preview?since=${this._proxyPreviewSince || ''}`),
|
|
9475
9629
|
this._proxyHttpGet('/config'),
|
|
9476
9630
|
this._proxyHttpGet(`/log?limit=50&since=${this._proxyLogSince || ''}`),
|
|
9477
9631
|
this._proxyHttpGet('/system-prompt'),
|
|
9632
|
+
this._proxyHttpGet('/custom-system-prompt'),
|
|
9478
9633
|
]);
|
|
9479
9634
|
this._proxyStats = stats;
|
|
9635
|
+
// Accumulate preview history instead of replacing each cycle
|
|
9636
|
+
if (preview?.history?.length) {
|
|
9637
|
+
for (const entry of preview.history) {
|
|
9638
|
+
if (!this._proxyPreviewSince || entry.timestamp > this._proxyPreviewSince) {
|
|
9639
|
+
this._proxyPreviewHistory.push(entry);
|
|
9640
|
+
}
|
|
9641
|
+
}
|
|
9642
|
+
if (this._proxyPreviewHistory.length > 20) {
|
|
9643
|
+
this._proxyPreviewHistory = this._proxyPreviewHistory.slice(-20);
|
|
9644
|
+
}
|
|
9645
|
+
this._proxyPreviewSince = preview.history[preview.history.length - 1].timestamp;
|
|
9646
|
+
}
|
|
9480
9647
|
this._proxyPreview = preview?.preview || null;
|
|
9481
9648
|
this._proxyConfig = config;
|
|
9482
9649
|
this._proxySystemPrompt = sysPrompt;
|
|
9483
|
-
|
|
9650
|
+
this._proxyCustomSysPrompt = customSysPrompt;
|
|
9484
9651
|
if (logData?.entries?.length) {
|
|
9485
9652
|
for (const entry of logData.entries) {
|
|
9486
9653
|
this._proxyLogLines.push(entry);
|
|
9487
9654
|
}
|
|
9488
|
-
// Keep last 200 lines
|
|
9489
9655
|
if (this._proxyLogLines.length > 200) {
|
|
9490
9656
|
this._proxyLogLines = this._proxyLogLines.slice(-200);
|
|
9491
9657
|
}
|
|
@@ -9493,13 +9659,68 @@ class ConsoleTUI {
|
|
|
9493
9659
|
}
|
|
9494
9660
|
this._renderProxyTab();
|
|
9495
9661
|
} catch (e) {
|
|
9496
|
-
this._debug(`
|
|
9662
|
+
this._debug(`Proxy poll error: ${e.message}`);
|
|
9497
9663
|
}
|
|
9498
9664
|
};
|
|
9499
9665
|
poll();
|
|
9500
9666
|
this._proxyPollTimer = setInterval(poll, 2000);
|
|
9501
9667
|
}
|
|
9502
9668
|
|
|
9669
|
+
async _spawnProxyDaemon() {
|
|
9670
|
+
try {
|
|
9671
|
+
const { spawn } = require('child_process');
|
|
9672
|
+
const fs = require('fs');
|
|
9673
|
+
const path = require('path');
|
|
9674
|
+
// Resolve daemon path relative to this file (bin/ → ../dist/mcp/)
|
|
9675
|
+
// Works whether running from /specmem/bin/ (dev) or /usr/lib/node_modules/.../bin/ (installed)
|
|
9676
|
+
let daemonPath = path.resolve(__dirname, '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
|
|
9677
|
+
if (!fs.existsSync(daemonPath)) {
|
|
9678
|
+
// Fallback: try resolving via the real path of this script
|
|
9679
|
+
try {
|
|
9680
|
+
const realBin = fs.realpathSync(__filename);
|
|
9681
|
+
daemonPath = path.resolve(path.dirname(realBin), '..', 'dist', 'mcp', 'compactionProxyDaemon.js');
|
|
9682
|
+
} catch {}
|
|
9683
|
+
}
|
|
9684
|
+
if (!fs.existsSync(daemonPath)) {
|
|
9685
|
+
throw new Error(`Daemon not found at ${daemonPath}`);
|
|
9686
|
+
}
|
|
9687
|
+
this._debug(`Spawning proxy daemon: ${daemonPath}`);
|
|
9688
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
9689
|
+
detached: true,
|
|
9690
|
+
stdio: 'ignore',
|
|
9691
|
+
env: { ...process.env, SPECMEM_DAEMON: '1' }
|
|
9692
|
+
});
|
|
9693
|
+
child.unref();
|
|
9694
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
9695
|
+
this._proxyPort = null; // force re-read port file
|
|
9696
|
+
this._debug('Proxy daemon spawned, waiting for port file...');
|
|
9697
|
+
} catch (e) {
|
|
9698
|
+
this._debug(`Proxy spawn failed: ${e.message}`);
|
|
9699
|
+
}
|
|
9700
|
+
}
|
|
9701
|
+
|
|
9702
|
+
async _killProxyDaemon() {
|
|
9703
|
+
try {
|
|
9704
|
+
const pidFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy.pid');
|
|
9705
|
+
const portFile = require('path').join(require('os').homedir(), '.claude', '.compaction-proxy-port');
|
|
9706
|
+
try {
|
|
9707
|
+
const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);
|
|
9708
|
+
if (pid > 0) {
|
|
9709
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
9710
|
+
await new Promise(r => setTimeout(r, 500));
|
|
9711
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
9712
|
+
}
|
|
9713
|
+
} catch {}
|
|
9714
|
+
try { require('fs').unlinkSync(pidFile); } catch {}
|
|
9715
|
+
try { require('fs').unlinkSync(portFile); } catch {}
|
|
9716
|
+
this._proxyPort = null;
|
|
9717
|
+
this._proxySpawnAttempted = false;
|
|
9718
|
+
this._debug('Proxy daemon killed.');
|
|
9719
|
+
} catch (e) {
|
|
9720
|
+
this._debug(`Proxy kill failed: ${e.message}`);
|
|
9721
|
+
}
|
|
9722
|
+
}
|
|
9723
|
+
|
|
9503
9724
|
_renderProxyTab() {
|
|
9504
9725
|
const w = this._widgets;
|
|
9505
9726
|
if (!w.proxyBox || w.proxyBox.hidden) return;
|
|
@@ -9600,6 +9821,12 @@ class ConsoleTUI {
|
|
|
9600
9821
|
ctrl += _stat('Size', `~${_fmtNum(sp.estTokens)} tok`) + '\n';
|
|
9601
9822
|
ctrl += _stat('Model', sp.model || '?') + '\n';
|
|
9602
9823
|
ctrl += _stat('Messages', `${sp.messageCount || 0}`) + '\n';
|
|
9824
|
+
// Custom prompt indicator + edit hint
|
|
9825
|
+
if (this._proxyCustomSysPrompt?.hasCustom) {
|
|
9826
|
+
ctrl += ` {#e056a0-fg}★ Custom Active{/#e056a0-fg} {#6c6c80-fg}(P=edit){/#6c6c80-fg}\n`;
|
|
9827
|
+
} else {
|
|
9828
|
+
ctrl += ` {#6c6c80-fg}(P) Customize Sys Prompt{/#6c6c80-fg}\n`;
|
|
9829
|
+
}
|
|
9603
9830
|
}
|
|
9604
9831
|
|
|
9605
9832
|
if (w.proxyControls) w.proxyControls.setContent(ctrl);
|
|
@@ -9638,58 +9865,619 @@ class ConsoleTUI {
|
|
|
9638
9865
|
if (w.proxyStats) w.proxyStats.setContent(st);
|
|
9639
9866
|
|
|
9640
9867
|
// ═══════════════════════════════════════════════════════════════
|
|
9641
|
-
// TOP-RIGHT: Raw IN text (what Claude sends)
|
|
9868
|
+
// TOP-RIGHT: Raw IN text — accumulated history (what Claude sends)
|
|
9642
9869
|
// ═══════════════════════════════════════════════════════════════
|
|
9643
9870
|
let inText = '';
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9871
|
+
const previewHistory = this._proxyPreviewHistory;
|
|
9872
|
+
if (previewHistory.length > 0) {
|
|
9873
|
+
for (const entry of previewHistory) {
|
|
9874
|
+
if (!entry?.original) continue;
|
|
9875
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
9876
|
+
const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
|
|
9877
|
+
inText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
|
|
9878
|
+
// Truncate each entry to keep panel readable (first 2000 chars)
|
|
9879
|
+
const rawContent = (entry.original || '').replace(/\{[^}]*\}/g, '');
|
|
9880
|
+
inText += rawContent.slice(0, 2000) + (rawContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n\n';
|
|
9881
|
+
}
|
|
9649
9882
|
} else {
|
|
9650
9883
|
inText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
|
|
9651
9884
|
}
|
|
9652
9885
|
|
|
9886
|
+
// Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
|
|
9653
9887
|
if (w.proxyIn) {
|
|
9654
|
-
|
|
9655
|
-
|
|
9888
|
+
const inHash = crypto.createHash('md5').update(inText).digest('hex').slice(0, 12);
|
|
9889
|
+
if (inHash !== this._proxyLastInHash) {
|
|
9890
|
+
this._proxyLastInHash = inHash;
|
|
9891
|
+
w.proxyIn.setContent(inText);
|
|
9892
|
+
if (!this._proxyUserScrolledIn) {
|
|
9893
|
+
w.proxyIn.setScrollPerc(100);
|
|
9894
|
+
}
|
|
9895
|
+
this._proxyUserScrolledIn = false; // reset for next new-content cycle
|
|
9896
|
+
}
|
|
9656
9897
|
}
|
|
9657
9898
|
|
|
9658
9899
|
// ═══════════════════════════════════════════════════════════════
|
|
9659
|
-
// BOTTOM-RIGHT:
|
|
9900
|
+
// BOTTOM-RIGHT: Compacted OUT — accumulated history
|
|
9660
9901
|
// ═══════════════════════════════════════════════════════════════
|
|
9661
9902
|
let outText = '';
|
|
9662
|
-
if (
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
|
|
9903
|
+
if (previewHistory.length > 0) {
|
|
9904
|
+
for (const entry of previewHistory) {
|
|
9905
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
9906
|
+
const saved = entry.savings ? ` {#42d77d-fg}-${_fmtNum(entry.savings)} chars{/#42d77d-fg}` : '';
|
|
9907
|
+
// Show translation samples if available
|
|
9908
|
+
if (entry.samples && entry.samples.length > 0) {
|
|
9909
|
+
outText += `{#5a5a80-fg}── ${ts}${saved} {#a55eea-fg}${entry.samples.length} blocks{/#a55eea-fg} ──{/#5a5a80-fg}\n`;
|
|
9910
|
+
for (const s of entry.samples.slice(0, 4)) {
|
|
9911
|
+
const before = (s.before || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
|
|
9912
|
+
const after = (s.after || '').slice(0, 60).replace(/\{[^}]*\}/g, '');
|
|
9913
|
+
outText += `{#ff4757-fg}EN:{/#ff4757-fg} ${before}${s.before?.length > 60 ? '…' : ''}\n`;
|
|
9914
|
+
outText += `{#42d77d-fg}→ {/#42d77d-fg} ${after}${s.after?.length > 60 ? '…' : ''}\n`;
|
|
9915
|
+
}
|
|
9916
|
+
if (entry.samples.length > 4) outText += `{#4a4a60-fg} +${entry.samples.length - 4} more{/#4a4a60-fg}\n`;
|
|
9917
|
+
} else if (entry.optimized) {
|
|
9918
|
+
outText += `{#5a5a80-fg}── ${ts}${saved} ──{/#5a5a80-fg}\n`;
|
|
9919
|
+
const optContent = (entry.optimized || '').replace(/\{[^}]*\}/g, '');
|
|
9920
|
+
outText += optContent.slice(0, 2000) + (optContent.length > 2000 ? '\n{#4a4a60-fg}... truncated{/#4a4a60-fg}' : '') + '\n';
|
|
9921
|
+
}
|
|
9922
|
+
outText += '\n';
|
|
9923
|
+
}
|
|
9681
9924
|
} else {
|
|
9682
9925
|
outText += '{#4a4a60-fg}waiting for traffic...{/#4a4a60-fg}';
|
|
9683
9926
|
}
|
|
9684
9927
|
|
|
9928
|
+
// Smart scroll: only update content when changed, only auto-scroll if user hasn't scrolled up
|
|
9685
9929
|
if (w.proxyOut) {
|
|
9686
|
-
|
|
9687
|
-
|
|
9930
|
+
const outHash = crypto.createHash('md5').update(outText).digest('hex').slice(0, 12);
|
|
9931
|
+
if (outHash !== this._proxyLastOutHash) {
|
|
9932
|
+
this._proxyLastOutHash = outHash;
|
|
9933
|
+
w.proxyOut.setContent(outText);
|
|
9934
|
+
if (!this._proxyUserScrolledOut) {
|
|
9935
|
+
w.proxyOut.setScrollPerc(100);
|
|
9936
|
+
}
|
|
9937
|
+
this._proxyUserScrolledOut = false;
|
|
9938
|
+
}
|
|
9688
9939
|
}
|
|
9689
9940
|
|
|
9690
9941
|
this._safeRender();
|
|
9691
9942
|
}
|
|
9692
9943
|
|
|
9944
|
+
async _showCustomSysPromptModal() {
|
|
9945
|
+
this._editorActive = true;
|
|
9946
|
+
const blessed = this._blessed;
|
|
9947
|
+
const screen = this._screen;
|
|
9948
|
+
if (!blessed || !screen) return;
|
|
9949
|
+
|
|
9950
|
+
// Fetch current state from proxy
|
|
9951
|
+
const data = await this._proxyHttpGet('/custom-system-prompt');
|
|
9952
|
+
if (!data) {
|
|
9953
|
+
this._updateStatusBar('{red-fg}✗ Cannot reach proxy{/red-fg}');
|
|
9954
|
+
return;
|
|
9955
|
+
}
|
|
9956
|
+
|
|
9957
|
+
const initialText = data.customPrompt || data.ogPrompt || '';
|
|
9958
|
+
if (!initialText) {
|
|
9959
|
+
this._updateStatusBar('{yellow-fg}No system prompt captured yet — send a request first{/yellow-fg}');
|
|
9960
|
+
return;
|
|
9961
|
+
}
|
|
9962
|
+
|
|
9963
|
+
const ogHash = data.ogHash;
|
|
9964
|
+
const self = this;
|
|
9965
|
+
|
|
9966
|
+
// ── Editor State ──
|
|
9967
|
+
const E = {
|
|
9968
|
+
lines: initialText.split('\n'),
|
|
9969
|
+
row: 0, col: 0, // cursor position
|
|
9970
|
+
scrollY: 0,
|
|
9971
|
+
selAnchor: null, // { row, col } — where selection started
|
|
9972
|
+
selEnd: null, // { row, col } — where selection ends
|
|
9973
|
+
clipboard: '',
|
|
9974
|
+
lastClickTime: 0,
|
|
9975
|
+
clickCount: 0,
|
|
9976
|
+
dirty: false,
|
|
9977
|
+
dragging: false, // mouse drag in progress
|
|
9978
|
+
};
|
|
9979
|
+
|
|
9980
|
+
// ── System clipboard helpers ──
|
|
9981
|
+
const { execSync: _execSync } = require('child_process');
|
|
9982
|
+
const _clipEnv = { ...process.env, DISPLAY: process.env.DISPLAY || ':1' };
|
|
9983
|
+
function _copyToSystemClipboard(text) {
|
|
9984
|
+
// OSC 52 — works in some terminals (iTerm2, kitty, alacritty)
|
|
9985
|
+
const b64 = Buffer.from(text).toString('base64');
|
|
9986
|
+
process.stdout.write(`\x1b]52;c;${b64}\x07`);
|
|
9987
|
+
// xclip/xsel with DISPLAY for VNC environments
|
|
9988
|
+
try {
|
|
9989
|
+
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();
|
|
9990
|
+
if (tool === 'xclip') _execSync('xclip -selection clipboard', { input: text, timeout: 2000, env: _clipEnv });
|
|
9991
|
+
else if (tool === 'xsel') _execSync('xsel --clipboard --input', { input: text, timeout: 2000, env: _clipEnv });
|
|
9992
|
+
} catch {}
|
|
9993
|
+
}
|
|
9994
|
+
function _readSystemClipboard() {
|
|
9995
|
+
try {
|
|
9996
|
+
return _execSync('xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null', { encoding: 'utf8', timeout: 2000, env: _clipEnv });
|
|
9997
|
+
} catch { return null; }
|
|
9998
|
+
}
|
|
9999
|
+
|
|
10000
|
+
// ── Helpers ──
|
|
10001
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
10002
|
+
// Ensure E.col never exceeds current line length
|
|
10003
|
+
function clampCursor() {
|
|
10004
|
+
E.row = clamp(E.row, 0, E.lines.length - 1);
|
|
10005
|
+
E.col = clamp(E.col, 0, E.lines[E.row].length);
|
|
10006
|
+
}
|
|
10007
|
+
|
|
10008
|
+
function getText() {
|
|
10009
|
+
return E.lines.join('\n');
|
|
10010
|
+
}
|
|
10011
|
+
|
|
10012
|
+
function hasSelection() {
|
|
10013
|
+
return E.selAnchor !== null && E.selEnd !== null &&
|
|
10014
|
+
(E.selAnchor.row !== E.selEnd.row || E.selAnchor.col !== E.selEnd.col);
|
|
10015
|
+
}
|
|
10016
|
+
|
|
10017
|
+
// Normalized selection: start <= end
|
|
10018
|
+
function getSelRange() {
|
|
10019
|
+
if (!hasSelection()) return null;
|
|
10020
|
+
const a = E.selAnchor, b = E.selEnd;
|
|
10021
|
+
const before = (a.row < b.row) || (a.row === b.row && a.col <= b.col);
|
|
10022
|
+
return before ? { start: a, end: b } : { start: b, end: a };
|
|
10023
|
+
}
|
|
10024
|
+
|
|
10025
|
+
function clearSel() { E.selAnchor = null; E.selEnd = null; }
|
|
10026
|
+
|
|
10027
|
+
function getSelectedText() {
|
|
10028
|
+
const r = getSelRange();
|
|
10029
|
+
if (!r) return '';
|
|
10030
|
+
if (r.start.row === r.end.row) {
|
|
10031
|
+
return E.lines[r.start.row].slice(r.start.col, r.end.col);
|
|
10032
|
+
}
|
|
10033
|
+
let t = E.lines[r.start.row].slice(r.start.col) + '\n';
|
|
10034
|
+
for (let i = r.start.row + 1; i < r.end.row; i++) t += E.lines[i] + '\n';
|
|
10035
|
+
t += E.lines[r.end.row].slice(0, r.end.col);
|
|
10036
|
+
return t;
|
|
10037
|
+
}
|
|
10038
|
+
|
|
10039
|
+
function deleteSelection() {
|
|
10040
|
+
const r = getSelRange();
|
|
10041
|
+
if (!r) return;
|
|
10042
|
+
const before = E.lines[r.start.row].slice(0, r.start.col);
|
|
10043
|
+
const after = E.lines[r.end.row].slice(r.end.col);
|
|
10044
|
+
E.lines.splice(r.start.row, r.end.row - r.start.row + 1, before + after);
|
|
10045
|
+
E.row = r.start.row; E.col = r.start.col;
|
|
10046
|
+
clearSel();
|
|
10047
|
+
E.dirty = true;
|
|
10048
|
+
}
|
|
10049
|
+
|
|
10050
|
+
function insertTextAt(text) {
|
|
10051
|
+
if (hasSelection()) deleteSelection();
|
|
10052
|
+
const before = E.lines[E.row].slice(0, E.col);
|
|
10053
|
+
const after = E.lines[E.row].slice(E.col);
|
|
10054
|
+
const insertLines = text.split('\n');
|
|
10055
|
+
if (insertLines.length === 1) {
|
|
10056
|
+
E.lines[E.row] = before + insertLines[0] + after;
|
|
10057
|
+
E.col += insertLines[0].length;
|
|
10058
|
+
} else {
|
|
10059
|
+
E.lines[E.row] = before + insertLines[0];
|
|
10060
|
+
for (let i = 1; i < insertLines.length - 1; i++) {
|
|
10061
|
+
E.lines.splice(E.row + i, 0, insertLines[i]);
|
|
10062
|
+
}
|
|
10063
|
+
const lastLine = insertLines[insertLines.length - 1];
|
|
10064
|
+
E.lines.splice(E.row + insertLines.length - 1, 0, lastLine + after);
|
|
10065
|
+
E.row += insertLines.length - 1;
|
|
10066
|
+
E.col = lastLine.length;
|
|
10067
|
+
}
|
|
10068
|
+
E.dirty = true;
|
|
10069
|
+
}
|
|
10070
|
+
|
|
10071
|
+
function isInSel(row, col) {
|
|
10072
|
+
const r = getSelRange();
|
|
10073
|
+
if (!r) return false;
|
|
10074
|
+
if (row < r.start.row || row > r.end.row) return false;
|
|
10075
|
+
if (row === r.start.row && col < r.start.col) return false;
|
|
10076
|
+
if (row === r.end.row && col >= r.end.col) return false;
|
|
10077
|
+
if (row === r.start.row && row === r.end.row) return col >= r.start.col && col < r.end.col;
|
|
10078
|
+
if (row === r.start.row) return col >= r.start.col;
|
|
10079
|
+
if (row === r.end.row) return col < r.end.col;
|
|
10080
|
+
return true;
|
|
10081
|
+
}
|
|
10082
|
+
|
|
10083
|
+
function selectWord(row, col) {
|
|
10084
|
+
const line = E.lines[row] || '';
|
|
10085
|
+
if (col >= line.length) { E.selAnchor = { row, col: 0 }; E.selEnd = { row, col: line.length }; return; }
|
|
10086
|
+
let start = col, end = col;
|
|
10087
|
+
const isWordChar = c => /\w/.test(c);
|
|
10088
|
+
const charType = isWordChar(line[col]);
|
|
10089
|
+
while (start > 0 && isWordChar(line[start - 1]) === charType) start--;
|
|
10090
|
+
while (end < line.length && isWordChar(line[end]) === charType) end++;
|
|
10091
|
+
E.selAnchor = { row, col: start };
|
|
10092
|
+
E.selEnd = { row, col: end };
|
|
10093
|
+
E.col = end;
|
|
10094
|
+
}
|
|
10095
|
+
|
|
10096
|
+
function selectLine(row) {
|
|
10097
|
+
E.selAnchor = { row, col: 0 };
|
|
10098
|
+
E.selEnd = { row, col: E.lines[row].length };
|
|
10099
|
+
E.col = E.lines[row].length;
|
|
10100
|
+
}
|
|
10101
|
+
|
|
10102
|
+
// ── Escape blessed tags in content ──
|
|
10103
|
+
function esc(ch) {
|
|
10104
|
+
if (ch === '{') return '{open}';
|
|
10105
|
+
if (ch === '}') return '{close}';
|
|
10106
|
+
return ch;
|
|
10107
|
+
}
|
|
10108
|
+
|
|
10109
|
+
function escStr(s) {
|
|
10110
|
+
return s.replace(/[{}]/g, c => c === '{' ? '{open}' : '{close}');
|
|
10111
|
+
}
|
|
10112
|
+
|
|
10113
|
+
// ── Render ──
|
|
10114
|
+
// Build visual rows from logical lines with soft word wrap.
|
|
10115
|
+
// Returns array of { logRow, colStart, text } — one entry per visual row.
|
|
10116
|
+
function buildVisualRows(visW) {
|
|
10117
|
+
const vRows = [];
|
|
10118
|
+
for (let i = 0; i < E.lines.length; i++) {
|
|
10119
|
+
const line = E.lines[i];
|
|
10120
|
+
if (line.length === 0) {
|
|
10121
|
+
vRows.push({ logRow: i, colStart: 0, text: '' });
|
|
10122
|
+
} else {
|
|
10123
|
+
for (let off = 0; off < line.length; off += visW) {
|
|
10124
|
+
vRows.push({ logRow: i, colStart: off, text: line.slice(off, off + visW) });
|
|
10125
|
+
}
|
|
10126
|
+
}
|
|
10127
|
+
}
|
|
10128
|
+
return vRows;
|
|
10129
|
+
}
|
|
10130
|
+
|
|
10131
|
+
// Find visual row index where cursor lives
|
|
10132
|
+
function cursorVisRow(vRows) {
|
|
10133
|
+
for (let v = 0; v < vRows.length; v++) {
|
|
10134
|
+
const vr = vRows[v];
|
|
10135
|
+
if (vr.logRow === E.row && E.col >= vr.colStart && E.col < vr.colStart + Math.max(vr.text.length, 1)) return v;
|
|
10136
|
+
// Cursor at end-of-line on last wrap segment
|
|
10137
|
+
if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
|
|
10138
|
+
const next = vRows[v + 1];
|
|
10139
|
+
if (!next || next.logRow !== E.row) return v; // last segment of this line
|
|
10140
|
+
}
|
|
10141
|
+
}
|
|
10142
|
+
return vRows.length - 1;
|
|
10143
|
+
}
|
|
10144
|
+
|
|
10145
|
+
function render() {
|
|
10146
|
+
clampCursor();
|
|
10147
|
+
const visH = editorBox.height - 2;
|
|
10148
|
+
const visW = editorBox.width - 2;
|
|
10149
|
+
if (visW < 2) return;
|
|
10150
|
+
|
|
10151
|
+
const vRows = buildVisualRows(visW);
|
|
10152
|
+
const cVRow = cursorVisRow(vRows);
|
|
10153
|
+
|
|
10154
|
+
// Scroll to keep cursor visual row visible
|
|
10155
|
+
if (cVRow < E.scrollY) E.scrollY = cVRow;
|
|
10156
|
+
if (cVRow >= E.scrollY + visH) E.scrollY = cVRow - visH + 1;
|
|
10157
|
+
if (E.scrollY < 0) E.scrollY = 0;
|
|
10158
|
+
|
|
10159
|
+
let out = '';
|
|
10160
|
+
for (let v = E.scrollY; v < Math.min(vRows.length, E.scrollY + visH); v++) {
|
|
10161
|
+
const vr = vRows[v];
|
|
10162
|
+
let rendered = '';
|
|
10163
|
+
for (let j = 0; j < vr.text.length; j++) {
|
|
10164
|
+
const logCol = vr.colStart + j;
|
|
10165
|
+
const ch = esc(vr.text[j]);
|
|
10166
|
+
if (vr.logRow === E.row && logCol === E.col) {
|
|
10167
|
+
rendered += `{inverse}${ch}{/inverse}`;
|
|
10168
|
+
} else if (isInSel(vr.logRow, logCol)) {
|
|
10169
|
+
rendered += `{blue-bg}{white-fg}${ch}{/white-fg}{/blue-bg}`;
|
|
10170
|
+
} else {
|
|
10171
|
+
rendered += ch;
|
|
10172
|
+
}
|
|
10173
|
+
}
|
|
10174
|
+
// Cursor at end of this visual segment
|
|
10175
|
+
if (vr.logRow === E.row && E.col === vr.colStart + vr.text.length) {
|
|
10176
|
+
const next = vRows[v + 1];
|
|
10177
|
+
if (!next || next.logRow !== E.row) {
|
|
10178
|
+
rendered += '{inverse} {/inverse}';
|
|
10179
|
+
}
|
|
10180
|
+
}
|
|
10181
|
+
out += rendered + '\n';
|
|
10182
|
+
}
|
|
10183
|
+
|
|
10184
|
+
// Status line at bottom
|
|
10185
|
+
const charCount = getText().length;
|
|
10186
|
+
const ogLen = data.ogPrompt?.length || 0;
|
|
10187
|
+
const diff = ogLen - charCount;
|
|
10188
|
+
const diffStr = diff > 0 ? `{#42d77d-fg}-${diff.toLocaleString()}{/#42d77d-fg}` : `{#ff4757-fg}+${Math.abs(diff).toLocaleString()}{/#ff4757-fg}`;
|
|
10189
|
+
|
|
10190
|
+
header.setContent(
|
|
10191
|
+
` {#6c6c80-fg}OG:{/#6c6c80-fg} ${ogLen.toLocaleString()} `
|
|
10192
|
+
+ `{#6c6c80-fg}Now:{/#6c6c80-fg} ${charCount.toLocaleString()} (${diffStr}) `
|
|
10193
|
+
+ `{#6c6c80-fg}Ln:{/#6c6c80-fg} ${E.row + 1}/${E.lines.length} `
|
|
10194
|
+
+ `{#6c6c80-fg}Col:{/#6c6c80-fg} ${E.col + 1}`
|
|
10195
|
+
+ (E.dirty ? ' {#e056a0-fg}●modified{/#e056a0-fg}' : '') + '\n'
|
|
10196
|
+
+ ` {#42d77d-fg}Ctrl+S{/#42d77d-fg}=Save {#ff4757-fg}Ctrl+R{/#ff4757-fg}=Reset {yellow-fg}Esc{/yellow-fg}=Cancel `
|
|
10197
|
+
+ `{#6c6c80-fg}Ctrl+A{/#6c6c80-fg}=SelAll {#6c6c80-fg}Ctrl+C/X/V{/#6c6c80-fg}=Clipboard`
|
|
10198
|
+
);
|
|
10199
|
+
|
|
10200
|
+
editorBox.setContent(out);
|
|
10201
|
+
// Prevent blessed from resetting scroll position on setContent
|
|
10202
|
+
editorBox.childBase = 0;
|
|
10203
|
+
editorBox.childOffset = 0;
|
|
10204
|
+
self._safeRender();
|
|
10205
|
+
}
|
|
10206
|
+
|
|
10207
|
+
// ── Create UI ──
|
|
10208
|
+
const overlay = blessed.box({
|
|
10209
|
+
parent: screen,
|
|
10210
|
+
top: 'center', left: 'center',
|
|
10211
|
+
width: '90%', height: '90%',
|
|
10212
|
+
grabKeys: true, // prevent key leaking to elements behind overlay
|
|
10213
|
+
border: { type: 'line' },
|
|
10214
|
+
style: { fg: '#c0c0d0', bg: '#0a0a15', border: { fg: '#e056a0' }, label: { fg: '#e056a0' } },
|
|
10215
|
+
tags: true,
|
|
10216
|
+
label: data.hasCustom ? ' ★ Edit Custom System Prompt ' : ' Customize System Prompt ',
|
|
10217
|
+
});
|
|
10218
|
+
|
|
10219
|
+
const header = blessed.box({
|
|
10220
|
+
parent: overlay,
|
|
10221
|
+
top: 0, left: 1, right: 1, height: 3,
|
|
10222
|
+
style: { fg: '#a0a0b0', bg: '#0a0a15' },
|
|
10223
|
+
tags: true,
|
|
10224
|
+
});
|
|
10225
|
+
|
|
10226
|
+
const editorBox = blessed.box({
|
|
10227
|
+
parent: overlay,
|
|
10228
|
+
top: 3, left: 1, right: 1, bottom: 0,
|
|
10229
|
+
border: { type: 'line' },
|
|
10230
|
+
style: { fg: '#c0c0d0', bg: '#0f0f1a', border: { fg: '#3a3a50' } },
|
|
10231
|
+
scrollable: false, // we handle scrolling manually
|
|
10232
|
+
mouse: true,
|
|
10233
|
+
keys: true,
|
|
10234
|
+
tags: true,
|
|
10235
|
+
keyable: true,
|
|
10236
|
+
});
|
|
10237
|
+
|
|
10238
|
+
let _destroyed = false;
|
|
10239
|
+
// Block named key events (screen.key() bindings like 'key q', 'key tab') from reaching
|
|
10240
|
+
// dashboard while editor is open. We keep 'keypress' flowing so editorBox still gets input.
|
|
10241
|
+
const _origEmit = screen.emit.bind(screen);
|
|
10242
|
+
screen.emit = function(event, ...args) {
|
|
10243
|
+
if (typeof event === 'string' && event.startsWith('key ') && event !== 'keypress') return false;
|
|
10244
|
+
return _origEmit(event, ...args);
|
|
10245
|
+
};
|
|
10246
|
+
|
|
10247
|
+
const cleanup = () => {
|
|
10248
|
+
if (_destroyed) return;
|
|
10249
|
+
_destroyed = true;
|
|
10250
|
+
screen.emit = _origEmit; // restore original emit so dashboard keys work again
|
|
10251
|
+
editorBox.removeAllListeners();
|
|
10252
|
+
overlay.removeAllListeners();
|
|
10253
|
+
overlay.detach();
|
|
10254
|
+
self._editorActive = false;
|
|
10255
|
+
self._safeRender();
|
|
10256
|
+
process.nextTick(() => overlay.destroy());
|
|
10257
|
+
};
|
|
10258
|
+
|
|
10259
|
+
// ── Mouse Handling (click, drag-select) ──
|
|
10260
|
+
function mouseToPos(mouse) {
|
|
10261
|
+
const localY = mouse.y - (editorBox.atop || 0) - 1;
|
|
10262
|
+
const localX = mouse.x - (editorBox.aleft || 0) - 1;
|
|
10263
|
+
const visW = editorBox.width - 2;
|
|
10264
|
+
const vRows = buildVisualRows(visW);
|
|
10265
|
+
const vIdx = clamp(localY + E.scrollY, 0, vRows.length - 1);
|
|
10266
|
+
const vr = vRows[vIdx];
|
|
10267
|
+
const col = clamp(vr.colStart + localX, 0, E.lines[vr.logRow].length);
|
|
10268
|
+
return { row: vr.logRow, col };
|
|
10269
|
+
}
|
|
10270
|
+
|
|
10271
|
+
editorBox.on('mousedown', (mouse) => {
|
|
10272
|
+
const { row, col } = mouseToPos(mouse);
|
|
10273
|
+
const now = Date.now();
|
|
10274
|
+
if (now - E.lastClickTime < 350 && E.clickCount < 3) {
|
|
10275
|
+
E.clickCount++;
|
|
10276
|
+
} else {
|
|
10277
|
+
E.clickCount = 1;
|
|
10278
|
+
}
|
|
10279
|
+
E.lastClickTime = now;
|
|
10280
|
+
|
|
10281
|
+
if (E.clickCount === 3) {
|
|
10282
|
+
selectLine(row);
|
|
10283
|
+
E.dragging = false;
|
|
10284
|
+
} else if (E.clickCount === 2) {
|
|
10285
|
+
selectWord(row, col);
|
|
10286
|
+
E.dragging = false;
|
|
10287
|
+
} else {
|
|
10288
|
+
E.row = row; E.col = col;
|
|
10289
|
+
clearSel();
|
|
10290
|
+
E.dragging = true;
|
|
10291
|
+
}
|
|
10292
|
+
render();
|
|
10293
|
+
});
|
|
10294
|
+
|
|
10295
|
+
editorBox.on('mousemove', (mouse) => {
|
|
10296
|
+
if (!E.dragging) return;
|
|
10297
|
+
const { row, col } = mouseToPos(mouse);
|
|
10298
|
+
// Start selection from cursor if not already started
|
|
10299
|
+
if (!hasSelection()) {
|
|
10300
|
+
E.selAnchor = { row: E.row, col: E.col };
|
|
10301
|
+
}
|
|
10302
|
+
E.selEnd = { row, col };
|
|
10303
|
+
E.row = row; E.col = col;
|
|
10304
|
+
render();
|
|
10305
|
+
});
|
|
10306
|
+
|
|
10307
|
+
editorBox.on('mouseup', () => {
|
|
10308
|
+
E.dragging = false;
|
|
10309
|
+
});
|
|
10310
|
+
|
|
10311
|
+
// Scroll wheel
|
|
10312
|
+
editorBox.on('wheeldown', () => { E.scrollY = Math.min(E.scrollY + 3, Math.max(0, E.lines.length - 5)); render(); });
|
|
10313
|
+
editorBox.on('wheelup', () => { E.scrollY = Math.max(0, E.scrollY - 3); render(); });
|
|
10314
|
+
|
|
10315
|
+
// ── Keyboard Handling ──
|
|
10316
|
+
editorBox.on('keypress', (ch, key) => {
|
|
10317
|
+
if (!key) return;
|
|
10318
|
+
const ctrl = key.ctrl;
|
|
10319
|
+
const shift = key.shift;
|
|
10320
|
+
const name = key.name;
|
|
10321
|
+
|
|
10322
|
+
// ── Ctrl combos ──
|
|
10323
|
+
if (ctrl && name === 's') {
|
|
10324
|
+
const text = getText();
|
|
10325
|
+
if (!text.trim()) { self._updateStatusBar('{red-fg}Cannot save empty prompt{/red-fg}'); return; }
|
|
10326
|
+
self._proxyHttpPost('/custom-system-prompt', { prompt: text, ogHash }).then((res) => {
|
|
10327
|
+
if (res?.ok) self._updateStatusBar(`{#42d77d-fg}✓ Custom prompt saved (${text.length} chars){/#42d77d-fg}`);
|
|
10328
|
+
else self._updateStatusBar(`{red-fg}✗ Save failed: ${res?.error || 'unknown'}{/red-fg}`);
|
|
10329
|
+
cleanup();
|
|
10330
|
+
});
|
|
10331
|
+
return;
|
|
10332
|
+
}
|
|
10333
|
+
if (ctrl && name === 'r') {
|
|
10334
|
+
self._proxyHttpPost('/custom-system-prompt', { reset: true }).then((res) => {
|
|
10335
|
+
if (res?.ok) self._updateStatusBar('{#42d77d-fg}✓ System prompt reset to original{/#42d77d-fg}');
|
|
10336
|
+
cleanup();
|
|
10337
|
+
});
|
|
10338
|
+
return;
|
|
10339
|
+
}
|
|
10340
|
+
if (ctrl && name === 'a') { // Select All
|
|
10341
|
+
E.selAnchor = { row: 0, col: 0 };
|
|
10342
|
+
E.selEnd = { row: E.lines.length - 1, col: E.lines[E.lines.length - 1].length };
|
|
10343
|
+
E.row = E.selEnd.row; E.col = E.selEnd.col;
|
|
10344
|
+
render(); return;
|
|
10345
|
+
}
|
|
10346
|
+
if (ctrl && name === 'c') { // Copy
|
|
10347
|
+
if (hasSelection()) {
|
|
10348
|
+
E.clipboard = getSelectedText();
|
|
10349
|
+
_copyToSystemClipboard(E.clipboard);
|
|
10350
|
+
}
|
|
10351
|
+
render(); return;
|
|
10352
|
+
}
|
|
10353
|
+
if (ctrl && name === 'x') { // Cut
|
|
10354
|
+
if (hasSelection()) {
|
|
10355
|
+
E.clipboard = getSelectedText();
|
|
10356
|
+
_copyToSystemClipboard(E.clipboard);
|
|
10357
|
+
deleteSelection();
|
|
10358
|
+
}
|
|
10359
|
+
render(); return;
|
|
10360
|
+
}
|
|
10361
|
+
if (ctrl && name === 'v') { // Paste
|
|
10362
|
+
// Try system clipboard first, fallback to internal
|
|
10363
|
+
const sysCb = _readSystemClipboard();
|
|
10364
|
+
if (sysCb) { E.clipboard = sysCb; insertTextAt(sysCb); }
|
|
10365
|
+
else if (E.clipboard) insertTextAt(E.clipboard);
|
|
10366
|
+
render(); return;
|
|
10367
|
+
}
|
|
10368
|
+
|
|
10369
|
+
// ── Escape ──
|
|
10370
|
+
if (name === 'escape') {
|
|
10371
|
+
// Prevent event propagation before cleanup
|
|
10372
|
+
editorBox.removeAllListeners('keypress');
|
|
10373
|
+
cleanup();
|
|
10374
|
+
return;
|
|
10375
|
+
}
|
|
10376
|
+
|
|
10377
|
+
// ── Arrow keys (with shift-select) ──
|
|
10378
|
+
if (name === 'left' || name === 'right' || name === 'up' || name === 'down'
|
|
10379
|
+
|| name === 'home' || name === 'end' || name === 'pageup' || name === 'pagedown') {
|
|
10380
|
+
const prevRow = E.row, prevCol = E.col;
|
|
10381
|
+
|
|
10382
|
+
if (name === 'left') {
|
|
10383
|
+
if (E.col > 0) E.col--;
|
|
10384
|
+
else if (E.row > 0) { E.row--; E.col = E.lines[E.row].length; }
|
|
10385
|
+
} else if (name === 'right') {
|
|
10386
|
+
if (E.col < E.lines[E.row].length) E.col++;
|
|
10387
|
+
else if (E.row < E.lines.length - 1) { E.row++; E.col = 0; }
|
|
10388
|
+
} else if (name === 'up') {
|
|
10389
|
+
if (E.row > 0) { E.row--; E.col = Math.min(E.col, E.lines[E.row].length); }
|
|
10390
|
+
} else if (name === 'down') {
|
|
10391
|
+
if (E.row < E.lines.length - 1) { E.row++; E.col = Math.min(E.col, E.lines[E.row].length); }
|
|
10392
|
+
} else if (name === 'home') {
|
|
10393
|
+
E.col = 0;
|
|
10394
|
+
} else if (name === 'end') {
|
|
10395
|
+
E.col = E.lines[E.row].length;
|
|
10396
|
+
} else if (name === 'pageup') {
|
|
10397
|
+
const visH = editorBox.height - 2;
|
|
10398
|
+
E.row = Math.max(0, E.row - visH);
|
|
10399
|
+
E.col = Math.min(E.col, E.lines[E.row].length);
|
|
10400
|
+
} else if (name === 'pagedown') {
|
|
10401
|
+
const visH = editorBox.height - 2;
|
|
10402
|
+
E.row = Math.min(E.lines.length - 1, E.row + visH);
|
|
10403
|
+
E.col = Math.min(E.col, E.lines[E.row].length);
|
|
10404
|
+
}
|
|
10405
|
+
|
|
10406
|
+
if (shift) {
|
|
10407
|
+
if (!E.selAnchor) E.selAnchor = { row: prevRow, col: prevCol };
|
|
10408
|
+
E.selEnd = { row: E.row, col: E.col };
|
|
10409
|
+
} else {
|
|
10410
|
+
clearSel();
|
|
10411
|
+
}
|
|
10412
|
+
render(); return;
|
|
10413
|
+
}
|
|
10414
|
+
|
|
10415
|
+
// ── Backspace ──
|
|
10416
|
+
if (name === 'backspace') {
|
|
10417
|
+
if (hasSelection()) { deleteSelection(); }
|
|
10418
|
+
else if (E.col > 0) {
|
|
10419
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col - 1) + E.lines[E.row].slice(E.col);
|
|
10420
|
+
E.col--;
|
|
10421
|
+
E.dirty = true;
|
|
10422
|
+
} else if (E.row > 0) {
|
|
10423
|
+
E.col = E.lines[E.row - 1].length;
|
|
10424
|
+
E.lines[E.row - 1] += E.lines[E.row];
|
|
10425
|
+
E.lines.splice(E.row, 1);
|
|
10426
|
+
E.row--;
|
|
10427
|
+
E.dirty = true;
|
|
10428
|
+
}
|
|
10429
|
+
render(); return;
|
|
10430
|
+
}
|
|
10431
|
+
|
|
10432
|
+
// ── Delete ──
|
|
10433
|
+
if (name === 'delete') {
|
|
10434
|
+
if (hasSelection()) { deleteSelection(); }
|
|
10435
|
+
else if (E.col < E.lines[E.row].length) {
|
|
10436
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col) + E.lines[E.row].slice(E.col + 1);
|
|
10437
|
+
E.dirty = true;
|
|
10438
|
+
} else if (E.row < E.lines.length - 1) {
|
|
10439
|
+
E.lines[E.row] += E.lines[E.row + 1];
|
|
10440
|
+
E.lines.splice(E.row + 1, 1);
|
|
10441
|
+
E.dirty = true;
|
|
10442
|
+
}
|
|
10443
|
+
render(); return;
|
|
10444
|
+
}
|
|
10445
|
+
|
|
10446
|
+
// ── Enter ──
|
|
10447
|
+
if (name === 'enter' || name === 'return') {
|
|
10448
|
+
if (hasSelection()) deleteSelection();
|
|
10449
|
+
const after = E.lines[E.row].slice(E.col);
|
|
10450
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col);
|
|
10451
|
+
E.lines.splice(E.row + 1, 0, after);
|
|
10452
|
+
E.row++; E.col = 0;
|
|
10453
|
+
E.dirty = true;
|
|
10454
|
+
render(); return;
|
|
10455
|
+
}
|
|
10456
|
+
|
|
10457
|
+
// ── Tab ──
|
|
10458
|
+
if (name === 'tab') {
|
|
10459
|
+
if (hasSelection()) deleteSelection();
|
|
10460
|
+
insertTextAt(' ');
|
|
10461
|
+
render(); return;
|
|
10462
|
+
}
|
|
10463
|
+
|
|
10464
|
+
// ── Printable character ──
|
|
10465
|
+
if (ch && ch.length === 1 && !ctrl && !key.meta) {
|
|
10466
|
+
if (hasSelection()) deleteSelection();
|
|
10467
|
+
E.lines[E.row] = E.lines[E.row].slice(0, E.col) + ch + E.lines[E.row].slice(E.col);
|
|
10468
|
+
E.col++;
|
|
10469
|
+
E.dirty = true;
|
|
10470
|
+
render(); return;
|
|
10471
|
+
}
|
|
10472
|
+
});
|
|
10473
|
+
|
|
10474
|
+
// Prevent blessed's built-in key/scroll handling from moving content
|
|
10475
|
+
editorBox.scrollable = false;
|
|
10476
|
+
editorBox.focus();
|
|
10477
|
+
screen.on('keypress', _blockKeys);
|
|
10478
|
+
render();
|
|
10479
|
+
}
|
|
10480
|
+
|
|
9693
10481
|
_adjustProxySlider(direction) {
|
|
9694
10482
|
const def = this._proxySliderDefs[this._proxySliderIdx];
|
|
9695
10483
|
if (!def || !this._proxyConfig) return;
|