mstro-app 0.1.54 → 0.1.57
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/bin/mstro.js +2 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +151 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +7 -1
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
- package/dist/server/cli/headless/stall-assessor.js +184 -0
- package/dist/server/cli/headless/stall-assessor.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +9 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +65 -5
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +32 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +8 -5
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/settings.d.ts +25 -0
- package/dist/server/services/settings.d.ts.map +1 -0
- package/dist/server/services/settings.js +72 -0
- package/dist/server/services/settings.js.map +1 -0
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
- package/dist/server/services/websocket/autocomplete.js +12 -15
- package/dist/server/services/websocket/autocomplete.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +99 -2
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +618 -157
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +38 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
- package/dist/server/services/websocket/session-registry.js +154 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server/cli/headless/RESEARCH.md +627 -0
- package/server/cli/headless/claude-invoker.ts +192 -1
- package/server/cli/headless/runner.ts +7 -1
- package/server/cli/headless/stall-assessor.ts +245 -0
- package/server/cli/headless/types.ts +9 -1
- package/server/cli/improvisation-session-manager.ts +73 -5
- package/server/index.ts +4 -1
- package/server/mcp/bouncer-integration.ts +32 -0
- package/server/services/platform.ts +8 -5
- package/server/services/settings.ts +89 -0
- package/server/services/websocket/autocomplete.ts +18 -14
- package/server/services/websocket/handler.ts +677 -170
- package/server/services/websocket/session-registry.ts +180 -0
- package/server/services/websocket/types.ts +31 -2
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { EventEmitter } from 'node:events';
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
|
|
14
15
|
import { HeadlessRunner } from './headless/index.js';
|
|
15
16
|
|
|
16
17
|
export interface ImprovisationOptions {
|
|
@@ -20,6 +21,8 @@ export interface ImprovisationOptions {
|
|
|
20
21
|
maxSessions: number;
|
|
21
22
|
verbose: boolean;
|
|
22
23
|
noColor: boolean;
|
|
24
|
+
/** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
|
|
25
|
+
model?: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
// File attachment for multimodal prompts (images)
|
|
@@ -82,12 +85,17 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
82
85
|
private isResumedSession: boolean = false; // Track if this is a resumed historical session
|
|
83
86
|
accumulatedKnowledge: string = '';
|
|
84
87
|
|
|
88
|
+
/** Whether a prompt is currently executing */
|
|
89
|
+
private _isExecuting: boolean = false;
|
|
90
|
+
/** Buffered events during current execution, for replay on reconnect */
|
|
91
|
+
private executionEventLog: Array<{ type: string; data: any; timestamp: number }> = [];
|
|
92
|
+
|
|
85
93
|
/**
|
|
86
94
|
* Resume from a historical session.
|
|
87
95
|
* Creates a new session manager that continues the conversation from a previous session.
|
|
88
96
|
* The first prompt will include context from the historical session.
|
|
89
97
|
*/
|
|
90
|
-
static resumeFromHistory(workingDir: string, historicalSessionId: string): ImprovisationSessionManager {
|
|
98
|
+
static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
|
|
91
99
|
const improviseDir = join(workingDir, '.mstro', 'improvise');
|
|
92
100
|
|
|
93
101
|
// Extract timestamp from session ID (format: improv-1234567890123 or just 1234567890123)
|
|
@@ -105,7 +113,8 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
105
113
|
// This ensures we continue writing to the same history file
|
|
106
114
|
const manager = new ImprovisationSessionManager({
|
|
107
115
|
workingDir,
|
|
108
|
-
sessionId: historyData.sessionId
|
|
116
|
+
sessionId: historyData.sessionId,
|
|
117
|
+
...overrides,
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
// Load the historical data
|
|
@@ -138,7 +147,8 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
138
147
|
tokenBudgetThreshold: options.tokenBudgetThreshold || 170000,
|
|
139
148
|
maxSessions: options.maxSessions || 10,
|
|
140
149
|
verbose: options.verbose || false,
|
|
141
|
-
noColor: options.noColor || false
|
|
150
|
+
noColor: options.noColor || false,
|
|
151
|
+
model: options.model,
|
|
142
152
|
};
|
|
143
153
|
|
|
144
154
|
this.sessionId = this.options.sessionId;
|
|
@@ -218,11 +228,31 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
218
228
|
async executePrompt(userPrompt: string, attachments?: FileAttachment[]): Promise<MovementRecord> {
|
|
219
229
|
const _execStart = Date.now();
|
|
220
230
|
|
|
231
|
+
// Start execution event log for reconnect replay
|
|
232
|
+
this._isExecuting = true;
|
|
233
|
+
this.executionEventLog = [];
|
|
234
|
+
|
|
221
235
|
this.emit('onMovementStart', this.history.movements.length + 1, userPrompt);
|
|
236
|
+
trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
|
|
237
|
+
prompt_length: userPrompt.length,
|
|
238
|
+
has_attachments: !!(attachments && attachments.length > 0),
|
|
239
|
+
attachment_count: attachments?.length || 0,
|
|
240
|
+
image_attachment_count: attachments?.filter(a => a.isImage).length || 0,
|
|
241
|
+
sequence_number: this.history.movements.length + 1,
|
|
242
|
+
is_resumed_session: this.isResumedSession,
|
|
243
|
+
model: this.options.model || 'default',
|
|
244
|
+
});
|
|
222
245
|
|
|
223
246
|
try {
|
|
224
247
|
const sequenceNumber = this.history.movements.length + 1;
|
|
225
248
|
|
|
249
|
+
// Log the movement start event
|
|
250
|
+
this.executionEventLog.push({
|
|
251
|
+
type: 'movementStart',
|
|
252
|
+
data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now() },
|
|
253
|
+
timestamp: Date.now(),
|
|
254
|
+
});
|
|
255
|
+
|
|
226
256
|
// DEBUG: Removed "Executing prompt..." message - it serves no purpose now that system responds fast
|
|
227
257
|
// this.queueOutput(`\n🎵 Executing prompt...\n`);
|
|
228
258
|
// this.flushOutputQueue();
|
|
@@ -245,19 +275,23 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
245
275
|
maxSessions: this.options.maxSessions,
|
|
246
276
|
verbose: this.options.verbose,
|
|
247
277
|
noColor: this.options.noColor,
|
|
278
|
+
model: this.options.model,
|
|
248
279
|
improvisationMode: true,
|
|
249
280
|
movementNumber: sequenceNumber,
|
|
250
281
|
continueSession: !this.isFirstPrompt, // Used as fallback only if claudeSessionId is missing
|
|
251
282
|
claudeSessionId: this.claudeSessionId, // Resume specific session for tab isolation
|
|
252
283
|
outputCallback: (text: string) => {
|
|
284
|
+
this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
|
|
253
285
|
this.queueOutput(text);
|
|
254
286
|
this.flushOutputQueue();
|
|
255
287
|
},
|
|
256
288
|
thinkingCallback: (text: string) => {
|
|
289
|
+
this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
|
|
257
290
|
this.emit('onThinking', text);
|
|
258
291
|
this.flushOutputQueue();
|
|
259
292
|
},
|
|
260
293
|
toolUseCallback: (event) => {
|
|
294
|
+
this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
|
|
261
295
|
this.emit('onToolUse', event);
|
|
262
296
|
this.flushOutputQueue();
|
|
263
297
|
},
|
|
@@ -334,14 +368,32 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
334
368
|
// this.queueOutput(`\n✓ Complete (tokens: ${result.totalTokens.toLocaleString()})\n`);
|
|
335
369
|
// this.flushOutputQueue();
|
|
336
370
|
|
|
371
|
+
this._isExecuting = false;
|
|
372
|
+
this.executionEventLog = [];
|
|
373
|
+
|
|
337
374
|
this.emit('onMovementComplete', movement);
|
|
375
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
|
|
376
|
+
tokens_used: movement.tokensUsed,
|
|
377
|
+
duration_ms: Date.now() - _execStart,
|
|
378
|
+
sequence_number: sequenceNumber,
|
|
379
|
+
tool_count: result.toolUseHistory?.length || 0,
|
|
380
|
+
model: this.options.model || 'default',
|
|
381
|
+
});
|
|
338
382
|
this.emit('onSessionUpdate', this.getHistory());
|
|
339
383
|
|
|
340
384
|
return movement;
|
|
341
385
|
|
|
342
386
|
} catch (error: any) {
|
|
387
|
+
this._isExecuting = false;
|
|
388
|
+
this.executionEventLog = [];
|
|
343
389
|
this.currentRunner = null;
|
|
344
390
|
this.emit('onMovementError', error);
|
|
391
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
|
|
392
|
+
error_message: error.message?.slice(0, 200),
|
|
393
|
+
sequence_number: this.history.movements.length + 1,
|
|
394
|
+
duration_ms: Date.now() - _execStart,
|
|
395
|
+
model: this.options.model || 'default',
|
|
396
|
+
});
|
|
345
397
|
this.queueOutput(`\n❌ Error: ${error.message}\n`);
|
|
346
398
|
this.flushOutputQueue();
|
|
347
399
|
throw error;
|
|
@@ -508,12 +560,27 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
508
560
|
};
|
|
509
561
|
}
|
|
510
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Whether a prompt is currently executing
|
|
565
|
+
*/
|
|
566
|
+
get isExecuting(): boolean {
|
|
567
|
+
return this._isExecuting;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Get buffered execution events for replay on reconnect.
|
|
572
|
+
* Only meaningful while isExecuting is true.
|
|
573
|
+
*/
|
|
574
|
+
getExecutionEventLog(): Array<{ type: string; data: any; timestamp: number }> {
|
|
575
|
+
return this.executionEventLog;
|
|
576
|
+
}
|
|
577
|
+
|
|
511
578
|
/**
|
|
512
579
|
* Start a new session with fresh context
|
|
513
580
|
* Creates a completely new session manager with isFirstPrompt=true and no claudeSessionId,
|
|
514
581
|
* ensuring the next prompt starts a fresh Claude conversation (proper tab isolation)
|
|
515
582
|
*/
|
|
516
|
-
startNewSession(): ImprovisationSessionManager {
|
|
583
|
+
startNewSession(overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
|
|
517
584
|
// Save current session
|
|
518
585
|
this.saveHistory();
|
|
519
586
|
|
|
@@ -523,7 +590,8 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
523
590
|
// This means the first prompt will start a completely fresh Claude conversation
|
|
524
591
|
const newSession = new ImprovisationSessionManager({
|
|
525
592
|
...this.options,
|
|
526
|
-
sessionId: `improv-${Date.now()}
|
|
593
|
+
sessionId: `improv-${Date.now()}`,
|
|
594
|
+
...overrides,
|
|
527
595
|
});
|
|
528
596
|
|
|
529
597
|
return newSession;
|
package/server/index.ts
CHANGED
|
@@ -348,7 +348,10 @@ async function startServer() {
|
|
|
348
348
|
console.log(`Framework: Hono`)
|
|
349
349
|
|
|
350
350
|
// Track server started event
|
|
351
|
-
trackEvent(AnalyticsEvents.SERVER_STARTED, {
|
|
351
|
+
trackEvent(AnalyticsEvents.SERVER_STARTED, {
|
|
352
|
+
port: PORT,
|
|
353
|
+
working_dir_basename: basename(WORKING_DIR),
|
|
354
|
+
})
|
|
352
355
|
|
|
353
356
|
// Create a virtual WebSocket context for platform relay
|
|
354
357
|
// This allows messages from the web (via platform) to use the same wsHandler
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { spawn } from 'node:child_process';
|
|
36
|
+
import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
|
|
36
37
|
import { captureException } from '../services/sentry.js';
|
|
37
38
|
import {
|
|
38
39
|
CRITICAL_THREATS,
|
|
@@ -275,6 +276,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
|
|
|
275
276
|
decision.reasoning,
|
|
276
277
|
{ context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-safe', latencyMs }
|
|
277
278
|
);
|
|
279
|
+
trackEvent(AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
|
|
280
|
+
layer: 'pattern-safe',
|
|
281
|
+
operation_length: operation.length,
|
|
282
|
+
threat_level: 'low',
|
|
283
|
+
confidence: 95,
|
|
284
|
+
latency_ms: latencyMs,
|
|
285
|
+
});
|
|
278
286
|
|
|
279
287
|
return decision;
|
|
280
288
|
}
|
|
@@ -302,6 +310,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
|
|
|
302
310
|
decision.reasoning,
|
|
303
311
|
{ context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-critical', latencyMs }
|
|
304
312
|
);
|
|
313
|
+
trackEvent(AnalyticsEvents.BOUNCER_TOOL_DENIED, {
|
|
314
|
+
layer: 'pattern-critical',
|
|
315
|
+
operation_length: operation.length,
|
|
316
|
+
threat_level: 'critical',
|
|
317
|
+
confidence: 99,
|
|
318
|
+
latency_ms: latencyMs,
|
|
319
|
+
});
|
|
305
320
|
|
|
306
321
|
return decision;
|
|
307
322
|
}
|
|
@@ -330,6 +345,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
|
|
|
330
345
|
decision.reasoning,
|
|
331
346
|
{ context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-default', latencyMs }
|
|
332
347
|
);
|
|
348
|
+
trackEvent(AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
|
|
349
|
+
layer: 'pattern-default',
|
|
350
|
+
operation_length: operation.length,
|
|
351
|
+
threat_level: 'low',
|
|
352
|
+
confidence: 80,
|
|
353
|
+
latency_ms: latencyMs,
|
|
354
|
+
});
|
|
333
355
|
|
|
334
356
|
return decision;
|
|
335
357
|
}
|
|
@@ -360,6 +382,9 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
|
|
|
360
382
|
}
|
|
361
383
|
|
|
362
384
|
console.error('[Bouncer] 🤖 Invoking Haiku for AI analysis...');
|
|
385
|
+
trackEvent(AnalyticsEvents.BOUNCER_HAIKU_REVIEW, {
|
|
386
|
+
operation_length: operation.length,
|
|
387
|
+
});
|
|
363
388
|
|
|
364
389
|
// Get Claude command and working directory from context or use defaults
|
|
365
390
|
const claudeCommand = process.env.CLAUDE_COMMAND || 'claude';
|
|
@@ -378,6 +403,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
|
|
|
378
403
|
decision.reasoning,
|
|
379
404
|
{ context: request.context, threatLevel: decision.threatLevel, layer: 'haiku-ai', latencyMs }
|
|
380
405
|
);
|
|
406
|
+
trackEvent(decision.decision === 'deny' ? AnalyticsEvents.BOUNCER_TOOL_DENIED : AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
|
|
407
|
+
layer: 'haiku-ai',
|
|
408
|
+
operation_length: operation.length,
|
|
409
|
+
threat_level: decision.threatLevel,
|
|
410
|
+
confidence: decision.confidence,
|
|
411
|
+
latency_ms: latencyMs,
|
|
412
|
+
});
|
|
381
413
|
|
|
382
414
|
return decision;
|
|
383
415
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
18
|
import { arch, homedir, hostname, type } from 'node:os'
|
|
19
19
|
import { basename, join } from 'node:path'
|
|
20
|
+
import { AnalyticsEvents, trackEvent } from './analytics.js'
|
|
20
21
|
import { getClientId } from './client-id.js'
|
|
21
22
|
import { captureException } from './sentry.js'
|
|
22
23
|
import { isTmuxAvailable } from './terminal/tmux-manager.js'
|
|
@@ -93,15 +94,13 @@ export function getMachineIdentifier(): string {
|
|
|
93
94
|
return `${machineHostname} @ node-${nodeVersion} ${osType} (${cpuArch})`
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
// Get WebSocket class - use global if available (Bun, Node 21+), otherwise
|
|
97
|
+
// Get WebSocket class - use global if available (Bun, Node 21+), otherwise use ws (Node 18-20)
|
|
97
98
|
let WebSocketImpl: typeof WebSocket
|
|
98
99
|
if (typeof WebSocket !== 'undefined') {
|
|
99
100
|
WebSocketImpl = WebSocket
|
|
100
101
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const { WebSocket: UndiciWS } = await import('undici')
|
|
104
|
-
WebSocketImpl = UndiciWS as unknown as typeof WebSocket
|
|
102
|
+
const { default: WS } = await import('ws')
|
|
103
|
+
WebSocketImpl = WS as unknown as typeof WebSocket
|
|
105
104
|
}
|
|
106
105
|
|
|
107
106
|
const DEFAULT_PLATFORM_URL = process.env.PLATFORM_URL || 'https://api.mstro.app'
|
|
@@ -295,6 +294,7 @@ export class PlatformConnection {
|
|
|
295
294
|
// Start periodic refresh checks
|
|
296
295
|
this.startTokenRefreshCheck()
|
|
297
296
|
this.reconnectAttempts = 0
|
|
297
|
+
trackEvent(AnalyticsEvents.PLATFORM_CONNECTED)
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
this.ws.onmessage = (event) => {
|
|
@@ -334,6 +334,7 @@ export class PlatformConnection {
|
|
|
334
334
|
|
|
335
335
|
console.log('Disconnected from platform, reconnecting...')
|
|
336
336
|
this.callbacks.onDisconnected?.()
|
|
337
|
+
trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
|
|
337
338
|
this.scheduleReconnect()
|
|
338
339
|
}
|
|
339
340
|
}
|
|
@@ -357,11 +358,13 @@ export class PlatformConnection {
|
|
|
357
358
|
case 'web_connected':
|
|
358
359
|
console.log('🔗 Web client connected')
|
|
359
360
|
this.callbacks.onWebConnected?.()
|
|
361
|
+
trackEvent(AnalyticsEvents.WEB_CLIENT_CONNECTED)
|
|
360
362
|
break
|
|
361
363
|
|
|
362
364
|
case 'web_disconnected':
|
|
363
365
|
console.log('🔗 Web client disconnected')
|
|
364
366
|
this.callbacks.onWebDisconnected?.()
|
|
367
|
+
trackEvent(AnalyticsEvents.WEB_CLIENT_DISCONNECTED)
|
|
365
368
|
break
|
|
366
369
|
|
|
367
370
|
case 'pong':
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings Service
|
|
6
|
+
*
|
|
7
|
+
* Manages persistent machine-wide settings stored in ~/.mstro/settings.json
|
|
8
|
+
*
|
|
9
|
+
* Structure:
|
|
10
|
+
* {
|
|
11
|
+
* "model": "opus"
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const MSTRO_DIR = join(homedir(), '.mstro')
|
|
20
|
+
const SETTINGS_FILE = join(MSTRO_DIR, 'settings.json')
|
|
21
|
+
|
|
22
|
+
export interface MstroSettings {
|
|
23
|
+
/**
|
|
24
|
+
* Claude model to use for main execution.
|
|
25
|
+
* - 'default' means don't pass --model (let Claude Code decide)
|
|
26
|
+
* - Any other string is passed as --model <value>
|
|
27
|
+
*/
|
|
28
|
+
model: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_SETTINGS: MstroSettings = {
|
|
32
|
+
model: 'opus'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure the ~/.mstro directory exists
|
|
37
|
+
*/
|
|
38
|
+
function ensureMstroDir(): void {
|
|
39
|
+
if (!existsSync(MSTRO_DIR)) {
|
|
40
|
+
mkdirSync(MSTRO_DIR, { recursive: true, mode: 0o700 })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get current settings, merged with defaults for any missing fields
|
|
46
|
+
*/
|
|
47
|
+
export function getSettings(): MstroSettings {
|
|
48
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
49
|
+
return { ...DEFAULT_SETTINGS }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const content = readFileSync(SETTINGS_FILE, 'utf-8')
|
|
54
|
+
const stored = JSON.parse(content)
|
|
55
|
+
return {
|
|
56
|
+
...DEFAULT_SETTINGS,
|
|
57
|
+
...stored,
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn('Failed to read settings file, using defaults:', err)
|
|
61
|
+
return { ...DEFAULT_SETTINGS }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Save full settings to disk
|
|
67
|
+
*/
|
|
68
|
+
export function saveSettings(settings: MstroSettings): void {
|
|
69
|
+
ensureMstroDir()
|
|
70
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), {
|
|
71
|
+
mode: 0o600
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the current model setting
|
|
77
|
+
*/
|
|
78
|
+
export function getModel(): string {
|
|
79
|
+
return getSettings().model
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Update just the model setting
|
|
84
|
+
*/
|
|
85
|
+
export function setModel(model: string): void {
|
|
86
|
+
const settings = getSettings()
|
|
87
|
+
settings.model = model
|
|
88
|
+
saveSettings(settings)
|
|
89
|
+
}
|
|
@@ -46,22 +46,26 @@ function compareAutocompleteResults(a: ScoredMatch, b: ScoredMatch): number {
|
|
|
46
46
|
function extractFuseMatchIndices(
|
|
47
47
|
result: FuseResult<FileMetadata>
|
|
48
48
|
): Array<[number, number]> {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
if (!result.matches) return [];
|
|
50
|
+
|
|
51
|
+
// Prefer fileName matches (95% weight) over relativePath to avoid duplicates.
|
|
52
|
+
// Using both keys produces overlapping index ranges that garble the display.
|
|
53
|
+
const fileNameMatch = result.matches.find(m => m.key === 'fileName');
|
|
54
|
+
if (fileNameMatch?.indices) {
|
|
55
|
+
const filenameStart = result.item.relativePath.lastIndexOf('/') + 1;
|
|
56
|
+
return fileNameMatch.indices.map(([start, end]) =>
|
|
57
|
+
[filenameStart + start, filenameStart + end + 1] as [number, number]
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pathMatch = result.matches.find(m => m.key === 'relativePath');
|
|
62
|
+
if (pathMatch?.indices) {
|
|
63
|
+
return pathMatch.indices.map(([start, end]) =>
|
|
64
|
+
[start, end + 1] as [number, number]
|
|
65
|
+
);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
return
|
|
68
|
+
return [];
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
function scoreFileMatch(
|