snow-ai 0.4.3 → 0.4.5

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.
@@ -41,7 +41,4 @@ export interface AnthropicMessageParam {
41
41
  content: string | Array<any>;
42
42
  }
43
43
  export declare function resetAnthropicClient(): void;
44
- /**
45
- * Create streaming chat completion using Anthropic API
46
- */
47
44
  export declare function createStreamingAnthropicCompletion(options: AnthropicOptions, abortSignal?: AbortSignal, onRetry?: (error: Error, attempt: number, nextDelay: number) => void): AsyncGenerator<AnthropicStreamChunk, void, unknown>;
@@ -5,6 +5,7 @@ import { withRetryGenerator, parseJsonWithFix } from '../utils/retryUtils.js';
5
5
  import { logger } from '../utils/logger.js';
6
6
  import { addProxyToFetchOptions } from '../utils/proxyUtils.js';
7
7
  import { saveUsageToFile } from '../utils/usageLogger.js';
8
+ import { isDevMode, getDevUserId } from '../utils/devMode.js';
8
9
  let anthropicConfig = null;
9
10
  // Persistent userId that remains the same until application restart
10
11
  let persistentUserId = null;
@@ -35,8 +36,16 @@ export function resetAnthropicClient() {
35
36
  * Generate a persistent user_id that remains the same until application restart
36
37
  * Format: user_<hash>_account__session_<uuid>
37
38
  * This matches Anthropic's expected format for tracking and caching
39
+ *
40
+ * In dev mode (--dev flag), uses a persistent userId from ~/.snow/dev-user-id
41
+ * instead of generating a new one each session
38
42
  */
39
43
  function getPersistentUserId() {
44
+ // Check if dev mode is enabled
45
+ if (isDevMode()) {
46
+ return getDevUserId();
47
+ }
48
+ // Normal mode: generate userId per session
40
49
  if (!persistentUserId) {
41
50
  const sessionId = randomUUID();
42
51
  const hash = createHash('sha256')
@@ -251,43 +260,58 @@ function convertToAnthropicMessages(messages, includeBuiltinSystemPrompt = true)
251
260
  async function* parseSSEStream(reader) {
252
261
  const decoder = new TextDecoder();
253
262
  let buffer = '';
254
- while (true) {
255
- const { done, value } = await reader.read();
256
- if (done)
257
- break;
258
- buffer += decoder.decode(value, { stream: true });
259
- const lines = buffer.split('\n');
260
- buffer = lines.pop() || '';
261
- for (const line of lines) {
262
- const trimmed = line.trim();
263
- if (!trimmed || trimmed.startsWith(':'))
264
- continue;
265
- if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
266
- return;
267
- }
268
- // Handle both "event: " and "event:" formats
269
- if (trimmed.startsWith('event:')) {
270
- // Event type, will be followed by data
271
- continue;
263
+ try {
264
+ while (true) {
265
+ const { done, value } = await reader.read();
266
+ if (done) {
267
+ // 关键修复:检查buffer是否有残留数据
268
+ if (buffer.trim()) {
269
+ // 连接异常中断,抛出明确错误
270
+ throw new Error(`Stream terminated unexpectedly with incomplete data: ${buffer.substring(0, 100)}...`);
271
+ }
272
+ break; // 正常结束
272
273
  }
273
- // Handle both "data: " and "data:" formats
274
- if (trimmed.startsWith('data:')) {
275
- const data = trimmed.startsWith('data: ')
276
- ? trimmed.slice(6)
277
- : trimmed.slice(5);
278
- try {
279
- yield JSON.parse(data);
274
+ buffer += decoder.decode(value, { stream: true });
275
+ const lines = buffer.split('\n');
276
+ buffer = lines.pop() || '';
277
+ for (const line of lines) {
278
+ const trimmed = line.trim();
279
+ if (!trimmed || trimmed.startsWith(':'))
280
+ continue;
281
+ if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
282
+ return;
283
+ }
284
+ // Handle both "event: " and "event:" formats
285
+ if (trimmed.startsWith('event:')) {
286
+ // Event type, will be followed by data
287
+ continue;
280
288
  }
281
- catch (e) {
282
- logger.error('Failed to parse SSE data:', data);
289
+ // Handle both "data: " and "data:" formats
290
+ if (trimmed.startsWith('data:')) {
291
+ const data = trimmed.startsWith('data: ')
292
+ ? trimmed.slice(6)
293
+ : trimmed.slice(5);
294
+ const parseResult = parseJsonWithFix(data, {
295
+ toolName: 'SSE stream',
296
+ logWarning: false,
297
+ logError: true,
298
+ });
299
+ if (parseResult.success) {
300
+ yield parseResult.data;
301
+ }
283
302
  }
284
303
  }
285
304
  }
286
305
  }
306
+ catch (error) {
307
+ const { logger } = await import('../utils/logger.js');
308
+ logger.error('SSE stream parsing error:', {
309
+ error: error instanceof Error ? error.message : 'Unknown error',
310
+ remainingBuffer: buffer.substring(0, 200),
311
+ });
312
+ throw error;
313
+ }
287
314
  }
288
- /**
289
- * Create streaming chat completion using Anthropic API
290
- */
291
315
  export async function* createStreamingAnthropicCompletion(options, abortSignal, onRetry) {
292
316
  yield* withRetryGenerator(async function* () {
293
317
  const config = getAnthropicConfig();
package/dist/api/chat.js CHANGED
@@ -126,41 +126,57 @@ export function resetOpenAIClient() {
126
126
  async function* parseSSEStream(reader) {
127
127
  const decoder = new TextDecoder();
128
128
  let buffer = '';
129
- while (true) {
130
- const { done, value } = await reader.read();
131
- if (done)
132
- break;
133
- buffer += decoder.decode(value, { stream: true });
134
- const lines = buffer.split('\n');
135
- buffer = lines.pop() || '';
136
- for (const line of lines) {
137
- const trimmed = line.trim();
138
- if (!trimmed || trimmed.startsWith(':'))
139
- continue;
140
- if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
141
- return;
142
- }
143
- // Handle both "event: " and "event:" formats
144
- if (trimmed.startsWith('event:')) {
145
- // Event type, will be followed by data
146
- continue;
129
+ try {
130
+ while (true) {
131
+ const { done, value } = await reader.read();
132
+ if (done) {
133
+ // 关键修复:检查buffer是否有残留数据
134
+ if (buffer.trim()) {
135
+ // 连接异常中断,抛出明确错误
136
+ throw new Error(`Stream terminated unexpectedly with incomplete data: ${buffer.substring(0, 100)}...`);
137
+ }
138
+ break; // 正常结束
147
139
  }
148
- // Handle both "data: " and "data:" formats
149
- if (trimmed.startsWith('data:')) {
150
- const data = trimmed.startsWith('data: ')
151
- ? trimmed.slice(6)
152
- : trimmed.slice(5);
153
- const parseResult = parseJsonWithFix(data, {
154
- toolName: 'SSE stream',
155
- logWarning: false,
156
- logError: true,
157
- });
158
- if (parseResult.success) {
159
- yield parseResult.data;
140
+ buffer += decoder.decode(value, { stream: true });
141
+ const lines = buffer.split('\n');
142
+ buffer = lines.pop() || '';
143
+ for (const line of lines) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed || trimmed.startsWith(':'))
146
+ continue;
147
+ if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
148
+ return;
149
+ }
150
+ // Handle both "event: " and "event:" formats
151
+ if (trimmed.startsWith('event:')) {
152
+ // Event type, will be followed by data
153
+ continue;
154
+ }
155
+ // Handle both "data: " and "data:" formats
156
+ if (trimmed.startsWith('data:')) {
157
+ const data = trimmed.startsWith('data: ')
158
+ ? trimmed.slice(6)
159
+ : trimmed.slice(5);
160
+ const parseResult = parseJsonWithFix(data, {
161
+ toolName: 'SSE stream',
162
+ logWarning: false,
163
+ logError: true,
164
+ });
165
+ if (parseResult.success) {
166
+ yield parseResult.data;
167
+ }
160
168
  }
161
169
  }
162
170
  }
163
171
  }
172
+ catch (error) {
173
+ const { logger } = await import('../utils/logger.js');
174
+ logger.error('SSE stream parsing error:', {
175
+ error: error instanceof Error ? error.message : 'Unknown error',
176
+ remainingBuffer: buffer.substring(0, 200),
177
+ });
178
+ throw error;
179
+ }
164
180
  }
165
181
  /**
166
182
  * Simple streaming chat completion - only handles OpenAI interaction
@@ -280,97 +280,113 @@ export async function* createStreamingGeminiCompletion(options, abortSignal, onR
280
280
  const reader = response.body.getReader();
281
281
  const decoder = new TextDecoder();
282
282
  let buffer = '';
283
- while (true) {
284
- const { done, value } = await reader.read();
285
- if (done)
286
- break;
287
- if (abortSignal?.aborted) {
288
- return;
289
- }
290
- buffer += decoder.decode(value, { stream: true });
291
- const lines = buffer.split('\n');
292
- buffer = lines.pop() || '';
293
- for (const line of lines) {
294
- const trimmed = line.trim();
295
- if (!trimmed || trimmed.startsWith(':'))
296
- continue;
297
- if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
298
- break;
283
+ try {
284
+ while (true) {
285
+ const { done, value } = await reader.read();
286
+ if (done) {
287
+ // 关键修复:检查buffer是否有残留数据
288
+ if (buffer.trim()) {
289
+ // 连接异常中断,抛出明确错误
290
+ throw new Error(`Stream terminated unexpectedly with incomplete data: ${buffer.substring(0, 100)}...`);
291
+ }
292
+ break; // 正常结束
299
293
  }
300
- // Handle both "event: " and "event:" formats
301
- if (trimmed.startsWith('event:')) {
302
- // Event type, will be followed by data
303
- continue;
294
+ if (abortSignal?.aborted) {
295
+ return;
304
296
  }
305
- // Handle both "data: " and "data:" formats
306
- if (trimmed.startsWith('data:')) {
307
- const data = trimmed.startsWith('data: ')
308
- ? trimmed.slice(6)
309
- : trimmed.slice(5);
310
- const parseResult = parseJsonWithFix(data, {
311
- toolName: 'Gemini SSE stream',
312
- logWarning: false,
313
- logError: true,
314
- });
315
- if (parseResult.success) {
316
- const chunk = parseResult.data;
317
- // Process candidates
318
- if (chunk.candidates && chunk.candidates.length > 0) {
319
- const candidate = chunk.candidates[0];
320
- if (candidate.content && candidate.content.parts) {
321
- for (const part of candidate.content.parts) {
322
- // Process thought content (Gemini thinking)
323
- // When part.thought === true, the text field contains thinking content
324
- if (part.thought === true && part.text) {
325
- thinkingTextBuffer += part.text;
326
- yield {
327
- type: 'reasoning_delta',
328
- delta: part.text,
329
- };
330
- }
331
- // Process regular text content (when thought is not true)
332
- else if (part.text) {
333
- contentBuffer += part.text;
334
- yield {
335
- type: 'content',
336
- content: part.text,
337
- };
338
- }
339
- // Process function calls
340
- if (part.functionCall) {
341
- hasToolCalls = true;
342
- const fc = part.functionCall;
343
- const toolCall = {
344
- id: `call_${toolCallIndex++}`,
345
- type: 'function',
346
- function: {
347
- name: fc.name,
348
- arguments: JSON.stringify(fc.args || {}),
349
- },
350
- };
351
- toolCallsBuffer.push(toolCall);
352
- // Yield delta for token counting
353
- const deltaText = fc.name + JSON.stringify(fc.args || {});
354
- yield {
355
- type: 'tool_call_delta',
356
- delta: deltaText,
357
- };
297
+ buffer += decoder.decode(value, { stream: true });
298
+ const lines = buffer.split('\n');
299
+ buffer = lines.pop() || '';
300
+ for (const line of lines) {
301
+ const trimmed = line.trim();
302
+ if (!trimmed || trimmed.startsWith(':'))
303
+ continue;
304
+ if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
305
+ break;
306
+ }
307
+ // Handle both "event: " and "event:" formats
308
+ if (trimmed.startsWith('event:')) {
309
+ // Event type, will be followed by data
310
+ continue;
311
+ }
312
+ // Handle both "data: " and "data:" formats
313
+ if (trimmed.startsWith('data:')) {
314
+ const data = trimmed.startsWith('data: ')
315
+ ? trimmed.slice(6)
316
+ : trimmed.slice(5);
317
+ const parseResult = parseJsonWithFix(data, {
318
+ toolName: 'Gemini SSE stream',
319
+ logWarning: false,
320
+ logError: true,
321
+ });
322
+ if (parseResult.success) {
323
+ const chunk = parseResult.data;
324
+ // Process candidates
325
+ if (chunk.candidates && chunk.candidates.length > 0) {
326
+ const candidate = chunk.candidates[0];
327
+ if (candidate.content && candidate.content.parts) {
328
+ for (const part of candidate.content.parts) {
329
+ // Process thought content (Gemini thinking)
330
+ // When part.thought === true, the text field contains thinking content
331
+ if (part.thought === true && part.text) {
332
+ thinkingTextBuffer += part.text;
333
+ yield {
334
+ type: 'reasoning_delta',
335
+ delta: part.text,
336
+ };
337
+ }
338
+ // Process regular text content (when thought is not true)
339
+ else if (part.text) {
340
+ contentBuffer += part.text;
341
+ yield {
342
+ type: 'content',
343
+ content: part.text,
344
+ };
345
+ }
346
+ // Process function calls
347
+ if (part.functionCall) {
348
+ hasToolCalls = true;
349
+ const fc = part.functionCall;
350
+ const toolCall = {
351
+ id: `call_${toolCallIndex++}`,
352
+ type: 'function',
353
+ function: {
354
+ name: fc.name,
355
+ arguments: JSON.stringify(fc.args || {}),
356
+ },
357
+ };
358
+ toolCallsBuffer.push(toolCall);
359
+ // Yield delta for token counting
360
+ const deltaText = fc.name + JSON.stringify(fc.args || {});
361
+ yield {
362
+ type: 'tool_call_delta',
363
+ delta: deltaText,
364
+ };
365
+ }
358
366
  }
359
367
  }
360
368
  }
361
- }
362
- // Track usage info
363
- if (chunk.usageMetadata) {
364
- totalTokens = {
365
- prompt: chunk.usageMetadata.promptTokenCount || 0,
366
- completion: chunk.usageMetadata.candidatesTokenCount || 0,
367
- total: chunk.usageMetadata.totalTokenCount || 0,
368
- };
369
+ // Track usage info
370
+ if (chunk.usageMetadata) {
371
+ totalTokens = {
372
+ prompt: chunk.usageMetadata.promptTokenCount || 0,
373
+ completion: chunk.usageMetadata.candidatesTokenCount || 0,
374
+ total: chunk.usageMetadata.totalTokenCount || 0,
375
+ };
376
+ }
369
377
  }
370
378
  }
371
379
  }
372
380
  }
373
381
  }
382
+ catch (error) {
383
+ const { logger } = await import('../utils/logger.js');
384
+ logger.error('Gemini SSE stream parsing error:', {
385
+ error: error instanceof Error ? error.message : 'Unknown error',
386
+ remainingBuffer: buffer.substring(0, 200),
387
+ });
388
+ throw error;
389
+ }
374
390
  // Yield tool calls if any
375
391
  if (hasToolCalls && toolCallsBuffer.length > 0) {
376
392
  yield {
@@ -207,41 +207,57 @@ function convertToResponseInput(messages, includeBuiltinSystemPrompt = true) {
207
207
  async function* parseSSEStream(reader) {
208
208
  const decoder = new TextDecoder();
209
209
  let buffer = '';
210
- while (true) {
211
- const { done, value } = await reader.read();
212
- if (done)
213
- break;
214
- buffer += decoder.decode(value, { stream: true });
215
- const lines = buffer.split('\n');
216
- buffer = lines.pop() || '';
217
- for (const line of lines) {
218
- const trimmed = line.trim();
219
- if (!trimmed || trimmed.startsWith(':'))
220
- continue;
221
- if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
222
- return;
223
- }
224
- // Handle both "event: " and "event:" formats
225
- if (trimmed.startsWith('event:')) {
226
- // Event type, will be followed by data
227
- continue;
210
+ try {
211
+ while (true) {
212
+ const { done, value } = await reader.read();
213
+ if (done) {
214
+ // 关键修复:检查buffer是否有残留数据
215
+ if (buffer.trim()) {
216
+ // 连接异常中断,抛出明确错误
217
+ throw new Error(`Stream terminated unexpectedly with incomplete data: ${buffer.substring(0, 100)}...`);
218
+ }
219
+ break; // 正常结束
228
220
  }
229
- // Handle both "data: " and "data:" formats
230
- if (trimmed.startsWith('data:')) {
231
- const data = trimmed.startsWith('data: ')
232
- ? trimmed.slice(6)
233
- : trimmed.slice(5);
234
- const parseResult = parseJsonWithFix(data, {
235
- toolName: 'Responses API SSE stream',
236
- logWarning: false,
237
- logError: true,
238
- });
239
- if (parseResult.success) {
240
- yield parseResult.data;
221
+ buffer += decoder.decode(value, { stream: true });
222
+ const lines = buffer.split('\n');
223
+ buffer = lines.pop() || '';
224
+ for (const line of lines) {
225
+ const trimmed = line.trim();
226
+ if (!trimmed || trimmed.startsWith(':'))
227
+ continue;
228
+ if (trimmed === 'data: [DONE]' || trimmed === 'data:[DONE]') {
229
+ return;
230
+ }
231
+ // Handle both "event: " and "event:" formats
232
+ if (trimmed.startsWith('event:')) {
233
+ // Event type, will be followed by data
234
+ continue;
235
+ }
236
+ // Handle both "data: " and "data:" formats
237
+ if (trimmed.startsWith('data:')) {
238
+ const data = trimmed.startsWith('data: ')
239
+ ? trimmed.slice(6)
240
+ : trimmed.slice(5);
241
+ const parseResult = parseJsonWithFix(data, {
242
+ toolName: 'Responses API SSE stream',
243
+ logWarning: false,
244
+ logError: true,
245
+ });
246
+ if (parseResult.success) {
247
+ yield parseResult.data;
248
+ }
241
249
  }
242
250
  }
243
251
  }
244
252
  }
253
+ catch (error) {
254
+ const { logger } = await import('../utils/logger.js');
255
+ logger.error('Responses API SSE stream parsing error:', {
256
+ error: error instanceof Error ? error.message : 'Unknown error',
257
+ remainingBuffer: buffer.substring(0, 200),
258
+ });
259
+ throw error;
260
+ }
245
261
  }
246
262
  /**
247
263
  * 使用 Responses API 创建流式响应(带自动工具调用)
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import './utils/patch-highlight.js';
2
+ export {};
package/dist/cli.js CHANGED
@@ -1,7 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // CRITICAL: Patch cli-highlight BEFORE any other imports
3
- // This must be the first import to ensure the patch is applied before cli-markdown loads
4
- import './utils/patch-highlight.js';
5
2
  import React from 'react';
6
3
  import { render, Text, Box } from 'ink';
7
4
  import Spinner from 'ink-spinner';
@@ -13,6 +10,7 @@ import { vscodeConnection } from './utils/vscodeConnection.js';
13
10
  import { resourceMonitor } from './utils/resourceMonitor.js';
14
11
  import { initializeProfiles } from './utils/configManager.js';
15
12
  import { processManager } from './utils/processManager.js';
13
+ import { enableDevMode, getDevUserId } from './utils/devMode.js';
16
14
  const execAsync = promisify(exec);
17
15
  // Check for updates asynchronously
18
16
  async function checkForUpdates(currentVersion) {
@@ -43,6 +41,7 @@ Options
43
41
  --update Update to latest version
44
42
  -c Skip welcome screen and resume last conversation
45
43
  --ask Quick question mode (headless mode with single prompt)
44
+ --dev Enable developer mode with persistent userId for testing
46
45
  `, {
47
46
  importMeta: import.meta,
48
47
  flags: {
@@ -57,6 +56,10 @@ Options
57
56
  ask: {
58
57
  type: 'string',
59
58
  },
59
+ dev: {
60
+ type: 'boolean',
61
+ default: false,
62
+ },
60
63
  },
61
64
  });
62
65
  // Handle update flag
@@ -72,6 +75,14 @@ if (cli.flags.update) {
72
75
  process.exit(1);
73
76
  }
74
77
  }
78
+ // Handle dev mode flag
79
+ if (cli.flags.dev) {
80
+ enableDevMode();
81
+ const userId = getDevUserId();
82
+ console.log('🔧 Developer mode enabled');
83
+ console.log(`📝 Using persistent userId: ${userId}`);
84
+ console.log(`📂 Stored in: ~/.snow/dev-user-id\n`);
85
+ }
75
86
  // Start resource monitoring in development/debug mode
76
87
  if (process.env['NODE_ENV'] === 'development' || process.env['DEBUG']) {
77
88
  resourceMonitor.startMonitoring(30000); // Monitor every 30 seconds
@@ -1041,9 +1041,12 @@ export async function handleConversationWithTools(options) {
1041
1041
  freeEncoder();
1042
1042
  }
1043
1043
  catch (error) {
1044
- freeEncoder();
1045
1044
  throw error;
1046
1045
  }
1046
+ finally {
1047
+ // ✅ 确保总是释放encoder资源,避免资源泄漏
1048
+ freeEncoder();
1049
+ }
1047
1050
  // Return the accumulated usage data
1048
1051
  return { usage: accumulatedUsage };
1049
1052
  }
@@ -1,23 +1,70 @@
1
1
  import React from 'react';
2
2
  import { Text, Box } from 'ink';
3
- // @ts-expect-error - cli-markdown doesn't have TypeScript definitions
4
- import cliMarkdown from 'cli-markdown';
3
+ import { marked } from 'marked';
4
+ import TerminalRenderer from 'marked-terminal';
5
5
  import logger from '../../utils/logger.js';
6
+ // Configure marked to use terminal renderer
7
+ marked.setOptions({
8
+ // @ts-expect-error - marked-terminal types mismatch with marked v15
9
+ renderer: new TerminalRenderer({
10
+ reflowText: true,
11
+ width: 80,
12
+ emoji: false,
13
+ tab: 2,
14
+ }),
15
+ });
16
+ /**
17
+ * Sanitize markdown content to prevent number-to-alphabet errors
18
+ * Fixes invalid ordered list start attributes (0 or negative values)
19
+ */
20
+ function sanitizeMarkdownContent(content) {
21
+ // Replace <ol start="0">, <ol start="-1">, etc. with <ol start="1">
22
+ return content.replace(/<ol\s+start=["']?(0|-\d+)["']?>/gi, '<ol start="1">');
23
+ }
24
+ /**
25
+ * Fallback renderer for when cli-markdown fails
26
+ * Renders content as plain text to ensure visibility
27
+ */
28
+ function renderFallback(content) {
29
+ const lines = content.split('\n');
30
+ return (React.createElement(Box, { flexDirection: "column" }, lines.map((line, index) => (React.createElement(Text, { key: index }, line)))));
31
+ }
6
32
  export default function MarkdownRenderer({ content }) {
7
- // Use cli-markdown for elegant markdown rendering with syntax highlighting
8
- // The patched highlight function will gracefully handle unknown languages
9
- const rendered = cliMarkdown(content);
10
- // Split into lines and render each separately
11
- // This prevents Ink's Text component from creating mysterious whitespace
12
- // when handling multi-line content with \n characters
13
- const lines = rendered.split('\n');
14
- // Safety check: prevent rendering issues with excessively long output
15
- if (lines.length > 500) {
16
- logger.warn('[MarkdownRenderer] Rendered output has too many lines', {
17
- totalLines: lines.length,
18
- truncatedTo: 500,
33
+ // Use marked + marked-terminal for elegant markdown rendering with syntax highlighting
34
+ // marked provides better stability and cross-platform support
35
+ try {
36
+ // Stage 1: Sanitize content to prevent invalid list numbering
37
+ const sanitizedContent = sanitizeMarkdownContent(content);
38
+ // Stage 2: Render with marked
39
+ const rendered = marked.parse(sanitizedContent);
40
+ // Split into lines and render each separately
41
+ // This prevents Ink's Text component from creating mysterious whitespace
42
+ // when handling multi-line content with \n characters
43
+ const lines = rendered.split('\n');
44
+ // Safety check: prevent rendering issues with excessively long output
45
+ if (lines.length > 500) {
46
+ logger.warn('[MarkdownRenderer] Rendered output has too many lines', {
47
+ totalLines: lines.length,
48
+ truncatedTo: 500,
49
+ });
50
+ return (React.createElement(Box, { flexDirection: "column" }, lines.slice(0, 500).map((line, index) => (React.createElement(Text, { key: index }, line)))));
51
+ }
52
+ return (React.createElement(Box, { flexDirection: "column" }, lines.map((line, index) => (React.createElement(Text, { key: index }, line)))));
53
+ }
54
+ catch (error) {
55
+ // Stage 3: Error handling - catch number-to-alphabet errors
56
+ if (error?.message?.includes('Number must be >')) {
57
+ logger.warn('[MarkdownRenderer] Invalid list numbering detected, falling back to plain text', {
58
+ error: error.message,
59
+ });
60
+ return renderFallback(content);
61
+ }
62
+ // Re-throw other errors for debugging
63
+ logger.error('[MarkdownRenderer] Unexpected error during markdown rendering', {
64
+ error: error.message,
65
+ stack: error.stack,
19
66
  });
20
- return (React.createElement(Box, { flexDirection: "column" }, lines.slice(0, 500).map((line, index) => (React.createElement(Text, { key: index }, line)))));
67
+ // Still provide fallback to prevent crash
68
+ return renderFallback(content);
21
69
  }
22
- return (React.createElement(Box, { flexDirection: "column" }, lines.map((line, index) => (React.createElement(Text, { key: index }, line)))));
23
70
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Get or create persistent dev userId
3
+ * The userId is stored in ~/.snow/dev-user-id and persists across sessions
4
+ */
5
+ export declare function getDevUserId(): string;
6
+ /**
7
+ * Check if dev mode is enabled
8
+ */
9
+ export declare function isDevMode(): boolean;
10
+ /**
11
+ * Enable dev mode by setting environment variable
12
+ */
13
+ export declare function enableDevMode(): void;
@@ -0,0 +1,54 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ const SNOW_DIR = join(homedir(), '.snow');
6
+ const DEV_USER_ID_FILE = join(SNOW_DIR, 'dev-user-id');
7
+ /**
8
+ * Ensure .snow directory exists
9
+ */
10
+ function ensureSnowDir() {
11
+ if (!existsSync(SNOW_DIR)) {
12
+ mkdirSync(SNOW_DIR, { recursive: true });
13
+ }
14
+ }
15
+ /**
16
+ * Generate a persistent dev userId following Anthropic's format
17
+ * Format: user_<hash>_account__session_<uuid>
18
+ */
19
+ function generateDevUserId() {
20
+ const sessionId = randomUUID();
21
+ const hash = createHash('sha256')
22
+ .update(`anthropic_dev_user_${sessionId}`)
23
+ .digest('hex');
24
+ return `user_${hash}_account__session_${sessionId}`;
25
+ }
26
+ /**
27
+ * Get or create persistent dev userId
28
+ * The userId is stored in ~/.snow/dev-user-id and persists across sessions
29
+ */
30
+ export function getDevUserId() {
31
+ ensureSnowDir();
32
+ if (existsSync(DEV_USER_ID_FILE)) {
33
+ const userId = readFileSync(DEV_USER_ID_FILE, 'utf-8').trim();
34
+ if (userId) {
35
+ return userId;
36
+ }
37
+ }
38
+ // Generate new userId if file doesn't exist or is empty
39
+ const userId = generateDevUserId();
40
+ writeFileSync(DEV_USER_ID_FILE, userId, 'utf-8');
41
+ return userId;
42
+ }
43
+ /**
44
+ * Check if dev mode is enabled
45
+ */
46
+ export function isDevMode() {
47
+ return process.env['SNOW_DEV_MODE'] === 'true';
48
+ }
49
+ /**
50
+ * Enable dev mode by setting environment variable
51
+ */
52
+ export function enableDevMode() {
53
+ process.env['SNOW_DEV_MODE'] = 'true';
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snow-ai",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Intelligent Command Line Assistant powered by AI",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -44,8 +44,9 @@
44
44
  "@types/better-sqlite3": "^7.6.13",
45
45
  "better-sqlite3": "^12.4.1",
46
46
  "cli-highlight": "^2.1.11",
47
- "cli-markdown": "^3.5.1",
48
47
  "diff": "^8.0.2",
48
+ "marked": "^15.0.6",
49
+ "marked-terminal": "^7.3.0",
49
50
  "fzf": "^0.5.2",
50
51
  "http-proxy-agent": "^7.0.2",
51
52
  "https-proxy-agent": "^7.0.6",
@@ -66,6 +67,7 @@
66
67
  "devDependencies": {
67
68
  "@sindresorhus/tsconfig": "^3.0.1",
68
69
  "@types/diff": "^7.0.2",
70
+ "@types/marked-terminal": "^6.1.1",
69
71
  "@types/prettier": "^2.7.3",
70
72
  "@types/react": "^18.0.32",
71
73
  "@types/ws": "^8.5.8",