prism-mcp-server 20.0.6 → 20.0.7
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/dist/agent/agentTools.js +453 -0
- package/dist/agent/mcpBridge.js +234 -0
- package/dist/agent/platformUtils.js +470 -0
- package/dist/agent/terminalUI.js +198 -0
- package/dist/auth.js +218 -0
- package/dist/darkfactory/cloudDelegate.js +173 -0
- package/dist/plugins/pluginManager.js +199 -0
- package/dist/prism-cloud.js +110 -0
- package/dist/scm/ciPipeline.js +220 -0
- package/dist/server.js +1 -1
- package/dist/storage/inferMetricsLedger.js +159 -0
- package/dist/sync/encryptedSync.js +172 -0
- package/dist/sync/synaluxProxy.js +177 -0
- package/dist/tools/__tests__/layer1Integration.test.js +34 -0
- package/dist/tools/adaptiveDefinitions.js +148 -0
- package/dist/tools/behavioralVerifierHandler.js +0 -9
- package/dist/tools/interactiveDefinitions.js +87 -0
- package/dist/tools/interactiveHandlers.js +164 -0
- package/dist/tools/ledgerHandlers.js +40 -13
- package/dist/tools/prismInferHandler.js +48 -6
- package/dist/tools/projects.js +214 -0
- package/dist/tools/sessionMemoryDefinitions.js +12 -5
- package/dist/tools/skillRouting.js +57 -8
- package/dist/utils/changelogGenerator.js +158 -0
- package/dist/utils/fallbackClient.js +52 -0
- package/dist/utils/groundingVerifier.js +3 -3
- package/dist/utils/inferenceMetrics.js +38 -1
- package/dist/utils/memoryAttestation.js +163 -0
- package/dist/utils/rbac.js +321 -0
- package/dist/utils/skillBudget.js +58 -0
- package/dist/utils/tavilyApi.js +70 -0
- package/dist/vm/quotaEnforcer.js +192 -0
- package/package.json +1 -1
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Utilities — Cross-platform abstractions for Prism CLI
|
|
3
|
+
* ================================================================
|
|
4
|
+
*
|
|
5
|
+
* Provides OS-aware implementations for shell commands, file operations,
|
|
6
|
+
* and system tools. Supports macOS, Linux, and Windows 10+.
|
|
7
|
+
*
|
|
8
|
+
* Key design decisions:
|
|
9
|
+
* - Never hardcode paths like /tmp/ or /opt/homebrew/
|
|
10
|
+
* - Always auto-detect binary locations via PATH
|
|
11
|
+
* - Use PowerShell on Windows for complex pipelines
|
|
12
|
+
* - Provide graceful fallbacks on every platform
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
export const IS_WINDOWS = process.platform === 'win32';
|
|
18
|
+
export const IS_MAC = process.platform === 'darwin';
|
|
19
|
+
export const IS_LINUX = process.platform === 'linux';
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// ANSI / Terminal
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* Enable ANSI escape sequence processing on Windows.
|
|
25
|
+
* Windows Terminal supports ANSI natively, but legacy cmd.exe and
|
|
26
|
+
* ConHost need ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) set
|
|
27
|
+
* on the console output handle. Node.js does NOT set this by default.
|
|
28
|
+
*
|
|
29
|
+
* Call this once at CLI startup.
|
|
30
|
+
*/
|
|
31
|
+
export function enableAnsiOnWindows() {
|
|
32
|
+
if (!IS_WINDOWS)
|
|
33
|
+
return;
|
|
34
|
+
// Windows Terminal already supports ANSI — check via WT_SESSION env
|
|
35
|
+
if (process.env.WT_SESSION)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
// Use PowerShell to flip the VT processing bit on the console
|
|
39
|
+
execSync(`powershell -NoProfile -Command "` +
|
|
40
|
+
`$k = Add-Type -MemberDefinition '` +
|
|
41
|
+
`[DllImport(\\\"kernel32.dll\\\")]public static extern IntPtr GetStdHandle(int h);` +
|
|
42
|
+
`[DllImport(\\\"kernel32.dll\\\")]public static extern bool GetConsoleMode(IntPtr h,out int m);` +
|
|
43
|
+
`[DllImport(\\\"kernel32.dll\\\")]public static extern bool SetConsoleMode(IntPtr h,int m);` +
|
|
44
|
+
`' -Name K -PassThru;` +
|
|
45
|
+
`$h=[K]::GetStdHandle(-11);$m=0;[K]::GetConsoleMode($h,[ref]$m);` +
|
|
46
|
+
`[K]::SetConsoleMode($h,$m -bor 4)"`, { stdio: 'pipe', timeout: 5000 });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Silently fail — colors just won't render in legacy terminals
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Whether the terminal likely supports ANSI colors */
|
|
53
|
+
export function supportsAnsi() {
|
|
54
|
+
if (IS_WINDOWS) {
|
|
55
|
+
// Windows Terminal, VS Code terminal, or ConEmu
|
|
56
|
+
return !!(process.env.WT_SESSION || process.env.TERM_PROGRAM || process.env.ConEmuANSI);
|
|
57
|
+
}
|
|
58
|
+
return process.stdout.isTTY === true;
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Temp directory — NEVER use /tmp/ directly
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/** Get a cross-platform temp file path */
|
|
64
|
+
export function tempPath(filename) {
|
|
65
|
+
return path.join(os.tmpdir(), filename);
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Shell / Command helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** The shell to use for PowerShell commands on Windows */
|
|
71
|
+
export function windowsShell() {
|
|
72
|
+
return IS_WINDOWS ? 'powershell.exe' : undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Open a URL in the default browser */
|
|
75
|
+
export function openUrlCommand(url) {
|
|
76
|
+
if (IS_WINDOWS)
|
|
77
|
+
return `start "" "${url}"`;
|
|
78
|
+
if (IS_MAC)
|
|
79
|
+
return `open "${url}"`;
|
|
80
|
+
return `xdg-open "${url}"`; // Linux
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Locate a CLI binary on any platform.
|
|
84
|
+
* Returns the resolved path or null if not found.
|
|
85
|
+
*/
|
|
86
|
+
export function findBinary(name) {
|
|
87
|
+
try {
|
|
88
|
+
const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
|
|
89
|
+
const result = execSync(cmd, { stdio: 'pipe', timeout: 5000 }).toString().trim();
|
|
90
|
+
// `where` on Windows may return multiple lines — take the first
|
|
91
|
+
return result.split('\n')[0]?.trim() || null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a CLI tool path — tries PATH first, then common install locations.
|
|
99
|
+
* Falls back to bare command name (relies on PATH at runtime).
|
|
100
|
+
*/
|
|
101
|
+
export function resolveCli(name) {
|
|
102
|
+
// Try PATH first
|
|
103
|
+
const inPath = findBinary(name);
|
|
104
|
+
if (inPath)
|
|
105
|
+
return `"${inPath}"`;
|
|
106
|
+
// macOS Homebrew fallback locations
|
|
107
|
+
if (IS_MAC) {
|
|
108
|
+
const homebrewPaths = [
|
|
109
|
+
`/opt/homebrew/bin/${name}`,
|
|
110
|
+
`/usr/local/bin/${name}`,
|
|
111
|
+
];
|
|
112
|
+
for (const p of homebrewPaths) {
|
|
113
|
+
try {
|
|
114
|
+
execSync(`test -f "${p}"`, { stdio: 'pipe' });
|
|
115
|
+
return `"${p}"`;
|
|
116
|
+
}
|
|
117
|
+
catch { /* not found */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Windows: try common install paths
|
|
121
|
+
if (IS_WINDOWS) {
|
|
122
|
+
const winPaths = [
|
|
123
|
+
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', `${name}.cmd`),
|
|
124
|
+
path.join(os.homedir(), 'AppData', 'Local', 'Programs', name, `${name}.exe`),
|
|
125
|
+
path.join('C:', 'Program Files', name, `${name}.exe`),
|
|
126
|
+
path.join(os.homedir(), 'scoop', 'shims', `${name}.exe`),
|
|
127
|
+
];
|
|
128
|
+
for (const p of winPaths) {
|
|
129
|
+
try {
|
|
130
|
+
execSync(`if exist "${p}" echo found`, { stdio: 'pipe', shell: 'cmd.exe' });
|
|
131
|
+
return `"${p}"`;
|
|
132
|
+
}
|
|
133
|
+
catch { /* not found */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Bare name — hope it's in PATH at runtime
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// fetch_url — cross-platform HTML fetching
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
/**
|
|
143
|
+
* Build the shell command to fetch and strip HTML from a URL.
|
|
144
|
+
* Uses curl on Unix, PowerShell Invoke-WebRequest on Windows.
|
|
145
|
+
*/
|
|
146
|
+
export function fetchUrlCommand(url) {
|
|
147
|
+
const safeUrl = url.replace(/"/g, '\\"');
|
|
148
|
+
if (IS_WINDOWS) {
|
|
149
|
+
// PowerShell pipeline: strip HTML tags, collapse whitespace
|
|
150
|
+
const psUrl = url.replace(/'/g, "''");
|
|
151
|
+
return (`powershell -NoProfile -Command "& { ` +
|
|
152
|
+
`$r = Invoke-WebRequest -Uri '${psUrl}' -UseBasicParsing -TimeoutSec 15; ` +
|
|
153
|
+
`$t = $r.Content -replace '<script[^>]*>[\\s\\S]*?</script>','' ` +
|
|
154
|
+
`-replace '<style[^>]*>[\\s\\S]*?</style>','' ` +
|
|
155
|
+
`-replace '<[^>]+>','' -replace '\\s+',' '; ` +
|
|
156
|
+
`$t.Substring(0, [Math]::Min($t.Length, 8000)) }"`);
|
|
157
|
+
}
|
|
158
|
+
// macOS / Linux: curl + sed pipeline
|
|
159
|
+
const curlBase = `curl -sL --max-time 15 --max-filesize 1048576 -H "User-Agent: Mozilla/5.0" "${safeUrl}"`;
|
|
160
|
+
return (`${curlBase} | ` +
|
|
161
|
+
`sed 's/<script[^>]*>.*<\\/script>//gi' | ` +
|
|
162
|
+
`sed 's/<style[^>]*>.*<\\/style>//gi' | ` +
|
|
163
|
+
`sed 's/<[^>]*>//g' | ` +
|
|
164
|
+
`tr -s '[:space:]' '\\n' | ` +
|
|
165
|
+
`sed '/^$/d' | head -300`);
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// list_files — cross-platform directory listing
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
export function listFilesCommand(dir, maxDepth, pattern) {
|
|
171
|
+
if (IS_WINDOWS) {
|
|
172
|
+
// PowerShell: Get-ChildItem with depth and exclusions
|
|
173
|
+
const psDir = dir.replace(/'/g, "''");
|
|
174
|
+
let cmd = `powershell -NoProfile -Command "Get-ChildItem -Path '${psDir}' -Recurse -Depth ${maxDepth}`;
|
|
175
|
+
cmd += ` | Where-Object { $_.FullName -notmatch 'node_modules|\\.git' }`;
|
|
176
|
+
if (pattern) {
|
|
177
|
+
cmd += ` | Where-Object { $_.Name -like '${pattern.replace(/'/g, "''")}' }`;
|
|
178
|
+
}
|
|
179
|
+
cmd += ` | Select-Object -First 100 -ExpandProperty FullName"`;
|
|
180
|
+
return cmd;
|
|
181
|
+
}
|
|
182
|
+
// macOS / Linux: find
|
|
183
|
+
let cmd = `find "${dir}" -maxdepth ${maxDepth} -not -path '*/node_modules/*' -not -path '*/.git/*'`;
|
|
184
|
+
if (pattern) {
|
|
185
|
+
cmd += ` -name "${pattern}"`;
|
|
186
|
+
}
|
|
187
|
+
cmd += ' | head -100 | sort';
|
|
188
|
+
return cmd;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// search_files — cross-platform text search
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
export function searchFilesCommand(query, dir, maxResults, filePattern) {
|
|
194
|
+
const escapedQuery = query.replace(/"/g, '\\"');
|
|
195
|
+
// Try ripgrep first (cross-platform, fast)
|
|
196
|
+
if (findBinary('rg')) {
|
|
197
|
+
let cmd = `rg --no-heading --line-number --max-count=${maxResults}`;
|
|
198
|
+
if (filePattern)
|
|
199
|
+
cmd += ` -g "${filePattern}"`;
|
|
200
|
+
cmd += ` "${escapedQuery}" "${dir}" 2>${IS_WINDOWS ? 'NUL' : '/dev/null'}`;
|
|
201
|
+
cmd += IS_WINDOWS ? '' : ' | head -50';
|
|
202
|
+
return cmd;
|
|
203
|
+
}
|
|
204
|
+
// Windows fallback: findstr
|
|
205
|
+
if (IS_WINDOWS) {
|
|
206
|
+
return `findstr /S /N /I /C:"${escapedQuery}" "${dir}\\*${filePattern || '.*'}"`;
|
|
207
|
+
}
|
|
208
|
+
// Unix fallback: grep -r
|
|
209
|
+
let cmd = `grep -rn "${escapedQuery}" "${dir}"`;
|
|
210
|
+
if (filePattern)
|
|
211
|
+
cmd += ` --include="${filePattern}"`;
|
|
212
|
+
cmd += ' | head -50';
|
|
213
|
+
return cmd;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Multimodal — cross-platform voice/camera/TTS/clipboard
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/** TTS command: read text aloud */
|
|
219
|
+
export function ttsCommand(text, rate = 190) {
|
|
220
|
+
const safe = text.replace(/"/g, '\\"').slice(0, 3000);
|
|
221
|
+
if (IS_MAC) {
|
|
222
|
+
return `say -r ${rate} "${safe}"`;
|
|
223
|
+
}
|
|
224
|
+
if (IS_WINDOWS) {
|
|
225
|
+
// PowerShell System.Speech (built into Windows, no install needed)
|
|
226
|
+
const psText = safe.replace(/'/g, "''");
|
|
227
|
+
const psRate = Math.max(-10, Math.min(10, Math.round((rate - 150) / 25)));
|
|
228
|
+
return (`powershell -NoProfile -Command "` +
|
|
229
|
+
`Add-Type -AssemblyName System.Speech; ` +
|
|
230
|
+
`$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ` +
|
|
231
|
+
`$s.Rate = ${psRate}; ` +
|
|
232
|
+
`$s.Speak('${psText}')"`);
|
|
233
|
+
}
|
|
234
|
+
// Linux: try espeak-ng first (modern), then espeak, then spd-say
|
|
235
|
+
if (findBinary('espeak-ng')) {
|
|
236
|
+
return `espeak-ng -s ${rate} "${safe}"`;
|
|
237
|
+
}
|
|
238
|
+
if (findBinary('espeak')) {
|
|
239
|
+
return `espeak -s ${rate} "${safe}"`;
|
|
240
|
+
}
|
|
241
|
+
if (findBinary('spd-say')) {
|
|
242
|
+
return `spd-say "${safe}"`;
|
|
243
|
+
}
|
|
244
|
+
return `echo "TTS unavailable — install espeak-ng: sudo apt install espeak-ng"`;
|
|
245
|
+
}
|
|
246
|
+
/** Clipboard paste image to file path */
|
|
247
|
+
export function clipboardImageCommand(outputPath) {
|
|
248
|
+
if (IS_MAC) {
|
|
249
|
+
if (findBinary('pngpaste')) {
|
|
250
|
+
return { cmd: `pngpaste "${outputPath}"`, available: true };
|
|
251
|
+
}
|
|
252
|
+
return { cmd: '', available: false };
|
|
253
|
+
}
|
|
254
|
+
if (IS_WINDOWS) {
|
|
255
|
+
// PowerShell: save clipboard image — works on all Windows 10+
|
|
256
|
+
const psPath = outputPath.replace(/'/g, "''");
|
|
257
|
+
const ps = (`Add-Type -AssemblyName System.Windows.Forms; ` +
|
|
258
|
+
`$img = [System.Windows.Forms.Clipboard]::GetImage(); ` +
|
|
259
|
+
`if ($img) { $img.Save('${psPath}') } ` +
|
|
260
|
+
`else { throw 'No image in clipboard' }`);
|
|
261
|
+
return { cmd: `powershell -NoProfile -Command "${ps}"`, available: true };
|
|
262
|
+
}
|
|
263
|
+
// Linux: xclip → xsel → wl-paste (Wayland)
|
|
264
|
+
if (findBinary('xclip')) {
|
|
265
|
+
return { cmd: `xclip -selection clipboard -t image/png -o > "${outputPath}"`, available: true };
|
|
266
|
+
}
|
|
267
|
+
if (findBinary('wl-paste')) {
|
|
268
|
+
return { cmd: `wl-paste --type image/png > "${outputPath}"`, available: true };
|
|
269
|
+
}
|
|
270
|
+
return { cmd: '', available: false };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Camera capture command — auto-detects webcam device on each platform.
|
|
274
|
+
*
|
|
275
|
+
* macOS: imagesnap (simple, reliable)
|
|
276
|
+
* Windows: ffmpeg with DirectShow — auto-lists devices to find webcam name
|
|
277
|
+
* Linux: ffmpeg with v4l2 — uses /dev/video0 by default
|
|
278
|
+
*/
|
|
279
|
+
export function cameraCaptureCommand(outputPath) {
|
|
280
|
+
if (IS_MAC) {
|
|
281
|
+
if (findBinary('imagesnap')) {
|
|
282
|
+
return { cmd: `imagesnap -w 1 "${outputPath}"`, available: true, waitMs: 3000 };
|
|
283
|
+
}
|
|
284
|
+
// ffmpeg fallback on macOS
|
|
285
|
+
if (findBinary('ffmpeg')) {
|
|
286
|
+
return {
|
|
287
|
+
cmd: `ffmpeg -f avfoundation -framerate 30 -i "0" -frames:v 1 -y "${outputPath}" 2>/dev/null`,
|
|
288
|
+
available: true,
|
|
289
|
+
waitMs: 3000,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { cmd: '', available: false, waitMs: 0 };
|
|
293
|
+
}
|
|
294
|
+
if (IS_WINDOWS) {
|
|
295
|
+
if (findBinary('ffmpeg')) {
|
|
296
|
+
// Auto-detect webcam device name via DirectShow listing
|
|
297
|
+
let deviceName = 'Integrated Camera'; // sensible default
|
|
298
|
+
try {
|
|
299
|
+
const listOutput = execSync('ffmpeg -list_devices true -f dshow -i dummy 2>&1', { stdio: 'pipe', timeout: 5000, shell: 'cmd.exe' }).toString();
|
|
300
|
+
// Parse: DirectShow video devices — find first video device
|
|
301
|
+
const videoMatch = listOutput.match(/\] "([^"]+)"\s*\n.*Alternative name/);
|
|
302
|
+
if (videoMatch?.[1]) {
|
|
303
|
+
deviceName = videoMatch[1];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
// ffmpeg -list_devices returns exit code 1 but stderr has the data
|
|
308
|
+
const stderr = e?.stderr?.toString() || '';
|
|
309
|
+
const videoMatch = stderr.match(/\] "([^"]+)"\s*\r?\n.*Alternative name/);
|
|
310
|
+
if (videoMatch?.[1]) {
|
|
311
|
+
deviceName = videoMatch[1];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
cmd: `ffmpeg -f dshow -i video="${deviceName}" -frames:v 1 -y "${outputPath}" 2>NUL`,
|
|
316
|
+
available: true,
|
|
317
|
+
waitMs: 5000,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return { cmd: '', available: false, waitMs: 0 };
|
|
321
|
+
}
|
|
322
|
+
// Linux: ffmpeg with v4l2 — auto-detect video device
|
|
323
|
+
if (findBinary('ffmpeg')) {
|
|
324
|
+
let device = '/dev/video0';
|
|
325
|
+
try {
|
|
326
|
+
// Check if v4l2-ctl is available for better detection
|
|
327
|
+
if (findBinary('v4l2-ctl')) {
|
|
328
|
+
const devOutput = execSync('v4l2-ctl --list-devices 2>/dev/null', {
|
|
329
|
+
stdio: 'pipe',
|
|
330
|
+
timeout: 3000,
|
|
331
|
+
}).toString();
|
|
332
|
+
const devMatch = devOutput.match(/(\/dev\/video\d+)/);
|
|
333
|
+
if (devMatch?.[1])
|
|
334
|
+
device = devMatch[1];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch { /* use default */ }
|
|
338
|
+
return {
|
|
339
|
+
cmd: `ffmpeg -f v4l2 -i ${device} -frames:v 1 -y "${outputPath}" 2>/dev/null`,
|
|
340
|
+
available: true,
|
|
341
|
+
waitMs: 3000,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return { cmd: '', available: false, waitMs: 0 };
|
|
345
|
+
}
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Voice recording — cross-platform
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
/** Voice recording binary path per platform */
|
|
350
|
+
export function avlistenPath() {
|
|
351
|
+
if (IS_WINDOWS) {
|
|
352
|
+
return path.join(os.homedir(), '.cache', 'synalux', 'avlisten.exe');
|
|
353
|
+
}
|
|
354
|
+
return path.join(os.homedir(), '.cache', 'synalux', 'avlisten');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Cross-platform audio recording command.
|
|
358
|
+
* Used as a FALLBACK when avlisten is not available.
|
|
359
|
+
*
|
|
360
|
+
* Records a WAV file that can be sent to Gemini for transcription.
|
|
361
|
+
* Returns { cmd, available, outputPath }.
|
|
362
|
+
*/
|
|
363
|
+
export function audioRecordCommand(durationSec) {
|
|
364
|
+
const outPath = tempPath('prism-voice-recording.wav');
|
|
365
|
+
if (IS_MAC) {
|
|
366
|
+
// SoX (brew install sox) — most reliable
|
|
367
|
+
if (findBinary('sox')) {
|
|
368
|
+
return {
|
|
369
|
+
cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
|
|
370
|
+
available: true,
|
|
371
|
+
outputPath: outPath,
|
|
372
|
+
format: 'audio/wav',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// ffmpeg with AVFoundation
|
|
376
|
+
if (findBinary('ffmpeg')) {
|
|
377
|
+
return {
|
|
378
|
+
cmd: `ffmpeg -f avfoundation -i ":0" -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>/dev/null`,
|
|
379
|
+
available: true,
|
|
380
|
+
outputPath: outPath,
|
|
381
|
+
format: 'audio/wav',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return { cmd: '', available: false, outputPath: '', format: '' };
|
|
385
|
+
}
|
|
386
|
+
if (IS_WINDOWS) {
|
|
387
|
+
// SoX for Windows
|
|
388
|
+
if (findBinary('sox')) {
|
|
389
|
+
return {
|
|
390
|
+
cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
|
|
391
|
+
available: true,
|
|
392
|
+
outputPath: outPath,
|
|
393
|
+
format: 'audio/wav',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// ffmpeg with DirectShow audio
|
|
397
|
+
if (findBinary('ffmpeg')) {
|
|
398
|
+
// Auto-detect microphone
|
|
399
|
+
let micName = 'Microphone';
|
|
400
|
+
try {
|
|
401
|
+
const listOutput = execSync('ffmpeg -list_devices true -f dshow -i dummy 2>&1', { stdio: 'pipe', timeout: 5000, shell: 'cmd.exe' }).toString();
|
|
402
|
+
const audioMatch = listOutput.match(/DirectShow audio devices[\s\S]*?\] "([^"]+)"/);
|
|
403
|
+
if (audioMatch?.[1])
|
|
404
|
+
micName = audioMatch[1];
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
const stderr = e?.stderr?.toString() || '';
|
|
408
|
+
const audioMatch = stderr.match(/DirectShow audio devices[\s\S]*?\] "([^"]+)"/);
|
|
409
|
+
if (audioMatch?.[1])
|
|
410
|
+
micName = audioMatch[1];
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
cmd: `ffmpeg -f dshow -i audio="${micName}" -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>NUL`,
|
|
414
|
+
available: true,
|
|
415
|
+
outputPath: outPath,
|
|
416
|
+
format: 'audio/wav',
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// PowerShell + .NET AudioEndpoints as last resort
|
|
420
|
+
return {
|
|
421
|
+
cmd: `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; $r = New-Object System.Speech.Recognition.SpeechRecognitionEngine; $r.SetInputToDefaultAudioDevice(); $r.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar)); $result = $r.Recognize((New-Object TimeSpan(0,0,${durationSec}))); if($result){$result.Text}else{'(no speech detected)'}"`,
|
|
422
|
+
available: true,
|
|
423
|
+
outputPath: '', // returns text directly, not a file
|
|
424
|
+
format: 'text',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// Linux
|
|
428
|
+
// arecord (ALSA) — most common on Linux
|
|
429
|
+
if (findBinary('arecord')) {
|
|
430
|
+
return {
|
|
431
|
+
cmd: `arecord -d ${durationSec} -f cd -t wav "${outPath}" 2>/dev/null`,
|
|
432
|
+
available: true,
|
|
433
|
+
outputPath: outPath,
|
|
434
|
+
format: 'audio/wav',
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// SoX
|
|
438
|
+
if (findBinary('sox')) {
|
|
439
|
+
return {
|
|
440
|
+
cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
|
|
441
|
+
available: true,
|
|
442
|
+
outputPath: outPath,
|
|
443
|
+
format: 'audio/wav',
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
// ffmpeg PulseAudio
|
|
447
|
+
if (findBinary('ffmpeg')) {
|
|
448
|
+
return {
|
|
449
|
+
cmd: `ffmpeg -f pulse -i default -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>/dev/null`,
|
|
450
|
+
available: true,
|
|
451
|
+
outputPath: outPath,
|
|
452
|
+
format: 'audio/wav',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return { cmd: '', available: false, outputPath: '', format: '' };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Get install instructions for voice recording tools.
|
|
459
|
+
*/
|
|
460
|
+
export function voiceInstallInstructions() {
|
|
461
|
+
if (IS_MAC)
|
|
462
|
+
return 'Install SoX: brew install sox';
|
|
463
|
+
if (IS_WINDOWS)
|
|
464
|
+
return 'Install SoX: choco install sox OR winget install sox OR install ffmpeg';
|
|
465
|
+
return 'Install arecord (ALSA): sudo apt install alsa-utils OR SoX: sudo apt install sox';
|
|
466
|
+
}
|
|
467
|
+
/** Dev null path by platform */
|
|
468
|
+
export function devNull() {
|
|
469
|
+
return IS_WINDOWS ? 'NUL' : '/dev/null';
|
|
470
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI — ANSI Color Formatting for Prism Agent Terminal
|
|
3
|
+
* =============================================================
|
|
4
|
+
*
|
|
5
|
+
* Provides rich terminal output matching the Synalux VS Code extension's
|
|
6
|
+
* purple/cyan/green color scheme. Uses ANSI escape codes for colors,
|
|
7
|
+
* bold, dim, italic, and underline formatting.
|
|
8
|
+
*/
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ANSI Escape Codes
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const ESC = '\x1b[';
|
|
13
|
+
export const c = {
|
|
14
|
+
// Reset
|
|
15
|
+
reset: `${ESC}0m`,
|
|
16
|
+
// Styles
|
|
17
|
+
bold: `${ESC}1m`,
|
|
18
|
+
dim: `${ESC}2m`,
|
|
19
|
+
italic: `${ESC}3m`,
|
|
20
|
+
underline: `${ESC}4m`,
|
|
21
|
+
// Brand colors (256-color mode)
|
|
22
|
+
purple: `${ESC}38;5;141m`, // Primary brand — headers, prompts
|
|
23
|
+
cyan: `${ESC}38;5;81m`, // Tools, actions
|
|
24
|
+
green: `${ESC}38;5;114m`, // Success, code
|
|
25
|
+
yellow: `${ESC}38;5;221m`, // Warnings
|
|
26
|
+
red: `${ESC}38;5;203m`, // Errors
|
|
27
|
+
blue: `${ESC}38;5;75m`, // Info, links
|
|
28
|
+
white: `${ESC}38;5;255m`, // AI response text
|
|
29
|
+
gray: `${ESC}38;5;245m`, // Dim text, timestamps
|
|
30
|
+
orange: `${ESC}38;5;215m`, // Highlights
|
|
31
|
+
// Backgrounds
|
|
32
|
+
bgPurple: `${ESC}48;5;141m`,
|
|
33
|
+
bgCyan: `${ESC}48;5;81m`,
|
|
34
|
+
bgDim: `${ESC}48;5;236m`,
|
|
35
|
+
};
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Formatting Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/** Calculate the visible terminal width of a string (strips ANSI, accounts for double-width chars) */
|
|
40
|
+
function visibleWidth(str) {
|
|
41
|
+
// Strip ANSI escape codes
|
|
42
|
+
const stripped = str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
43
|
+
let width = 0;
|
|
44
|
+
for (const char of stripped) {
|
|
45
|
+
const cp = char.codePointAt(0) || 0;
|
|
46
|
+
// Surrogate pair / astral plane characters (emoji, symbols) = 2 columns
|
|
47
|
+
if (cp > 0xFFFF) {
|
|
48
|
+
width += 2;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
width += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return width;
|
|
55
|
+
}
|
|
56
|
+
/** Pad a visible string to a fixed column width */
|
|
57
|
+
function padLine(visible, targetWidth) {
|
|
58
|
+
const w = visibleWidth(visible);
|
|
59
|
+
const padding = Math.max(0, targetWidth - w);
|
|
60
|
+
return visible + ' '.repeat(padding);
|
|
61
|
+
}
|
|
62
|
+
/** Print the startup header — compact VS Code-style top bar */
|
|
63
|
+
export function printBanner(opts) {
|
|
64
|
+
console.log('');
|
|
65
|
+
// ─── Top bar: Prism Agent CLOUD ✨ Model 👤 user PLAN ──
|
|
66
|
+
const cloudBadge = `${c.bgCyan}${c.bold} CLOUD ${c.reset}`;
|
|
67
|
+
const modelChip = opts.model ? `${c.bgDim} ✨ ${c.bold}${opts.model} ${c.reset}` : '';
|
|
68
|
+
const userStr = opts.name || opts.email || '';
|
|
69
|
+
const userChip = userStr ? ` ${c.dim}👤${c.reset} ${userStr}` : '';
|
|
70
|
+
const planBadge = opts.plan ? ` ${c.bgPurple}${c.bold} ${opts.plan.toUpperCase()} ${c.reset}` : '';
|
|
71
|
+
console.log(` ${c.bold}${c.purple}Prism Agent${c.reset} ${cloudBadge} ${modelChip}${userChip}${planBadge}`);
|
|
72
|
+
// ─── Sub-bar: project · cwd · tools ───────────────────────
|
|
73
|
+
const cwdShort = opts.cwd.length > 30 ? '...' + opts.cwd.slice(-27) : opts.cwd;
|
|
74
|
+
const mcpStr = opts.mcpServers ? ` ${c.dim}·${c.reset} ${opts.mcpServers} MCP` : '';
|
|
75
|
+
console.log(` ${c.dim}📂 ${opts.project} · 📁 ${cwdShort} · 🔧 ${opts.toolCount} tools${mcpStr}${c.reset}`);
|
|
76
|
+
console.log('');
|
|
77
|
+
}
|
|
78
|
+
/** Build the input prompt string — clean prompt for the text input line */
|
|
79
|
+
export function buildPromptStr() {
|
|
80
|
+
return `${c.purple}${c.bold}❯${c.reset} `;
|
|
81
|
+
}
|
|
82
|
+
/** Action button definitions with keyboard shortcuts */
|
|
83
|
+
export const ACTION_BUTTONS = [
|
|
84
|
+
{ icon: '📂', label: 'Image', cmd: '/image', key: '^I' },
|
|
85
|
+
{ icon: '📎', label: 'Paste', cmd: '/paste', key: '^P' },
|
|
86
|
+
{ icon: '🎤', label: 'Voice', cmd: '/voice', key: '^V' },
|
|
87
|
+
{ icon: '💬', label: 'Speak', cmd: '/speak', key: '^S' },
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Print the action buttons bar with keyboard shortcuts underneath.
|
|
91
|
+
* Example output:
|
|
92
|
+
* 📂 Image 📎 Paste 🎤 Voice 💬 Speak
|
|
93
|
+
* ^I ^P ^V ^S
|
|
94
|
+
*/
|
|
95
|
+
export function printActionBar() {
|
|
96
|
+
// Top line: icons + labels
|
|
97
|
+
const topParts = ACTION_BUTTONS.map(b => `${c.bgDim} ${b.icon} ${c.cyan}${b.label}${c.reset}${c.bgDim} ${c.reset}`);
|
|
98
|
+
console.log(` ${topParts.join(' ')}`);
|
|
99
|
+
// Bottom line: keyboard shortcuts aligned under each button
|
|
100
|
+
const shortcutParts = ACTION_BUTTONS.map(b => {
|
|
101
|
+
// Pad shortcut to match button width: " icon label " = 2+label.length+2 = label.length+4
|
|
102
|
+
const btnWidth = b.label.length + 4; // space + emoji(2) + space + label
|
|
103
|
+
const pad = Math.max(0, Math.floor((btnWidth - b.key.length) / 2));
|
|
104
|
+
return ' '.repeat(pad) + `${c.dim}${b.key}${c.reset}` + ' '.repeat(Math.max(0, btnWidth - pad - b.key.length));
|
|
105
|
+
});
|
|
106
|
+
console.log(` ${shortcutParts.join(' ')}`);
|
|
107
|
+
console.log(` ${c.dim}Enter for menu · / + Tab for commands${c.reset}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
}
|
|
110
|
+
/** Show readline-based action menu as fallback (Enter on empty line) */
|
|
111
|
+
export function showActionMenu(rl) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
console.log(`\n ${c.bold}${c.purple}⚡ Actions${c.reset}`);
|
|
114
|
+
for (let i = 0; i < ACTION_BUTTONS.length; i++) {
|
|
115
|
+
const item = ACTION_BUTTONS[i];
|
|
116
|
+
console.log(` ${c.cyan}${c.bold}${i + 1}${c.reset} ${item.icon} ${item.label}`);
|
|
117
|
+
}
|
|
118
|
+
console.log('');
|
|
119
|
+
rl.question(` ${c.dim}Select (1-${ACTION_BUTTONS.length}) or Enter to cancel:${c.reset} `, (answer) => {
|
|
120
|
+
const num = parseInt(answer.trim(), 10);
|
|
121
|
+
if (num >= 1 && num <= ACTION_BUTTONS.length) {
|
|
122
|
+
resolve(ACTION_BUTTONS[num - 1].cmd);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
resolve(null);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Format a tool call for display */
|
|
131
|
+
export function formatToolCall(name, args) {
|
|
132
|
+
const argsStr = JSON.stringify(args);
|
|
133
|
+
const truncated = argsStr.length > 80 ? argsStr.substring(0, 77) + '...' : argsStr;
|
|
134
|
+
return ` ${c.cyan}${c.bold}⚡ ${name}${c.reset}${c.dim}(${truncated})${c.reset}`;
|
|
135
|
+
}
|
|
136
|
+
/** Format a tool result summary */
|
|
137
|
+
export function formatToolResult(name, success) {
|
|
138
|
+
return success
|
|
139
|
+
? ` ${c.green}✓${c.reset} ${c.dim}${name} completed${c.reset}`
|
|
140
|
+
: ` ${c.red}✗${c.reset} ${c.dim}${name} failed${c.reset}`;
|
|
141
|
+
}
|
|
142
|
+
/** Format an AI response */
|
|
143
|
+
export function formatResponse(text) {
|
|
144
|
+
// Highlight code blocks with green
|
|
145
|
+
let formatted = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `${c.dim}┌─ ${lang || 'code'} ──${c.reset}\n${c.green}${code.trimEnd()}${c.reset}\n${c.dim}└──────${c.reset}`);
|
|
146
|
+
// Highlight inline code with cyan
|
|
147
|
+
formatted = formatted.replace(/`([^`]+)`/g, `${c.cyan}$1${c.reset}`);
|
|
148
|
+
// Highlight bold with white bold
|
|
149
|
+
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, `${c.bold}${c.white}$1${c.reset}`);
|
|
150
|
+
return `\n${formatted}\n`;
|
|
151
|
+
}
|
|
152
|
+
/** Format an error */
|
|
153
|
+
export function formatError(message) {
|
|
154
|
+
return `\n${c.red}${c.bold}✗ Error:${c.reset} ${c.red}${message}${c.reset}\n`;
|
|
155
|
+
}
|
|
156
|
+
/** Format a success message */
|
|
157
|
+
export function formatSuccess(message) {
|
|
158
|
+
return `${c.green}✓${c.reset} ${message}`;
|
|
159
|
+
}
|
|
160
|
+
/** Format a warning */
|
|
161
|
+
export function formatWarning(message) {
|
|
162
|
+
return `${c.yellow}⚠${c.reset} ${c.yellow}${message}${c.reset}`;
|
|
163
|
+
}
|
|
164
|
+
/** Format the help menu */
|
|
165
|
+
export function printHelp() {
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(` ${c.bold}${c.purple}Commands:${c.reset}`);
|
|
168
|
+
console.log(` ${c.cyan}/image ${c.dim}<path> [question]${c.reset} — Analyze an image`);
|
|
169
|
+
console.log(` ${c.cyan}/voice ${c.dim}[seconds]${c.reset} — Record & transcribe speech`);
|
|
170
|
+
console.log(` ${c.cyan}/camera ${c.dim}[question]${c.reset} — Capture photo & analyze`);
|
|
171
|
+
console.log(` ${c.cyan}/speak${c.reset} — Toggle text-to-speech`);
|
|
172
|
+
console.log(` ${c.cyan}/paste${c.reset} — Paste clipboard image`);
|
|
173
|
+
console.log(` ${c.cyan}/search ${c.dim}<query>${c.reset} — Search Prism memory`);
|
|
174
|
+
console.log(` ${c.cyan}/todos${c.reset} — Show open TODOs`);
|
|
175
|
+
console.log(` ${c.cyan}/context${c.reset} — Show loaded context`);
|
|
176
|
+
console.log(` ${c.cyan}/tools${c.reset} — List available tools`);
|
|
177
|
+
console.log(` ${c.cyan}/exit${c.reset} — Quit`);
|
|
178
|
+
console.log('');
|
|
179
|
+
}
|
|
180
|
+
/** Format MCP connection status */
|
|
181
|
+
export function formatMcpConnect(name, tools, success) {
|
|
182
|
+
if (success) {
|
|
183
|
+
return ` ${c.green}✓${c.reset} ${c.bold}${name}${c.reset} ${c.dim}— ${tools.length} tool(s):${c.reset} ${c.cyan}${tools.join(', ')}${c.reset}`;
|
|
184
|
+
}
|
|
185
|
+
return ` ${c.red}✗${c.reset} ${c.bold}${name}${c.reset} ${c.dim}— connection failed${c.reset}`;
|
|
186
|
+
}
|
|
187
|
+
/** Format context loaded */
|
|
188
|
+
export function formatContextLoaded(todoCount, keywordCount) {
|
|
189
|
+
return formatSuccess(`Loaded ${c.bold}${todoCount}${c.reset} TODOs, ${c.bold}${keywordCount}${c.reset} keywords`);
|
|
190
|
+
}
|
|
191
|
+
/** Print thinking indicator on its own line */
|
|
192
|
+
export function printThinking() {
|
|
193
|
+
console.log(`${c.dim}${c.italic} thinking...${c.reset}`);
|
|
194
|
+
}
|
|
195
|
+
/** Clear thinking indicator — move cursor up one line and erase it */
|
|
196
|
+
export function clearThinking() {
|
|
197
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
198
|
+
}
|