amicus 1.0.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
package/src/cli.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * CLI Argument Parser
3
+ *
4
+ * Spec Reference: §4 CLI Interface
5
+ * Parses command line arguments for all sidecar commands.
6
+ */
7
+
8
+ const {
9
+ validatePromptContent,
10
+ validateCwdPath,
11
+ validateExplicitSession,
12
+ validateAgentMode,
13
+ validateHeadlessAgent,
14
+ validateMcpSpec,
15
+ validateMcpConfigFile,
16
+ validateApiKey,
17
+ validateThinkingLevel
18
+ } = require('./utils/validators');
19
+ const { logger } = require('./utils/logger');
20
+
21
+ /**
22
+ * Default values per spec §4.1
23
+ */
24
+ const DEFAULTS = {
25
+ 'session-id': 'current',
26
+ cwd: process.cwd(),
27
+ 'context-turns': 50,
28
+ 'context-max-tokens': 80000,
29
+ timeout: 15,
30
+ 'no-ui': false,
31
+ 'summary-length': 'normal', // Default summary length
32
+ position: 'right' // Default window position
33
+ };
34
+
35
+ /**
36
+ * Parse command line arguments
37
+ * @param {string[]} argv - Command line arguments (without node and script name)
38
+ * @returns {object} Parsed arguments
39
+ */
40
+ function parseArgs(argv) {
41
+ const result = {
42
+ _: [],
43
+ ...DEFAULTS
44
+ };
45
+
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const arg = argv[i];
48
+
49
+ if (arg.startsWith('--')) {
50
+ let key = arg.slice(2);
51
+ const next = argv[i + 1];
52
+
53
+ // Handle --key=value syntax
54
+ let inlineValue;
55
+ const eqIdx = key.indexOf('=');
56
+ if (eqIdx !== -1) {
57
+ inlineValue = key.slice(eqIdx + 1);
58
+ key = key.slice(0, eqIdx);
59
+ }
60
+
61
+ // Boolean flags (no value expected)
62
+ if (isBooleanFlag(key)) {
63
+ result[key] = true;
64
+ continue;
65
+ }
66
+
67
+ // If --key=value was used, use the inline value directly
68
+ if (inlineValue !== undefined) {
69
+ result[key] = parseValue(key, inlineValue);
70
+ continue;
71
+ }
72
+
73
+ // Array accumulation flags
74
+ if (key === 'exclude-mcp' && next && !next.startsWith('--')) {
75
+ result['exclude-mcp'] = result['exclude-mcp'] || [];
76
+ result['exclude-mcp'].push(next);
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ // Options with values
82
+ if (next && !next.startsWith('--')) {
83
+ result[key] = parseValue(key, next);
84
+ i++;
85
+ } else {
86
+ result[key] = true;
87
+ }
88
+ } else {
89
+ result._.push(arg);
90
+ }
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Check if a flag is boolean (doesn't take a value)
98
+ */
99
+ function isBooleanFlag(key) {
100
+ const booleanFlags = [
101
+ 'no-ui',
102
+ 'no-mcp',
103
+ 'no-context',
104
+ 'setup',
105
+ 'all',
106
+ // 'summary', // summary is now an option with a value
107
+ 'conversation',
108
+ 'json',
109
+ 'version',
110
+ 'help',
111
+ 'api-keys',
112
+ 'validate-model',
113
+ 'no-validate-model'
114
+ ];
115
+ return booleanFlags.includes(key);
116
+ }
117
+
118
+ /**
119
+ * Parse a value to the appropriate type
120
+ */
121
+ function parseValue(key, value) {
122
+ // Numeric options
123
+ const numericOptions = ['context-turns', 'context-max-tokens', 'timeout', 'opencode-port'];
124
+ if (numericOptions.includes(key)) {
125
+ return parseInt(value, 10);
126
+ }
127
+
128
+ // Specific string options
129
+ if (key === 'summary-length') {
130
+ const validLengths = ['brief', 'normal', 'verbose'];
131
+ if (!validLengths.includes(value.toLowerCase())) {
132
+ logger.warn('Invalid summary-length value, using default', { value, default: 'normal' });
133
+ return DEFAULTS['summary-length'];
134
+ }
135
+ return value.toLowerCase();
136
+ }
137
+
138
+ return value;
139
+ }
140
+
141
+ /**
142
+ * Validate arguments for the 'start' command
143
+ * @param {object} args - Parsed arguments
144
+ * @returns {{ valid: boolean, error?: string }}
145
+ */
146
+ function validateStartArgs(args) {
147
+ // Required: --prompt (presence check)
148
+ if (!args.prompt) {
149
+ return { valid: false, error: 'Error: --prompt is required' };
150
+ }
151
+
152
+ // Validate prompt content (not empty/whitespace-only)
153
+ const promptCheck = validatePromptContent(args.prompt);
154
+ if (!promptCheck.valid) {
155
+ return promptCheck;
156
+ }
157
+
158
+ // Validate model format if model is present (model is resolved externally via resolveModel)
159
+ if (args.model && !isValidModelFormat(args.model)) {
160
+ return { valid: false, error: 'Error: --model must be in format provider/model (e.g., google/gemini-2.5-flash) or openrouter/provider/model' };
161
+ }
162
+
163
+ // Validate cwd path exists (if provided)
164
+ const cwdCheck = validateCwdPath(args.cwd);
165
+ if (!cwdCheck.valid) {
166
+ return cwdCheck;
167
+ }
168
+
169
+ // Validate explicit session ID exists (if not 'current')
170
+ const sessionCheck = validateExplicitSession(args['session-id'], args.cwd);
171
+ if (!sessionCheck.valid) {
172
+ return sessionCheck;
173
+ }
174
+
175
+ // Validate agent mode (if provided)
176
+ const agentCheck = validateAgentMode(args.agent);
177
+ if (!agentCheck.valid) {
178
+ return agentCheck;
179
+ }
180
+
181
+ // Validate agent is headless-safe when --no-ui is set
182
+ let headlessWarning;
183
+ if (args['no-ui']) {
184
+ const headlessCheck = validateHeadlessAgent(args.agent);
185
+ if (!headlessCheck.valid) {
186
+ return headlessCheck;
187
+ }
188
+ if (headlessCheck.warning) {
189
+ logger.warn('Custom agent headless warning', { warning: headlessCheck.warning });
190
+ headlessWarning = headlessCheck.warning;
191
+ }
192
+ }
193
+
194
+ // Validate --client (if provided)
195
+ if (args.client) {
196
+ const validClients = ['code-local', 'code-web', 'cowork'];
197
+ if (!validClients.includes(args.client)) {
198
+ return { valid: false, error: `Error: --client must be one of: ${validClients.join(', ')}` };
199
+ }
200
+ // Require --session-dir when client is code-web
201
+ if (args.client === 'code-web' && !args['session-dir']) {
202
+ return { valid: false, error: 'Error: --session-dir is required when --client is code-web' };
203
+ }
204
+ }
205
+
206
+ // Validate MCP spec format (if provided)
207
+ const mcpCheck = validateMcpSpec(args.mcp);
208
+ if (!mcpCheck.valid) {
209
+ return mcpCheck;
210
+ }
211
+
212
+ // Validate MCP config file (if provided)
213
+ const mcpConfigCheck = validateMcpConfigFile(args['mcp-config']);
214
+ if (!mcpConfigCheck.valid) {
215
+ return mcpConfigCheck;
216
+ }
217
+
218
+ // Validate timeout is positive
219
+ if (args.timeout !== undefined && args.timeout <= 0) {
220
+ return { valid: false, error: 'Error: --timeout must be a positive number' };
221
+ }
222
+
223
+ // Validate context-turns is positive
224
+ if (args['context-turns'] !== undefined && args['context-turns'] <= 0) {
225
+ return { valid: false, error: 'Error: --context-turns must be a positive number' };
226
+ }
227
+
228
+ // Validate context-since format if provided
229
+ if (args['context-since'] && !isValidDurationFormat(args['context-since'])) {
230
+ return { valid: false, error: 'Error: --context-since must be in format like 30m, 2h, or 1d' };
231
+ }
232
+
233
+ // Validate summary-length
234
+ const validSummaryLengths = ['brief', 'normal', 'verbose'];
235
+ if (args['summary-length'] && !validSummaryLengths.includes(args['summary-length'])) {
236
+ return { valid: false, error: `Error: --summary-length must be one of: ${validSummaryLengths.join(', ')}` };
237
+ }
238
+
239
+ // Validate thinking effort level (if provided), with model-specific support check
240
+ const thinkingCheck = validateThinkingLevel(args.thinking, args.model);
241
+ if (!thinkingCheck.valid) {
242
+ return thinkingCheck;
243
+ }
244
+ // If model doesn't support the level, adjust it and warn
245
+ if (thinkingCheck.warning) {
246
+ logger.warn('Thinking level adjusted', { warning: thinkingCheck.warning, adjustedLevel: thinkingCheck.adjustedLevel });
247
+ args.thinking = thinkingCheck.adjustedLevel;
248
+ }
249
+
250
+ // Validate API key is present for the model's provider
251
+ const apiKeyCheck = validateApiKey(args.model);
252
+ if (!apiKeyCheck.valid) {
253
+ return apiKeyCheck;
254
+ }
255
+
256
+ const result = { valid: true };
257
+ if (headlessWarning) {
258
+ result.warning = headlessWarning;
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Check if model format is valid
265
+ * Supports:
266
+ * - Direct API: provider/model (e.g., google/gemini-2.5-flash)
267
+ * - OpenRouter: openrouter/provider/model (e.g., openrouter/google/gemini-2.5-flash)
268
+ */
269
+ function isValidModelFormat(model) {
270
+ const parts = model.split('/');
271
+ // Must have at least 2 parts (provider/model) and at most 3 (openrouter/provider/model)
272
+ if (parts.length < 2 || parts.length > 3) {
273
+ return false;
274
+ }
275
+ // All parts must be non-empty
276
+ return parts.every(part => part.length > 0);
277
+ }
278
+
279
+ /**
280
+ * Check if duration format is valid (e.g., 30m, 2h, 1d)
281
+ */
282
+ function isValidDurationFormat(duration) {
283
+ return /^\d+[mhd]$/.test(duration);
284
+ }
285
+
286
+ /**
287
+ * Get usage text
288
+ */
289
+ function getUsage() {
290
+ return `
291
+ Usage: amicus <command> [options]
292
+
293
+ Commands:
294
+ start Launch a new amicus session
295
+ fanout Run N models on the same prompt in parallel (headless)
296
+ list Show previous sessions
297
+ resume Reopen a previous session
298
+ continue New session building on previous
299
+ read Output session summary/conversation
300
+ models List/search the model catalog, refresh it, audit aliases
301
+ abort Abort a running session (or --all)
302
+ setup Configure default model and aliases
303
+ --api-keys Open API key setup window
304
+ --add-alias <name=model> Add a model alias without the full wizard
305
+ update Update to latest version
306
+ mcp Start MCP server (stdio transport)
307
+
308
+ Options for 'start':
309
+ --model <model> Optional (uses config default). Model to use:
310
+ - Short aliases: gemini, opus, gpt (see 'amicus setup')
311
+ - Direct API: google/gemini-2.5-flash
312
+ - OpenRouter: openrouter/google/gemini-2.5-flash
313
+ --prompt <text> Required. Task description
314
+ --prompt-file <path> Read the prompt from a UTF-8 file (XOR --prompt)
315
+ --json With --no-ui: emit the run result as stable JSON
316
+ --agent <agent> OpenCode agent to use (see Agent Types below)
317
+ --session-id <id|"current"> Session ID to pull context from (default: current)
318
+ --cwd <path> Project directory (default: cwd)
319
+ --no-ui Run without GUI (autonomous mode)
320
+ --no-context Skip parent conversation history context
321
+ --timeout <minutes> Headless timeout (default: 15)
322
+ --client <type> Client type: code-local, code-web, cowork
323
+ --session-dir <path> Explicit session data directory
324
+ --setup Force open configuration
325
+ --fold-shortcut <key> Customize fold shortcut
326
+ --opencode-port <port> Port override for OpenCode server
327
+ --context-turns <N> Max conversation turns (default: 50)
328
+ --context-since <duration> Time filter (e.g., 2h). Overrides turns.
329
+ --context-max-tokens <N> Max context tokens (default: 80000)
330
+ --summary-length <length> Summary verbosity: brief, normal (default), verbose
331
+ --thinking <level> Reasoning effort: none, minimal, low, medium, high, xhigh
332
+ --mcp <spec> Add MCP server. Formats:
333
+ - name=url (remote server)
334
+ - name=command (local server)
335
+ --mcp-config <path> Path to opencode.json with MCP config
336
+ --no-mcp Don't inherit MCP servers from parent LLM
337
+ --exclude-mcp <name> Exclude specific MCP server (repeatable)
338
+ --validate-model (Deprecated: validation is on by default)
339
+ --no-validate-model Skip model-catalog validation before launch
340
+ --position <pos> Window position: right (default), left, center
341
+
342
+ Options for 'fanout':
343
+ --models <a,b,c> Required. Comma-separated aliases or provider/model IDs
344
+ --prompt <text> Task briefing (or use --prompt-file)
345
+ --prompt-file <path> Read the briefing from a UTF-8 file (avoids the
346
+ ~32KB Windows argument cap). Mutually exclusive
347
+ with --prompt. Also works with 'start'.
348
+ --wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
349
+ --json Emit the wave result as stable JSON on stdout
350
+ Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
351
+ --no-context, --context-*, --mcp*, --no-validate-model, --cwd
352
+ Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
353
+
354
+ Options for 'models':
355
+ --search <q> Filter by substring over model id and name
356
+ --refresh Force-refresh the catalog from provider APIs
357
+ --check Audit aliases against the catalog (exit = stale count)
358
+ --json Machine-readable output
359
+
360
+ Options for 'list':
361
+ --status <filter> Filter by status (running, complete)
362
+ --all Show all projects
363
+ --json Output as JSON
364
+
365
+ Options for 'abort':
366
+ --all Abort all running sessions in this project
367
+
368
+ Options for 'read':
369
+ --summary Show summary (default)
370
+ --conversation Show full conversation
371
+ --metadata Show session metadata
372
+ --json Emit the run/wave result as stable JSON
373
+
374
+ OpenCode Agent Types:
375
+ Chat Reads auto, writes/bash ask permission (interactive default)
376
+ Build Full tool access (headless default)
377
+ Plan Read-only analysis and planning
378
+
379
+ NOTE: --agent chat is interactive-only (incompatible with --no-ui).
380
+ Headless mode defaults to build agent.
381
+
382
+ Custom agents defined in ~/.config/opencode/agents/ or
383
+ .opencode/agents/ are also supported.
384
+
385
+ Examples:
386
+ amicus start --model google/gemini-2.5 --prompt "Debug auth issue"
387
+ amicus start --model openai/o3 --prompt "Generate tests" --no-ui
388
+ amicus start --model gemini --prompt "Review code" --agent Plan
389
+ amicus list
390
+ amicus resume abc123
391
+ amicus read abc123 --conversation
392
+ `;
393
+ }
394
+
395
+ module.exports = {
396
+ parseArgs,
397
+ validateStartArgs,
398
+ getUsage,
399
+ DEFAULTS
400
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * File Conflict Detection Module
3
+ *
4
+ * Spec Reference: Section 7.2 File Conflict Detection
5
+ * Detects conflicts between sidecar file modifications and external changes.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Format a relative time string (e.g., "5 min ago")
13
+ *
14
+ * @param {Date|string} date - Date to format
15
+ * @returns {string} Relative time string
16
+ */
17
+ function formatRelativeTime(date) {
18
+ const dateObj = date instanceof Date ? date : new Date(date);
19
+ const diffMs = Date.now() - dateObj.getTime();
20
+ const diffMins = Math.floor(diffMs / 60000);
21
+
22
+ if (diffMins < 1) {
23
+ return 'just now';
24
+ }
25
+ if (diffMins < 60) {
26
+ return `${diffMins} min ago`;
27
+ }
28
+
29
+ const diffHours = Math.floor(diffMins / 60);
30
+ if (diffHours < 24) {
31
+ return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
32
+ }
33
+
34
+ const diffDays = Math.floor(diffHours / 24);
35
+ return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
36
+ }
37
+
38
+ /**
39
+ * Detect file conflicts between sidecar and external modifications
40
+ * Spec Reference: §7.2 File Conflict Detection
41
+ *
42
+ * Compares files written by the sidecar against their current modification times.
43
+ * A conflict exists when a file was modified externally after the sidecar session started.
44
+ *
45
+ * @param {object} sidecarFiles - Object containing file tracking info
46
+ * @param {string[]} sidecarFiles.written - Array of relative file paths written by sidecar
47
+ * @param {string} projectDir - Project directory path
48
+ * @param {Date|string} sessionStartTime - When the sidecar session started
49
+ * @returns {object[]} Array of conflict objects
50
+ *
51
+ * @example
52
+ * const conflicts = detectConflicts(
53
+ * { written: ['src/auth/TokenManager.ts'] },
54
+ * '/path/to/project',
55
+ * new Date(Date.now() - 5 * 60 * 1000)
56
+ * );
57
+ * // Returns: [{ file: 'src/auth/TokenManager.ts', sidecarAction: 'write', externalMtime: Date }]
58
+ */
59
+ function detectConflicts(sidecarFiles, projectDir, sessionStartTime) {
60
+ const conflicts = [];
61
+
62
+ // Handle missing or empty written files list
63
+ const writtenFiles = sidecarFiles?.written || [];
64
+ if (writtenFiles.length === 0) {
65
+ return conflicts;
66
+ }
67
+
68
+ // Normalize session start time to Date object
69
+ const startTime = sessionStartTime instanceof Date
70
+ ? sessionStartTime
71
+ : new Date(sessionStartTime);
72
+
73
+ for (const file of writtenFiles) {
74
+ const filePath = path.join(projectDir, file);
75
+
76
+ // Check if file exists
77
+ if (!fs.existsSync(filePath)) {
78
+ // File doesn't exist - no conflict (sidecar created it or it was deleted)
79
+ continue;
80
+ }
81
+
82
+ try {
83
+ const stat = fs.statSync(filePath);
84
+
85
+ // Check if file was modified after session started
86
+ if (stat.mtime > startTime) {
87
+ conflicts.push({
88
+ file,
89
+ sidecarAction: 'write',
90
+ externalMtime: stat.mtime
91
+ });
92
+ }
93
+ } catch (error) {
94
+ // Skip files we can't stat
95
+ continue;
96
+ }
97
+ }
98
+
99
+ return conflicts;
100
+ }
101
+
102
+ /**
103
+ * Format conflict warning for summary output
104
+ * Spec Reference: §7.2 Conflict Warning in Summary
105
+ *
106
+ * @param {object[]} conflicts - Array of conflict objects
107
+ * @returns {string} Formatted warning string or empty string if no conflicts
108
+ *
109
+ * @example
110
+ * formatConflictWarning([
111
+ * { file: 'src/auth/TokenManager.ts', sidecarAction: 'write', externalMtime: new Date() }
112
+ * ]);
113
+ * // Returns:
114
+ * // ⚠️ **FILE CONFLICT WARNING**
115
+ * // The following files were modified by both this sidecar AND externally:
116
+ * // - src/auth/TokenManager.ts (external change: 5 min ago)
117
+ * //
118
+ * // **Review these changes carefully before accepting.**
119
+ */
120
+ function formatConflictWarning(conflicts) {
121
+ if (!conflicts || conflicts.length === 0) {
122
+ return '';
123
+ }
124
+
125
+ const lines = [
126
+ '\u26A0\uFE0F **FILE CONFLICT WARNING**',
127
+ 'The following files were modified by both this sidecar AND externally:'
128
+ ];
129
+
130
+ for (const conflict of conflicts) {
131
+ const relativeTime = formatRelativeTime(conflict.externalMtime);
132
+ lines.push(`- ${conflict.file} (external change: ${relativeTime})`);
133
+ }
134
+
135
+ lines.push('');
136
+ lines.push('**Review these changes carefully before accepting.**');
137
+
138
+ return lines.join('\n');
139
+ }
140
+
141
+ module.exports = {
142
+ detectConflicts,
143
+ formatConflictWarning
144
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Context Compression Module
3
+ *
4
+ * Provides token estimation and context compression utilities for
5
+ * sidecar sessions. Determines whether context needs model-based
6
+ * compression (delegated to the caller).
7
+ *
8
+ * @module context-compression
9
+ */
10
+
11
+ const { logger } = require('./utils/logger');
12
+
13
+ /**
14
+ * Default token limit for context compression
15
+ * @type {number}
16
+ */
17
+ const DEFAULT_TOKEN_LIMIT = 30000;
18
+
19
+ /**
20
+ * Estimate the number of tokens in a text string.
21
+ * Uses a simple heuristic: ceil(length / 4).
22
+ *
23
+ * @param {string|null|undefined} text - The text to estimate tokens for
24
+ * @returns {number} Estimated token count (0 for empty/null/undefined)
25
+ */
26
+ function estimateTokenCount(text) {
27
+ if (!text) {
28
+ return 0;
29
+ }
30
+
31
+ return Math.ceil(text.length / 4);
32
+ }
33
+
34
+ /**
35
+ * Build a preamble string that identifies the working directory.
36
+ *
37
+ * @param {string} cwd - The current working directory path
38
+ * @returns {string} Preamble string ending with double newline
39
+ */
40
+ function buildPreamble(cwd) {
41
+ return `You are working in ${cwd}. Here is the conversation:\n\n`;
42
+ }
43
+
44
+ /**
45
+ * Compress context text for sidecar sessions.
46
+ *
47
+ * If the estimated token count is within the limit, returns the text
48
+ * with a preamble prepended and compressed=false. If it exceeds the
49
+ * limit, returns the full text with compressed=true and
50
+ * needsModelCompression=true, signaling the caller to perform
51
+ * model-based compression.
52
+ *
53
+ * @param {string} contextText - The raw context text to compress
54
+ * @param {Object} [options] - Compression options
55
+ * @param {string} [options.cwd=process.cwd()] - Working directory for preamble
56
+ * @param {number} [options.tokenLimit=30000] - Maximum token threshold
57
+ * @returns {Object} Compression result
58
+ * @returns {string} result.text - Preamble + context text
59
+ * @returns {boolean} result.compressed - Whether compression was triggered
60
+ * @returns {boolean} result.needsModelCompression - Whether caller should run model compression
61
+ * @returns {number} result.estimatedTokens - Estimated token count of the full text
62
+ */
63
+ function compressContext(contextText, options = {}) {
64
+ const { cwd = process.cwd(), tokenLimit = DEFAULT_TOKEN_LIMIT } = options;
65
+
66
+ const preamble = buildPreamble(cwd);
67
+ const fullText = preamble + contextText;
68
+ const estimatedTokens = estimateTokenCount(fullText);
69
+
70
+ if (estimatedTokens <= tokenLimit) {
71
+ logger.debug('Context within token limit', {
72
+ estimatedTokens,
73
+ tokenLimit
74
+ });
75
+
76
+ return {
77
+ text: fullText,
78
+ compressed: false,
79
+ needsModelCompression: false,
80
+ estimatedTokens
81
+ };
82
+ }
83
+
84
+ logger.info('Context exceeds token limit, model compression needed', {
85
+ estimatedTokens,
86
+ tokenLimit
87
+ });
88
+
89
+ return {
90
+ text: fullText,
91
+ compressed: true,
92
+ needsModelCompression: true,
93
+ estimatedTokens
94
+ };
95
+ }
96
+
97
+ module.exports = {
98
+ compressContext,
99
+ estimateTokenCount,
100
+ buildPreamble,
101
+ DEFAULT_TOKEN_LIMIT
102
+ };