mstro-app 0.1.58 → 0.3.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.
Files changed (161) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +85 -42
  4. package/bin/commands/logout.js +35 -1
  5. package/bin/commands/status.js +1 -1
  6. package/bin/mstro.js +231 -131
  7. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  8. package/dist/server/cli/headless/claude-invoker.js +550 -115
  9. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  10. package/dist/server/cli/headless/index.d.ts +2 -1
  11. package/dist/server/cli/headless/index.d.ts.map +1 -1
  12. package/dist/server/cli/headless/index.js +2 -0
  13. package/dist/server/cli/headless/index.js.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  15. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  16. package/dist/server/cli/headless/prompt-utils.js +40 -5
  17. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  18. package/dist/server/cli/headless/runner.d.ts +1 -1
  19. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  20. package/dist/server/cli/headless/runner.js +52 -7
  21. package/dist/server/cli/headless/runner.js.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
  23. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  24. package/dist/server/cli/headless/stall-assessor.js +355 -20
  25. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
  27. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  28. package/dist/server/cli/headless/tool-watchdog.js +302 -0
  29. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  30. package/dist/server/cli/headless/types.d.ts +98 -1
  31. package/dist/server/cli/headless/types.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
  33. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-session-manager.js +929 -132
  35. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  36. package/dist/server/index.js +5 -13
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  39. package/dist/server/mcp/bouncer-integration.js +18 -0
  40. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  41. package/dist/server/mcp/security-audit.d.ts +2 -2
  42. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  43. package/dist/server/mcp/security-audit.js +12 -8
  44. package/dist/server/mcp/security-audit.js.map +1 -1
  45. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  46. package/dist/server/mcp/security-patterns.js +9 -4
  47. package/dist/server/mcp/security-patterns.js.map +1 -1
  48. package/dist/server/routes/improvise.js +6 -6
  49. package/dist/server/routes/improvise.js.map +1 -1
  50. package/dist/server/services/analytics.d.ts +2 -0
  51. package/dist/server/services/analytics.d.ts.map +1 -1
  52. package/dist/server/services/analytics.js +26 -4
  53. package/dist/server/services/analytics.js.map +1 -1
  54. package/dist/server/services/platform.d.ts.map +1 -1
  55. package/dist/server/services/platform.js +17 -10
  56. package/dist/server/services/platform.js.map +1 -1
  57. package/dist/server/services/sandbox-utils.d.ts +6 -0
  58. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  59. package/dist/server/services/sandbox-utils.js +72 -0
  60. package/dist/server/services/sandbox-utils.js.map +1 -0
  61. package/dist/server/services/settings.d.ts +6 -0
  62. package/dist/server/services/settings.d.ts.map +1 -1
  63. package/dist/server/services/settings.js +21 -0
  64. package/dist/server/services/settings.js.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.d.ts +5 -51
  66. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  67. package/dist/server/services/terminal/pty-manager.js +63 -102
  68. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  69. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  70. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  71. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  72. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  73. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  74. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  75. package/dist/server/services/websocket/git-handlers.js +797 -0
  76. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  77. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  78. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  79. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  80. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  81. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  82. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  83. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  84. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  85. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  87. package/dist/server/services/websocket/handler-context.js +4 -0
  88. package/dist/server/services/websocket/handler-context.js.map +1 -0
  89. package/dist/server/services/websocket/handler.d.ts +27 -338
  90. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  91. package/dist/server/services/websocket/handler.js +74 -2106
  92. package/dist/server/services/websocket/handler.js.map +1 -1
  93. package/dist/server/services/websocket/index.d.ts +1 -1
  94. package/dist/server/services/websocket/index.d.ts.map +1 -1
  95. package/dist/server/services/websocket/index.js.map +1 -1
  96. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  97. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  98. package/dist/server/services/websocket/session-handlers.js +507 -0
  99. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  100. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  101. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  102. package/dist/server/services/websocket/settings-handlers.js +125 -0
  103. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  104. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  105. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  106. package/dist/server/services/websocket/tab-handlers.js +131 -0
  107. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  108. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  109. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  110. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  111. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  112. package/dist/server/services/websocket/types.d.ts +67 -2
  113. package/dist/server/services/websocket/types.d.ts.map +1 -1
  114. package/hooks/bouncer.sh +11 -4
  115. package/package.json +7 -2
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +740 -133
  118. package/server/cli/headless/index.ts +7 -1
  119. package/server/cli/headless/output-utils.test.ts +225 -0
  120. package/server/cli/headless/prompt-utils.ts +37 -5
  121. package/server/cli/headless/runner.ts +55 -8
  122. package/server/cli/headless/stall-assessor.test.ts +165 -0
  123. package/server/cli/headless/stall-assessor.ts +478 -22
  124. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  125. package/server/cli/headless/tool-watchdog.ts +398 -0
  126. package/server/cli/headless/types.ts +93 -1
  127. package/server/cli/improvisation-session-manager.ts +1133 -145
  128. package/server/index.ts +5 -14
  129. package/server/mcp/README.md +59 -67
  130. package/server/mcp/bouncer-integration.test.ts +161 -0
  131. package/server/mcp/bouncer-integration.ts +28 -0
  132. package/server/mcp/security-audit.ts +12 -8
  133. package/server/mcp/security-patterns.test.ts +258 -0
  134. package/server/mcp/security-patterns.ts +8 -2
  135. package/server/routes/improvise.ts +6 -6
  136. package/server/services/analytics.ts +26 -4
  137. package/server/services/platform.test.ts +0 -10
  138. package/server/services/platform.ts +16 -11
  139. package/server/services/sandbox-utils.ts +78 -0
  140. package/server/services/settings.ts +25 -0
  141. package/server/services/terminal/pty-manager.ts +68 -129
  142. package/server/services/websocket/autocomplete.test.ts +194 -0
  143. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  144. package/server/services/websocket/git-handlers.ts +924 -0
  145. package/server/services/websocket/git-pr-handlers.ts +363 -0
  146. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  147. package/server/services/websocket/handler-context.ts +44 -0
  148. package/server/services/websocket/handler.test.ts +1 -1
  149. package/server/services/websocket/handler.ts +90 -2421
  150. package/server/services/websocket/index.ts +1 -1
  151. package/server/services/websocket/session-handlers.ts +574 -0
  152. package/server/services/websocket/settings-handlers.ts +150 -0
  153. package/server/services/websocket/tab-handlers.ts +150 -0
  154. package/server/services/websocket/terminal-handlers.ts +277 -0
  155. package/server/services/websocket/types.ts +145 -4
  156. package/bin/release.sh +0 -110
  157. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  158. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  159. package/dist/server/services/terminal/tmux-manager.js +0 -352
  160. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  161. package/server/services/terminal/tmux-manager.ts +0 -426
@@ -12,15 +12,13 @@
12
12
  * - Scrollback buffer is maintained for replay on reconnect
13
13
  * - Sessions can be reattached without losing running processes
14
14
  *
15
- * Also supports tmux-backed persistence for sessions that survive server restarts.
16
- *
17
15
  * NOTE: node-pty is an optional dependency requiring native compilation.
18
16
  * Terminal features gracefully degrade when node-pty is not available.
19
17
  */
20
18
 
21
19
  import { EventEmitter } from 'node:events';
22
20
  import { homedir, platform } from 'node:os';
23
- import { getTmuxManager, isTmuxAvailable, type TmuxSession } from './tmux-manager.js';
21
+ import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
24
22
 
25
23
  // Try to load node-pty (optional native dependency)
26
24
  let pty: typeof import('node-pty') | null = null;
@@ -74,11 +72,6 @@ export function getPtyInstallInstructions(): string {
74
72
  return instructions;
75
73
  }
76
74
 
77
- // Maximum lines to store in scrollback buffer per terminal
78
- const MAX_SCROLLBACK_LINES = 5000;
79
- // Maximum characters per line to prevent memory bloat
80
- const MAX_LINE_LENGTH = 2000;
81
-
82
75
  // Import type separately for type-checking (doesn't require the module to load)
83
76
  type IPty = import('node-pty').IPty;
84
77
 
@@ -87,8 +80,6 @@ export interface PTYSession {
87
80
  pty: IPty;
88
81
  shell: string;
89
82
  cwd: string;
90
- // Scrollback buffer for replay on reconnect
91
- scrollback: string[];
92
83
  // Timestamp when session was created
93
84
  createdAt: number;
94
85
  // Last activity timestamp
@@ -96,6 +87,9 @@ export interface PTYSession {
96
87
  // Current dimensions
97
88
  cols: number;
98
89
  rows: number;
90
+ // Output coalescing: buffer small chunks into fewer WS messages
91
+ _outputBuffer: string;
92
+ _outputTimer: ReturnType<typeof setTimeout> | null;
99
93
  }
100
94
 
101
95
  /**
@@ -156,41 +150,6 @@ export class PTYManager extends EventEmitter {
156
150
  };
157
151
  }
158
152
 
159
- /**
160
- * Get scrollback buffer for replay on reconnect
161
- * Returns the stored output history
162
- */
163
- getScrollback(terminalId: string): string[] {
164
- const session = this.terminals.get(terminalId);
165
- if (!session) return [];
166
- return [...session.scrollback];
167
- }
168
-
169
- /**
170
- * Add data to scrollback buffer
171
- * Maintains a rolling buffer of recent terminal output
172
- */
173
- private addToScrollback(session: PTYSession, data: string): void {
174
- // Split data into lines
175
- const lines = data.split(/\r?\n/);
176
-
177
- for (const line of lines) {
178
- // Truncate very long lines to prevent memory issues
179
- const truncatedLine = line.length > MAX_LINE_LENGTH
180
- ? `${line.slice(0, MAX_LINE_LENGTH)}...`
181
- : line;
182
-
183
- session.scrollback.push(truncatedLine);
184
- }
185
-
186
- // Trim buffer if it exceeds max size
187
- if (session.scrollback.length > MAX_SCROLLBACK_LINES) {
188
- session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_LINES);
189
- }
190
-
191
- session.lastActivityAt = Date.now();
192
- }
193
-
194
153
  /**
195
154
  * Check if PTY functionality is available
196
155
  */
@@ -213,7 +172,8 @@ export class PTYManager extends EventEmitter {
213
172
  workingDir: string,
214
173
  cols: number = 80,
215
174
  rows: number = 24,
216
- requestedShell?: string
175
+ requestedShell?: string,
176
+ options?: { sandboxed?: boolean }
217
177
  ): { shell: string; cwd: string; isReconnect: boolean } {
218
178
  // Check if node-pty is available
219
179
  if (!pty) {
@@ -242,43 +202,83 @@ export class PTYManager extends EventEmitter {
242
202
 
243
203
 
244
204
  try {
205
+ // Build env: sandboxed sessions get stripped secrets and HOME=projectDir
206
+ const baseEnv = options?.sandboxed
207
+ ? sanitizeEnvForSandbox(process.env, cwd)
208
+ : { ...process.env, HOME: homedir() };
209
+ const env = {
210
+ ...baseEnv,
211
+ TERM: 'xterm-256color',
212
+ COLORTERM: 'truecolor',
213
+ };
214
+
245
215
  // Spawn the PTY process
246
216
  const ptyProcess = pty.spawn(shell, [], {
247
217
  name: 'xterm-256color',
248
218
  cols,
249
219
  rows,
250
220
  cwd,
251
- env: {
252
- ...process.env,
253
- TERM: 'xterm-256color',
254
- COLORTERM: 'truecolor',
255
- // Ensure home directory is set
256
- HOME: homedir(),
257
- },
221
+ env,
258
222
  });
259
223
 
260
- // Store the session with scrollback buffer
261
224
  const session: PTYSession = {
262
225
  id: terminalId,
263
226
  pty: ptyProcess,
264
227
  shell: getShellName(shell),
265
228
  cwd,
266
- scrollback: [],
267
229
  createdAt: Date.now(),
268
230
  lastActivityAt: Date.now(),
269
231
  cols,
270
232
  rows,
233
+ _outputBuffer: '',
234
+ _outputTimer: null,
271
235
  };
272
236
  this.terminals.set(terminalId, session);
273
237
 
274
- // Handle data output - store in scrollback and emit
238
+ // Handle data output coalesce small chunks to reduce WebSocket message count.
239
+ // On macOS, node-pty emits many tiny chunks (sometimes single bytes) and zsh
240
+ // wraps echoed chars in multi-part ANSI sequences (RPROMPT, syntax highlighting).
241
+ // A longer window on macOS ensures these multi-part sequences arrive as one chunk,
242
+ // which the browser's predictive echo can match correctly.
243
+ const OUTPUT_COALESCE_MS = platform() === 'darwin' ? 16 : 8;
244
+ // High-water mark: flush immediately when buffer exceeds this size
245
+ // to prevent unbounded memory growth during high-output commands (e.g. `yes`)
246
+ const OUTPUT_HIGH_WATER = 64 * 1024; // 64KB
247
+ // Maximum chunk size per WebSocket message to prevent browser overload
248
+ const OUTPUT_CHUNK_SIZE = 64 * 1024;
249
+
250
+ const flushOutputBuffer = () => {
251
+ if (session._outputTimer) {
252
+ clearTimeout(session._outputTimer);
253
+ session._outputTimer = null;
254
+ }
255
+ const buffered = session._outputBuffer;
256
+ session._outputBuffer = '';
257
+ // Chunk large output to prevent single massive WebSocket frames
258
+ for (let i = 0; i < buffered.length; i += OUTPUT_CHUNK_SIZE) {
259
+ this.emit('output', terminalId, buffered.slice(i, i + OUTPUT_CHUNK_SIZE));
260
+ }
261
+ };
262
+
275
263
  ptyProcess.onData((data: string) => {
276
- this.addToScrollback(session, data);
277
- this.emit('output', terminalId, data);
264
+ session.lastActivityAt = Date.now();
265
+ session._outputBuffer += data;
266
+ // Flush immediately if buffer exceeds high-water mark
267
+ if (session._outputBuffer.length >= OUTPUT_HIGH_WATER) {
268
+ flushOutputBuffer();
269
+ } else if (!session._outputTimer) {
270
+ session._outputTimer = setTimeout(flushOutputBuffer, OUTPUT_COALESCE_MS);
271
+ }
278
272
  });
279
273
 
280
- // Handle exit
274
+ // Handle exit — flush any buffered output first
281
275
  ptyProcess.onExit(({ exitCode }) => {
276
+ if (session._outputBuffer) {
277
+ flushOutputBuffer();
278
+ } else if (session._outputTimer) {
279
+ clearTimeout(session._outputTimer);
280
+ session._outputTimer = null;
281
+ }
282
282
  this.emit('exit', terminalId, exitCode);
283
283
  this.terminals.delete(terminalId);
284
284
  });
@@ -341,6 +341,15 @@ export class PTYManager extends EventEmitter {
341
341
 
342
342
 
343
343
  try {
344
+ // Flush any coalesced output before closing
345
+ if (session._outputTimer) {
346
+ clearTimeout(session._outputTimer);
347
+ if (session._outputBuffer) {
348
+ this.emit('output', terminalId, session._outputBuffer);
349
+ session._outputBuffer = '';
350
+ }
351
+ session._outputTimer = null;
352
+ }
344
353
  session.pty.kill();
345
354
  this.terminals.delete(terminalId);
346
355
  return true;
@@ -381,76 +390,6 @@ export class PTYManager extends EventEmitter {
381
390
  }
382
391
  }
383
392
 
384
- /**
385
- * Check if tmux persistence is available
386
- */
387
- isTmuxAvailable(): boolean {
388
- return isTmuxAvailable();
389
- }
390
-
391
- /**
392
- * Get list of persistent tmux sessions that can be restored
393
- * These are sessions that survived a server restart
394
- */
395
- getPersistentSessions(): TmuxSession[] {
396
- const tmux = getTmuxManager();
397
- return tmux.getActiveSessions();
398
- }
399
-
400
- /**
401
- * Create a persistent (tmux-backed) terminal session
402
- * These sessions survive server restarts
403
- */
404
- createPersistent(
405
- terminalId: string,
406
- workingDir: string,
407
- cols: number = 80,
408
- rows: number = 24,
409
- requestedShell?: string
410
- ): { shell: string; cwd: string; isReconnect: boolean; persistent: true } {
411
- const tmux = getTmuxManager();
412
-
413
- if (!tmux.isAvailable()) {
414
- throw new Error('tmux is not available for persistent sessions');
415
- }
416
-
417
- const result = tmux.create(terminalId, workingDir, cols, rows, requestedShell);
418
- return { ...result, persistent: true };
419
- }
420
-
421
- /**
422
- * Attach to a persistent (tmux) session
423
- * Returns handlers for write, resize, and detach
424
- */
425
- attachPersistent(
426
- terminalId: string,
427
- onOutput: (data: string) => void,
428
- onExit: (code: number) => void
429
- ): { write: (data: string) => void; resize: (cols: number, rows: number) => void; detach: () => void } | null {
430
- const tmux = getTmuxManager();
431
-
432
- if (!tmux.exists(terminalId)) {
433
- return null;
434
- }
435
-
436
- return tmux.attach(terminalId, onOutput, onExit);
437
- }
438
-
439
- /**
440
- * Get scrollback from a persistent (tmux) session
441
- */
442
- getPersistentScrollback(terminalId: string): string[] {
443
- const tmux = getTmuxManager();
444
- return tmux.getScrollback(terminalId);
445
- }
446
-
447
- /**
448
- * Close a persistent (tmux) session
449
- */
450
- closePersistent(terminalId: string): boolean {
451
- const tmux = getTmuxManager();
452
- return tmux.close(terminalId);
453
- }
454
393
  }
455
394
 
456
395
  // Singleton instance
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AutocompleteService } from './autocomplete.js';
3
+
4
+ // Mock file system operations to avoid hitting real FS
5
+ vi.mock('node:fs', () => ({
6
+ existsSync: vi.fn(() => false),
7
+ readdirSync: vi.fn(() => []),
8
+ statSync: vi.fn(() => ({ isDirectory: () => false })),
9
+ }));
10
+
11
+ vi.mock('./file-utils.js', () => ({
12
+ CACHE_TTL_MS: 5000,
13
+ directoryCache: new Map(),
14
+ getFileType: vi.fn((path: string) => {
15
+ const ext = path.split('.').pop() || '';
16
+ return ext || 'unknown';
17
+ }),
18
+ isIgnored: vi.fn(() => false),
19
+ parseGitignore: vi.fn(() => []),
20
+ scanDirectoryRecursiveWithDepth: vi.fn(() => []),
21
+ }));
22
+
23
+ describe('AutocompleteService', () => {
24
+ // ========== Frecency ==========
25
+
26
+ describe('calculateFrecencyScore', () => {
27
+ it('returns 0 for unknown files', () => {
28
+ const svc = new AutocompleteService();
29
+ expect(svc.calculateFrecencyScore('nonexistent.ts')).toBe(0);
30
+ });
31
+
32
+ it('returns positive score for recently used files', () => {
33
+ const svc = new AutocompleteService();
34
+ svc.recordFileSelection('src/index.ts');
35
+ const score = svc.calculateFrecencyScore('src/index.ts');
36
+ expect(score).toBeGreaterThan(0);
37
+ });
38
+
39
+ it('increases score with more selections', () => {
40
+ const svc = new AutocompleteService();
41
+ svc.recordFileSelection('src/index.ts');
42
+ const score1 = svc.calculateFrecencyScore('src/index.ts');
43
+
44
+ svc.recordFileSelection('src/index.ts');
45
+ svc.recordFileSelection('src/index.ts');
46
+ const score3 = svc.calculateFrecencyScore('src/index.ts');
47
+
48
+ expect(score3).toBeGreaterThan(score1);
49
+ });
50
+
51
+ it('decays score over time', () => {
52
+ const svc = new AutocompleteService();
53
+
54
+ // Record selection at a specific time
55
+ const now = Date.now();
56
+ vi.spyOn(Date, 'now').mockReturnValue(now);
57
+ svc.recordFileSelection('src/index.ts');
58
+ const recentScore = svc.calculateFrecencyScore('src/index.ts');
59
+
60
+ // Move forward 8 days (past the 7-day recency window)
61
+ vi.spyOn(Date, 'now').mockReturnValue(now + 8 * 24 * 60 * 60 * 1000);
62
+ const staleScore = svc.calculateFrecencyScore('src/index.ts');
63
+
64
+ expect(staleScore).toBeLessThan(recentScore);
65
+ vi.restoreAllMocks();
66
+ });
67
+
68
+ it('handles initial frecency data in constructor', () => {
69
+ const svc = new AutocompleteService({
70
+ 'src/main.ts': { count: 5, lastUsed: Date.now() },
71
+ });
72
+ expect(svc.calculateFrecencyScore('src/main.ts')).toBeGreaterThan(0);
73
+ });
74
+ });
75
+
76
+ // ========== recordFileSelection ==========
77
+
78
+ describe('recordFileSelection', () => {
79
+ it('creates new entry for first selection', () => {
80
+ const svc = new AutocompleteService();
81
+ svc.recordFileSelection('new-file.ts');
82
+
83
+ const data = svc.getFrecencyData();
84
+ expect(data['new-file.ts']).toBeDefined();
85
+ expect(data['new-file.ts'].count).toBe(1);
86
+ });
87
+
88
+ it('increments count for existing entry', () => {
89
+ const svc = new AutocompleteService();
90
+ svc.recordFileSelection('file.ts');
91
+ svc.recordFileSelection('file.ts');
92
+ svc.recordFileSelection('file.ts');
93
+
94
+ const data = svc.getFrecencyData();
95
+ expect(data['file.ts'].count).toBe(3);
96
+ });
97
+
98
+ it('updates lastUsed timestamp', () => {
99
+ const svc = new AutocompleteService();
100
+ const before = Date.now();
101
+ svc.recordFileSelection('file.ts');
102
+ const data = svc.getFrecencyData();
103
+ expect(data['file.ts'].lastUsed).toBeGreaterThanOrEqual(before);
104
+ });
105
+ });
106
+
107
+ // ========== setFrecencyData / getFrecencyData ==========
108
+
109
+ describe('setFrecencyData / getFrecencyData', () => {
110
+ it('replaces frecency data', () => {
111
+ const svc = new AutocompleteService();
112
+ svc.recordFileSelection('old.ts');
113
+
114
+ const newData = {
115
+ 'new.ts': { count: 10, lastUsed: Date.now() },
116
+ };
117
+ svc.setFrecencyData(newData);
118
+
119
+ expect(svc.getFrecencyData()).toBe(newData);
120
+ expect(svc.calculateFrecencyScore('old.ts')).toBe(0);
121
+ expect(svc.calculateFrecencyScore('new.ts')).toBeGreaterThan(0);
122
+ });
123
+ });
124
+
125
+ // ========== getFileCompletions ==========
126
+
127
+ describe('getFileCompletions', () => {
128
+ it('returns empty array when no files match', () => {
129
+ const svc = new AutocompleteService();
130
+ const results = svc.getFileCompletions('nonexistent', '/tmp/test');
131
+ expect(results).toEqual([]);
132
+ });
133
+
134
+ it('handles @ symbol prefix', () => {
135
+ const svc = new AutocompleteService();
136
+ // Should not throw when handling @ prefix
137
+ const results = svc.getFileCompletions('@src/index', '/tmp/test');
138
+ expect(Array.isArray(results)).toBe(true);
139
+ });
140
+
141
+ it('returns empty array on error', () => {
142
+ const svc = new AutocompleteService();
143
+ // Invalid working dir should return empty (caught by try/catch)
144
+ const results = svc.getFileCompletions('test', '/nonexistent/path');
145
+ expect(results).toEqual([]);
146
+ });
147
+
148
+ it('limits results to 15', () => {
149
+ // This is tested structurally — the code slices to 15
150
+ const svc = new AutocompleteService();
151
+ const results = svc.getFileCompletions('', '/tmp/test');
152
+ expect(results.length).toBeLessThanOrEqual(15);
153
+ });
154
+ });
155
+
156
+ // ========== Scoring logic (frecency weight formula) ==========
157
+
158
+ describe('frecency scoring formula', () => {
159
+ it('uses log2 for frequency weight', () => {
160
+ const svc = new AutocompleteService();
161
+
162
+ // count=1: log2(2) = 1
163
+ svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: Date.now() } });
164
+ const score1 = svc.calculateFrecencyScore('a.ts');
165
+
166
+ // count=7: log2(8) = 3
167
+ svc.setFrecencyData({ 'a.ts': { count: 7, lastUsed: Date.now() } });
168
+ const score7 = svc.calculateFrecencyScore('a.ts');
169
+
170
+ // Score should roughly triple (3x) since frequency goes from 1 to 3
171
+ expect(score7 / score1).toBeCloseTo(3, 0);
172
+ });
173
+
174
+ it('recency weight is ~1.0 for very recent files', () => {
175
+ const svc = new AutocompleteService();
176
+ svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: Date.now() } });
177
+ const score = svc.calculateFrecencyScore('a.ts');
178
+
179
+ // With recencyWeight ≈ 1, score ≈ log2(2) * (0.3 + 0.7*1) * 100 = 100
180
+ expect(score).toBeCloseTo(100, -1);
181
+ });
182
+
183
+ it('recency weight is ~0.3 for files used > 7 days ago', () => {
184
+ const svc = new AutocompleteService();
185
+ const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000);
186
+ svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: eightDaysAgo } });
187
+ const score = svc.calculateFrecencyScore('a.ts');
188
+
189
+ // With recencyWeight = max(0, 1 - 8*24/168) = 0
190
+ // score ≈ log2(2) * (0.3 + 0.7*0) * 100 = 30
191
+ expect(score).toBeCloseTo(30, -1);
192
+ });
193
+ });
194
+ });