shmakk 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/shmakk.js +2 -0
- package/docs/index.html +581 -0
- package/docs/voice.md +181 -0
- package/package.json +58 -0
- package/scripts/patch-onnxruntime.js +82 -0
- package/src/agent.js +0 -0
- package/src/audit.js +18 -0
- package/src/cli.js +177 -0
- package/src/completions.js +167 -0
- package/src/control.js +250 -0
- package/src/correction.js +159 -0
- package/src/endpoints.js +52 -0
- package/src/global-doctor.js +33 -0
- package/src/global-setup.js +62 -0
- package/src/glossary.js +235 -0
- package/src/history-parser.js +166 -0
- package/src/hooks/bash.js +43 -0
- package/src/hooks/fish.js +25 -0
- package/src/hooks/index.js +14 -0
- package/src/hooks/zsh.js +42 -0
- package/src/index.js +166 -0
- package/src/llm.js +45 -0
- package/src/markers.js +113 -0
- package/src/orchestrator.js +61 -0
- package/src/profiles.js +19 -0
- package/src/prompt-cache.js +83 -0
- package/src/pty.js +107 -0
- package/src/review.js +75 -0
- package/src/safety.js +77 -0
- package/src/services/stt.js +131 -0
- package/src/services/tts.js +307 -0
- package/src/services/voice.js +362 -0
- package/src/session.js +604 -0
- package/src/setup-voice.js +108 -0
- package/src/shell.js +32 -0
- package/src/skills.js +309 -0
- package/src/subagent.js +42 -0
- package/src/system-prompt.js +261 -0
- package/src/tools.js +386 -0
- package/src/web.js +228 -0
- package/src/workspace-index.js +213 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const { parseArgs, HELP } = require('./cli');
|
|
2
|
+
const { normalizeProfile, resolveProfile } = require('./profiles');
|
|
3
|
+
const { applyEndpoint } = require('./endpoints');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
10
|
+
|
|
11
|
+
// Apply named endpoint preset from .shmakk/endpoints.json before
|
|
12
|
+
// any other module reads SHMAKK_* environment variables.
|
|
13
|
+
if (opts.endpoint) {
|
|
14
|
+
const cwd = opts.workspace || process.cwd();
|
|
15
|
+
if (!applyEndpoint(opts.endpoint, cwd)) {
|
|
16
|
+
process.stderr.write(`[shmakk] endpoint "${opts.endpoint}" not found in ${path.join(cwd, '.shmakk', 'endpoints.json')}\n`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (opts.colors !== null) {
|
|
21
|
+
const v = String(opts.colors).toLowerCase();
|
|
22
|
+
if (v !== 'true' && v !== 'false') {
|
|
23
|
+
process.stderr.write('[shmakk] invalid --colors. Use: true|false\n');
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
opts.colors = v === 'true';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (opts.help) {
|
|
30
|
+
process.stdout.write(HELP);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (opts.completion) {
|
|
35
|
+
const { generate } = require('./completions');
|
|
36
|
+
try {
|
|
37
|
+
process.stdout.write(generate(opts.completion));
|
|
38
|
+
process.exit(0);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
process.stderr.write(`[shmakk] ${e.message}\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (opts.printConfig) {
|
|
46
|
+
const profile = resolveProfile(opts.profile || process.env.SHMAKK_PROFILE);
|
|
47
|
+
const cfg = {
|
|
48
|
+
review: opts.review,
|
|
49
|
+
yesFiles: opts.yesFiles,
|
|
50
|
+
noAi: opts.noAi,
|
|
51
|
+
noCorrection: opts.noCorrection,
|
|
52
|
+
workspace: opts.workspace || process.cwd(),
|
|
53
|
+
shell: process.env.SHELL,
|
|
54
|
+
term: process.env.TERM,
|
|
55
|
+
baseUrl: process.env.SHMAKK_BASE_URL || null,
|
|
56
|
+
model: process.env.SHMAKK_MODEL || null,
|
|
57
|
+
endpoint: opts.endpoint || null,
|
|
58
|
+
profile: profile.name,
|
|
59
|
+
colors: opts.colors,
|
|
60
|
+
stt: opts.stt,
|
|
61
|
+
tts: opts.tts,
|
|
62
|
+
sts: opts.sts,
|
|
63
|
+
};
|
|
64
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + '\n');
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (opts.updateGlossary) {
|
|
69
|
+
const { updateGlossary } = require('./glossary');
|
|
70
|
+
await updateGlossary({ debug: opts.debug });
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (opts.buildHistory !== null) {
|
|
75
|
+
const hist = require('./history-parser');
|
|
76
|
+
const files = opts.buildHistory && opts.buildHistory.length
|
|
77
|
+
? opts.buildHistory
|
|
78
|
+
: hist.autoDetectHistoryFiles();
|
|
79
|
+
if (!files.length) {
|
|
80
|
+
process.stderr.write('[shmakk] no history files found. Specify paths: shmakk --build-history ~/.bash_history ...\n');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
process.stdout.write(`[shmakk] parsing ${files.length} history file(s)...\n`);
|
|
84
|
+
for (const f of files) process.stdout.write(` ${f}\n`);
|
|
85
|
+
const freqMap = hist.buildFreqMap(files);
|
|
86
|
+
const count = Object.keys(freqMap).length;
|
|
87
|
+
const total = Object.values(freqMap).reduce((a, b) => a + b, 0);
|
|
88
|
+
const saved = hist.saveFreqMap(freqMap);
|
|
89
|
+
process.stdout.write(`[shmakk] built frequency map: ${count} unique commands, ${total} total uses\n`);
|
|
90
|
+
process.stdout.write(`[shmakk] saved to: ${saved}\n`);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (opts.status || opts.stats || opts.compact || opts.loadSkill || opts.installSkill || opts.listSkills || opts.skillStatus || opts.unloadSkill || opts.resumeStatus || opts.exitNow || opts.restart || opts.reset || opts.profileSet) {
|
|
95
|
+
const ctl = require('./control');
|
|
96
|
+
if (opts.status) process.exit(ctl.status());
|
|
97
|
+
if (opts.stats) process.exit(ctl.stats());
|
|
98
|
+
if (opts.compact) process.exit(ctl.compactContext());
|
|
99
|
+
if (opts.loadSkill) process.exit(ctl.loadSkill(opts.loadSkill));
|
|
100
|
+
if (opts.installSkill) process.exit(await ctl.installSkill(opts.installSkill));
|
|
101
|
+
if (opts.listSkills) process.exit(ctl.listSkills());
|
|
102
|
+
if (opts.skillStatus) process.exit(ctl.skillStatus());
|
|
103
|
+
if (opts.unloadSkill) process.exit(ctl.unloadSkill(opts.unloadSkill));
|
|
104
|
+
if (opts.resumeStatus) process.exit(ctl.resumeStatus());
|
|
105
|
+
if (opts.exitNow) process.exit(ctl.exitParent());
|
|
106
|
+
if (opts.restart) process.exit(ctl.restartParent());
|
|
107
|
+
if (opts.reset) process.exit(ctl.resetConversation());
|
|
108
|
+
if (opts.profileSet) process.exit(ctl.setProfileAndRestart(opts.profileSet));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (opts.profile && !normalizeProfile(opts.profile)) {
|
|
112
|
+
process.stderr.write('[shmakk] invalid --profile. Use: tiny|balanced|deep|builder|large-app\n');
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (opts.unknown.length) {
|
|
117
|
+
process.stderr.write(`[shmakk] unknown args: ${opts.unknown.join(' ')}\n`);
|
|
118
|
+
process.stderr.write(HELP);
|
|
119
|
+
process.exit(2);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Lazy-init STT/TTS models if flags are set (model downloads happen on first use)
|
|
123
|
+
if (opts.stt || opts.tts || opts.sts) {
|
|
124
|
+
const initTasks = [];
|
|
125
|
+
if (opts.stt || opts.sts) {
|
|
126
|
+
const { _ensureModel } = require('./services/stt');
|
|
127
|
+
initTasks.push(
|
|
128
|
+
_ensureModel().then(() => process.stdout.write('[shmakk] STT ready (Whisper ONNX)\n')),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (opts.tts || opts.sts) {
|
|
132
|
+
const tts = require('./services/tts');
|
|
133
|
+
const voice = opts.ttsVoice || process.env.SHMAKK_TTS_VOICE || 'af_heart';
|
|
134
|
+
initTasks.push(
|
|
135
|
+
tts.listVoices().then(() => process.stdout.write(`[shmakk] TTS ready (Kokoro ONNX, voice=${voice})\n`)),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
// Don't block startup — models download in background
|
|
139
|
+
Promise.allSettled(initTasks).then((results) => {
|
|
140
|
+
for (const r of results) {
|
|
141
|
+
if (r.status === 'rejected') {
|
|
142
|
+
process.stderr.write(`[shmakk] voice init: ${r.reason?.message || r.reason}\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pre-seed SHMAKK_VOICE_* env vars from CLI flags before voice module loads.
|
|
149
|
+
// voice.js reads these at require-time; orchestrator → session → getVoiceService().
|
|
150
|
+
if (opts.voiceLanguage) process.env.SHMAKK_VOICE_LANGUAGE = opts.voiceLanguage;
|
|
151
|
+
if (opts.voiceMaxDuration) process.env.SHMAKK_VOICE_MAX_SEC = String(opts.voiceMaxDuration);
|
|
152
|
+
if (opts.voiceSilenceSec) process.env.SHMAKK_VOICE_SILENCE_SEC = opts.voiceSilenceSec;
|
|
153
|
+
if (opts.voiceSilenceThreshold) process.env.SHMAKK_VOICE_SILENCE_THRESHOLD = opts.voiceSilenceThreshold;
|
|
154
|
+
if (opts.voiceSilenceStartSec) process.env.SHMAKK_VOICE_SILENCE_START_SEC = opts.voiceSilenceStartSec;
|
|
155
|
+
if (opts.voicePadStartSec) process.env.SHMAKK_VOICE_PAD_START_SEC = opts.voicePadStartSec;
|
|
156
|
+
if (opts.ttsVoice) process.env.SHMAKK_TTS_VOICE = opts.ttsVoice;
|
|
157
|
+
|
|
158
|
+
const { start } = require('./orchestrator');
|
|
159
|
+
const exitCode = await start(opts);
|
|
160
|
+
process.exit(exitCode);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
main().catch((err) => {
|
|
164
|
+
process.stderr.write(`[shmakk] fatal: ${err && err.stack || err}\n`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
let OpenAI;
|
|
2
|
+
try { OpenAI = require('openai'); } catch { OpenAI = null; }
|
|
3
|
+
|
|
4
|
+
function parseHeaders(s) {
|
|
5
|
+
const out = {};
|
|
6
|
+
if (!s) return out;
|
|
7
|
+
for (const part of s.split(',')) {
|
|
8
|
+
const eq = part.indexOf('=');
|
|
9
|
+
if (eq === -1) continue;
|
|
10
|
+
const k = part.slice(0, eq).trim();
|
|
11
|
+
const v = part.slice(eq + 1).trim();
|
|
12
|
+
if (k) out[k] = v;
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function envForProvider() {
|
|
18
|
+
return {
|
|
19
|
+
baseURL: process.env.SHMAKK_BASE_URL,
|
|
20
|
+
apiKey: process.env.SHMAKK_API_KEY,
|
|
21
|
+
headers: process.env.SHMAKK_HEADERS,
|
|
22
|
+
model: process.env.SHMAKK_MODEL,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isConfigured() {
|
|
27
|
+
const cfg = envForProvider();
|
|
28
|
+
return !!cfg.baseURL && !!OpenAI;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeClient() {
|
|
32
|
+
if (!OpenAI) throw new Error('openai sdk not installed');
|
|
33
|
+
const cfg = envForProvider();
|
|
34
|
+
return new OpenAI({
|
|
35
|
+
baseURL: cfg.baseURL,
|
|
36
|
+
apiKey: cfg.apiKey || 'not-needed',
|
|
37
|
+
defaultHeaders: parseHeaders(cfg.headers),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function modelFor() {
|
|
42
|
+
return process.env.SHMAKK_MODEL || 'gpt-4o-mini';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { makeClient, modelFor, isConfigured };
|
package/src/markers.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// OSC marker emitted by injected shell hooks:
|
|
2
|
+
// ESC ] 6973 ; <type> ; <payload> BEL
|
|
3
|
+
// Types:
|
|
4
|
+
// B command starting payload = base64(command line)
|
|
5
|
+
// C command finished payload = exit code (decimal)
|
|
6
|
+
// D cwd update payload = base64(absolute path)
|
|
7
|
+
|
|
8
|
+
const OSC = '\x1b]6973;';
|
|
9
|
+
const BEL = '\x07';
|
|
10
|
+
const PARTIAL_KEEP = 64; // bytes of unfinished sequence we hold across chunks
|
|
11
|
+
|
|
12
|
+
function b64decode(s) {
|
|
13
|
+
try { return Buffer.from(s, 'base64').toString('utf8'); } catch { return ''; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createMarkerStream(emit) {
|
|
17
|
+
// emit(event, payload) — events: 'command', 'exit', 'cwd'
|
|
18
|
+
// returns a function: feed(buf) -> cleanedBuf (Buffer)
|
|
19
|
+
let pending = '';
|
|
20
|
+
|
|
21
|
+
return function feed(chunk) {
|
|
22
|
+
pending += chunk.toString('utf8');
|
|
23
|
+
let out = '';
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < pending.length) {
|
|
26
|
+
const start = pending.indexOf(OSC, i);
|
|
27
|
+
if (start === -1) {
|
|
28
|
+
out += pending.slice(i);
|
|
29
|
+
i = pending.length;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
out += pending.slice(i, start);
|
|
33
|
+
const end = pending.indexOf(BEL, start + OSC.length);
|
|
34
|
+
if (end === -1) {
|
|
35
|
+
// incomplete marker — keep it in pending for next chunk
|
|
36
|
+
pending = pending.slice(start);
|
|
37
|
+
return Buffer.from(out, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
const body = pending.slice(start + OSC.length, end);
|
|
40
|
+
const semi = body.indexOf(';');
|
|
41
|
+
const type = semi === -1 ? body : body.slice(0, semi);
|
|
42
|
+
const data = semi === -1 ? '' : body.slice(semi + 1);
|
|
43
|
+
switch (type) {
|
|
44
|
+
case 'B': emit('command', b64decode(data)); break;
|
|
45
|
+
case 'C': emit('exit', parseInt(data, 10)); break;
|
|
46
|
+
case 'D': emit('cwd', b64decode(data)); break;
|
|
47
|
+
}
|
|
48
|
+
i = end + 1;
|
|
49
|
+
}
|
|
50
|
+
// Possibly trailing incomplete ESC near the end without OSC prefix yet
|
|
51
|
+
const lastEsc = pending.lastIndexOf('\x1b', pending.length - 1);
|
|
52
|
+
if (lastEsc !== -1 && lastEsc >= i && (pending.length - lastEsc) < PARTIAL_KEEP &&
|
|
53
|
+
!pending.slice(lastEsc).includes(BEL)) {
|
|
54
|
+
out = out.slice(0, out.length - (pending.length - lastEsc));
|
|
55
|
+
pending = pending.slice(lastEsc);
|
|
56
|
+
return Buffer.from(out, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
pending = '';
|
|
59
|
+
return Buffer.from(out, 'utf8');
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Strip terminal-response sequences (DA, DSR, OSC color queries) from stdin
|
|
64
|
+
// before forwarding to the child PTY. These come back from the terminal in
|
|
65
|
+
// reply to queries the shell or its plugins make at startup; if they arrive
|
|
66
|
+
// after the shell is already at the prompt, they appear as typed input.
|
|
67
|
+
//
|
|
68
|
+
// Examples we strip:
|
|
69
|
+
// ESC ] N ; ... BEL (OSC with BEL term)
|
|
70
|
+
// ESC ] N ; ... ESC \ (OSC with ST term)
|
|
71
|
+
// ESC [ <digits>;<digits> R (cursor position report / DSR)
|
|
72
|
+
// ESC [ ? <digits>(;<digits>)* c (primary device attributes)
|
|
73
|
+
// ESC [ > <digits>(;<digits>)* c (secondary device attributes)
|
|
74
|
+
//
|
|
75
|
+
// User-typed bare ESC (Escape key) is preserved.
|
|
76
|
+
|
|
77
|
+
function createStdinFilter() {
|
|
78
|
+
let pending = Buffer.alloc(0);
|
|
79
|
+
const HOLD_MAX = 128;
|
|
80
|
+
return function feed(chunk) {
|
|
81
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : Buffer.from(chunk);
|
|
82
|
+
const out = [];
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < pending.length) {
|
|
85
|
+
const b = pending[i];
|
|
86
|
+
if (b !== 0x1b) { out.push(b); i++; continue; }
|
|
87
|
+
// Look at what follows ESC.
|
|
88
|
+
const tail = pending.slice(i);
|
|
89
|
+
const tailStr = tail.toString('binary');
|
|
90
|
+
// OSC: ESC ] ... (BEL | ESC \)
|
|
91
|
+
let m = /^\x1b\][^\x07\x1b]*(\x07|\x1b\\)/.exec(tailStr);
|
|
92
|
+
if (m) { i += m[0].length; continue; }
|
|
93
|
+
// CSI response: ESC [ optional ?> digits ; digits R/c
|
|
94
|
+
m = /^\x1b\[[\?>]?[\d;]*[Rc]/.exec(tailStr);
|
|
95
|
+
if (m) { i += m[0].length; continue; }
|
|
96
|
+
// Possibly incomplete — hold short tail for next chunk
|
|
97
|
+
if (tail.length < HOLD_MAX && (
|
|
98
|
+
/^\x1b\][^\x07\x1b]*$/.test(tailStr) ||
|
|
99
|
+
/^\x1b\[[\?>]?[\d;]*$/.test(tailStr) ||
|
|
100
|
+
tailStr === '\x1b' || tailStr === '\x1b['
|
|
101
|
+
)) {
|
|
102
|
+
pending = tail;
|
|
103
|
+
return Buffer.from(out);
|
|
104
|
+
}
|
|
105
|
+
// Bare ESC or unknown sequence — pass through
|
|
106
|
+
out.push(b); i++;
|
|
107
|
+
}
|
|
108
|
+
pending = Buffer.alloc(0);
|
|
109
|
+
return Buffer.from(out);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { createMarkerStream, createStdinFilter, OSC, BEL };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// shmakk orchestrator entry point.
|
|
2
|
+
// Manages signal-driven lifecycle (restart, exit, profile changes) and
|
|
3
|
+
// delegates each session to ./session.js.
|
|
4
|
+
|
|
5
|
+
const { runOneSession } = require('./session');
|
|
6
|
+
const { normalizeProfile } = require('./profiles');
|
|
7
|
+
const { profileSignalPath } = require('./control');
|
|
8
|
+
|
|
9
|
+
function isAbortError(e) {
|
|
10
|
+
return e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripAnsi(s) {
|
|
14
|
+
return String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function start(opts) {
|
|
18
|
+
let runtimeProfile = normalizeProfile(opts.profile || process.env.SHMAKK_PROFILE) || 'balanced';
|
|
19
|
+
let lastExit = 0;
|
|
20
|
+
let restartRequested = false;
|
|
21
|
+
let exitRequested = false;
|
|
22
|
+
let activeSession = null;
|
|
23
|
+
|
|
24
|
+
// Handlers persist across restarts; only installed once.
|
|
25
|
+
const onSigTerm = () => { exitRequested = true; if (activeSession) activeSession.kill(); };
|
|
26
|
+
const onSigUsr1 = () => { restartRequested = true; if (activeSession) activeSession.kill(); };
|
|
27
|
+
// SIGUSR2 = clear conversation history. Hot-replaced per session below.
|
|
28
|
+
let onSigUsr2 = () => {};
|
|
29
|
+
process.on('SIGTERM', onSigTerm);
|
|
30
|
+
process.on('SIGUSR1', onSigUsr1);
|
|
31
|
+
process.on('SIGUSR2', () => onSigUsr2());
|
|
32
|
+
|
|
33
|
+
while (true) {
|
|
34
|
+
const signalFile = profileSignalPath(process.pid);
|
|
35
|
+
try {
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
if (fs.existsSync(signalFile)) {
|
|
38
|
+
const requested = String(fs.readFileSync(signalFile, 'utf8') || '').trim().toLowerCase();
|
|
39
|
+
const normalized = normalizeProfile(requested);
|
|
40
|
+
if (normalized) runtimeProfile = normalized;
|
|
41
|
+
fs.rmSync(signalFile, { force: true });
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
lastExit = await runOneSession({ ...opts, profile: runtimeProfile }, (s, resetFn) => { activeSession = s; onSigUsr2 = resetFn; });
|
|
45
|
+
activeSession = null;
|
|
46
|
+
onSigUsr2 = () => {};
|
|
47
|
+
if (exitRequested) break;
|
|
48
|
+
if (restartRequested) {
|
|
49
|
+
restartRequested = false;
|
|
50
|
+
process.stdout.write('\r\n\x1b[36m[shmakk] restarting...\x1b[0m\r\n');
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
process.removeListener('SIGTERM', onSigTerm);
|
|
56
|
+
process.removeListener('SIGUSR1', onSigUsr1);
|
|
57
|
+
|
|
58
|
+
return lastExit;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { start };
|
package/src/profiles.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const PRESETS = {
|
|
2
|
+
tiny: { contextMode: 'tiny', maxToolIters: 10 },
|
|
3
|
+
balanced: { contextMode: 'balanced', maxToolIters: 16 },
|
|
4
|
+
deep: { contextMode: 'deep', maxToolIters: 24 },
|
|
5
|
+
builder: { contextMode: 'deep', maxToolIters: 32 },
|
|
6
|
+
'large-app': { contextMode: 'deep', maxToolIters: 32 },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeProfile(name) {
|
|
10
|
+
const n = String(name || '').toLowerCase();
|
|
11
|
+
return PRESETS[n] ? n : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveProfile(name) {
|
|
15
|
+
const key = normalizeProfile(name) || 'balanced';
|
|
16
|
+
return { name: key, ...PRESETS[key] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { PRESETS, normalizeProfile, resolveProfile };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TTL_MS = Math.max(60_000, Number(process.env.SHMAKK_PROMPT_CACHE_TTL_MS) || 6 * 60 * 60 * 1000);
|
|
6
|
+
const DEFAULT_MAX_ENTRIES = Math.max(20, Number(process.env.SHMAKK_PROMPT_CACHE_MAX_ENTRIES) || 200);
|
|
7
|
+
|
|
8
|
+
function cachePath(root) {
|
|
9
|
+
return path.join(root, '.shmakk', 'state', 'prompt-cache.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hashObj(obj) {
|
|
13
|
+
return crypto.createHash('sha256').update(JSON.stringify(obj), 'utf8').digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeMessages(messages) {
|
|
17
|
+
return (messages || []).map((m) => ({
|
|
18
|
+
role: m.role,
|
|
19
|
+
content: m.content,
|
|
20
|
+
tool_calls: m.tool_calls || undefined,
|
|
21
|
+
tool_call_id: m.tool_call_id || undefined,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeKey({ model, messages, toolChoice = 'auto' }) {
|
|
26
|
+
return hashObj({ model, toolChoice, messages: normalizeMessages(messages) });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function load(root) {
|
|
30
|
+
try {
|
|
31
|
+
const p = cachePath(root);
|
|
32
|
+
if (!fs.existsSync(p)) return { entries: {} };
|
|
33
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
34
|
+
return { entries: j.entries || {} };
|
|
35
|
+
} catch {
|
|
36
|
+
return { entries: {} };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save(root, cache) {
|
|
41
|
+
try {
|
|
42
|
+
const p = cachePath(root);
|
|
43
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
44
|
+
fs.writeFileSync(p, JSON.stringify({ entries: cache.entries || {} }, null, 2));
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function get(root, key, ttlMs = DEFAULT_TTL_MS) {
|
|
49
|
+
const c = load(root);
|
|
50
|
+
const e = c.entries[key];
|
|
51
|
+
if (!e) return null;
|
|
52
|
+
const age = Date.now() - Number(e.createdAt || 0);
|
|
53
|
+
if (!Number.isFinite(age) || age > ttlMs) {
|
|
54
|
+
delete c.entries[key];
|
|
55
|
+
save(root, c);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
e.lastHitAt = Date.now();
|
|
59
|
+
e.hits = Number(e.hits || 0) + 1;
|
|
60
|
+
c.entries[key] = e;
|
|
61
|
+
save(root, c);
|
|
62
|
+
return e;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function put(root, key, value, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
66
|
+
const c = load(root);
|
|
67
|
+
c.entries[key] = {
|
|
68
|
+
content: String(value.content || ''),
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
lastHitAt: Date.now(),
|
|
71
|
+
hits: 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const keys = Object.keys(c.entries);
|
|
75
|
+
if (keys.length > maxEntries) {
|
|
76
|
+
keys.sort((a, b) => Number(c.entries[a].lastHitAt || 0) - Number(c.entries[b].lastHitAt || 0));
|
|
77
|
+
const removeN = keys.length - maxEntries;
|
|
78
|
+
for (let i = 0; i < removeN; i++) delete c.entries[keys[i]];
|
|
79
|
+
}
|
|
80
|
+
save(root, c);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { makeKey, get, put };
|
package/src/pty.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const pty = require('node-pty');
|
|
2
|
+
const { EventEmitter } = require('events');
|
|
3
|
+
const { detectShell } = require('./shell');
|
|
4
|
+
const { configureForShell } = require('./hooks');
|
|
5
|
+
const { createMarkerStream, createStdinFilter } = require('./markers');
|
|
6
|
+
|
|
7
|
+
function getSize() {
|
|
8
|
+
return {
|
|
9
|
+
cols: process.stdout.columns || 80,
|
|
10
|
+
rows: process.stdout.rows || 24,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VOICE_HOTKEY = 0x0f; // Ctrl+O — triggers voice recording
|
|
15
|
+
|
|
16
|
+
function startSession({ debug = false, voiceEnabled = false } = {}) {
|
|
17
|
+
const shell = detectShell();
|
|
18
|
+
const cfg = configureForShell(shell.name);
|
|
19
|
+
const { cols, rows } = getSize();
|
|
20
|
+
|
|
21
|
+
if (debug) {
|
|
22
|
+
process.stderr.write(`[shmakk] shell=${shell.path} args=${cfg.args.join(' ')}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const child = pty.spawn(shell.path, cfg.args, {
|
|
26
|
+
name: process.env.TERM || 'xterm-256color',
|
|
27
|
+
cols, rows,
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
// SHMAKK_PID lets `shmakk --status/--exit/--restart` find us from
|
|
30
|
+
// inside the inner shell.
|
|
31
|
+
env: { ...process.env, SHMAKK: '1', SHMAKK_PID: String(process.pid), ...cfg.env },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const ev = new EventEmitter();
|
|
35
|
+
// Stack of stdin handlers: top of stack receives data. Null at bottom
|
|
36
|
+
// means "relay to child PTY".
|
|
37
|
+
const stdinStack = [];
|
|
38
|
+
const topHandler = () => stdinStack.length ? stdinStack[stdinStack.length - 1] : null;
|
|
39
|
+
|
|
40
|
+
const feed = createMarkerStream((type, data) => ev.emit(type, data));
|
|
41
|
+
const filterStdin = createStdinFilter();
|
|
42
|
+
|
|
43
|
+
const stdin = process.stdin;
|
|
44
|
+
const stdout = process.stdout;
|
|
45
|
+
const wasRaw = stdin.isTTY ? stdin.isRaw : false;
|
|
46
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
47
|
+
stdin.resume();
|
|
48
|
+
|
|
49
|
+
const onStdin = (data) => {
|
|
50
|
+
const h = topHandler();
|
|
51
|
+
if (h) return h(data);
|
|
52
|
+
|
|
53
|
+
// Voice hotkey detection — only when voice is enabled
|
|
54
|
+
if (voiceEnabled) {
|
|
55
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
56
|
+
if (buf.length === 1 && buf[0] === VOICE_HOTKEY) {
|
|
57
|
+
ev.emit('voice');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cleaned = filterStdin(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
63
|
+
if (cleaned.length) child.write(cleaned);
|
|
64
|
+
};
|
|
65
|
+
const onPty = (data) => {
|
|
66
|
+
const cleaned = feed(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
67
|
+
if (cleaned.length) ev.emit('output', cleaned);
|
|
68
|
+
};
|
|
69
|
+
const onResize = () => {
|
|
70
|
+
const s = getSize();
|
|
71
|
+
try { child.resize(s.cols, s.rows); } catch {}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
stdin.on('data', onStdin);
|
|
75
|
+
child.onData(onPty);
|
|
76
|
+
process.stdout.on('resize', onResize);
|
|
77
|
+
|
|
78
|
+
const exitPromise = new Promise((resolve) => {
|
|
79
|
+
child.onExit(({ exitCode, signal }) => {
|
|
80
|
+
stdin.removeListener('data', onStdin);
|
|
81
|
+
process.stdout.removeListener('resize', onResize);
|
|
82
|
+
if (stdin.isTTY) { try { stdin.setRawMode(wasRaw); } catch {} }
|
|
83
|
+
stdin.pause();
|
|
84
|
+
cfg.cleanup();
|
|
85
|
+
resolve({ exitCode: exitCode ?? 0, signal });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ev,
|
|
91
|
+
stdoutWrite: (s) => stdout.write(s),
|
|
92
|
+
childWrite: (s) => child.write(s),
|
|
93
|
+
// Push a handler; returns a release fn that pops it. Stacked so nested
|
|
94
|
+
// captures (e.g. ask() inside an AI tap) don't wipe the outer handler.
|
|
95
|
+
captureStdin(handler) {
|
|
96
|
+
stdinStack.push(handler);
|
|
97
|
+
return () => {
|
|
98
|
+
const i = stdinStack.lastIndexOf(handler);
|
|
99
|
+
if (i !== -1) stdinStack.splice(i, 1);
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
waitExit: () => exitPromise,
|
|
103
|
+
kill: () => { try { child.kill(); } catch {} },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { startSession };
|
package/src/review.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Y/n prompt with cooperative cancellation. The returned `ask` accepts an
|
|
2
|
+
// optional `{ onCancel }` callback that fires when the user hits Ctrl-C.
|
|
3
|
+
|
|
4
|
+
function makePrompter(pty, write) {
|
|
5
|
+
return function ask(question, defaultYes, { onCancel, onWhy } = {}) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const tag = defaultYes ? '[Y/n/?]' : '[y/N/?]';
|
|
8
|
+
write(`${question} ${tag} `);
|
|
9
|
+
let buf = '';
|
|
10
|
+
function finishYesNo(ans) {
|
|
11
|
+
write('\n');
|
|
12
|
+
release();
|
|
13
|
+
return resolve(ans);
|
|
14
|
+
}
|
|
15
|
+
const release = pty.captureStdin((data) => {
|
|
16
|
+
for (const ch of data.toString('utf8')) {
|
|
17
|
+
const code = ch.charCodeAt(0);
|
|
18
|
+
if (!buf && (ch === 'y' || ch === 'Y')) return finishYesNo(true);
|
|
19
|
+
if (!buf && (ch === 'n' || ch === 'N')) return finishYesNo(false);
|
|
20
|
+
if (!buf && ch === '?') {
|
|
21
|
+
write('\n');
|
|
22
|
+
if (onWhy) onWhy();
|
|
23
|
+
write(`${question} ${tag} `);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (ch === '\r' || ch === '\n') {
|
|
27
|
+
write('\n');
|
|
28
|
+
const ans = buf.trim().toLowerCase();
|
|
29
|
+
if (!ans) {
|
|
30
|
+
release();
|
|
31
|
+
return resolve(defaultYes);
|
|
32
|
+
}
|
|
33
|
+
if (ans === '?') {
|
|
34
|
+
if (onWhy) onWhy();
|
|
35
|
+
buf = '';
|
|
36
|
+
write(`${question} ${tag} `);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
release();
|
|
40
|
+
return resolve(ans === 'y' || ans === 'yes');
|
|
41
|
+
}
|
|
42
|
+
if (code === 0x7f || code === 0x08) {
|
|
43
|
+
if (buf.length) { buf = buf.slice(0, -1); write('\b \b'); }
|
|
44
|
+
} else if (code === 0x03) { // Ctrl-C
|
|
45
|
+
write('^C\n');
|
|
46
|
+
release();
|
|
47
|
+
if (onCancel) onCancel();
|
|
48
|
+
return resolve(false);
|
|
49
|
+
} else if (code >= 0x20) {
|
|
50
|
+
buf += ch;
|
|
51
|
+
write(ch);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decisionBanner({ input, decision, mode }) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('\x1b[36m── shmakk ──\x1b[0m');
|
|
63
|
+
lines.push(` input: ${input}`);
|
|
64
|
+
lines.push(` category: ${decision.category}`);
|
|
65
|
+
if (decision.proposed) lines.push(` proposed: ${decision.proposed}`);
|
|
66
|
+
lines.push(` safety: ${decision.safety}`);
|
|
67
|
+
if (decision.reason) lines.push(` reason: ${decision.reason}`);
|
|
68
|
+
if (mode === 'review') {
|
|
69
|
+
const wouldAuto = decision.safety === 'safe' && decision.category === 'command_correction';
|
|
70
|
+
lines.push(` auto-mode: ${wouldAuto ? 'would auto-run' : 'would ask confirmation'}`);
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\r\n') + '\r\n';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { makePrompter, decisionBanner };
|