deeper-cli 1.0.6 → 1.2.1

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.
@@ -1,5 +1,5 @@
1
1
  import type { ChatMessage, DeepSeekConfig, StreamChunk } from './types.js';
2
- import type { ToolDefinition, ToolCall } from '../tools/tool-types.js';
2
+ import type { ToolDefinition } from '../tools/tool-types.js';
3
3
  import { RetryManager } from './RetryManager.js';
4
4
  import { StreamHandler } from './StreamHandler.js';
5
5
  import { logger } from '../core/logger.js';
@@ -41,13 +41,22 @@ interface ChatCompletionResponse {
41
41
  const DEFAULT_TIMEOUT_MS = 120000;
42
42
  const MAX_RETRIES = 3;
43
43
 
44
+ const isRetryable = (error: Error, _attempt: number): boolean => {
45
+ const msg = error.message.toLowerCase();
46
+ if (msg.includes('429') || msg.includes('rate limit')) return true;
47
+ if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('504')) return true;
48
+ if (msg.includes('timeout') || msg.includes('abort')) return _attempt < 2;
49
+ if (msg.includes('econnreset') || msg.includes('econnrefused')) return true;
50
+ return false;
51
+ };
52
+
44
53
  export class DeepSeekClient {
45
54
  private config: DeepSeekConfig;
46
55
  private retryManager: RetryManager;
47
56
 
48
57
  constructor(config: DeepSeekConfig) {
49
58
  this.config = config;
50
- this.retryManager = new RetryManager(config.maxTokens > 0 ? MAX_RETRIES : MAX_RETRIES);
59
+ this.retryManager = new RetryManager(MAX_RETRIES);
51
60
  }
52
61
 
53
62
  async chat(
@@ -59,12 +68,12 @@ export class DeepSeekClient {
59
68
  const body = this.buildRequestBody(messages, tools, cfg, false);
60
69
 
61
70
  const response = await this.retryManager.execute(async () => {
62
- const result = await this.retryManager.withTimeout(
71
+ return this.retryManager.withTimeout(
63
72
  () => this.makeRequest(cfg, body),
64
- cfg.maxTokens > 0 ? DEFAULT_TIMEOUT_MS : DEFAULT_TIMEOUT_MS,
73
+ DEFAULT_TIMEOUT_MS,
74
+ cfg.signal,
65
75
  );
66
- return result;
67
- }, this.shouldRetry);
76
+ }, isRetryable);
68
77
 
69
78
  const data = (await response.json()) as ChatCompletionResponse;
70
79
 
@@ -89,7 +98,7 @@ export class DeepSeekClient {
89
98
  }
90
99
 
91
100
  if (message.reasoning_content) {
92
- result.thinking = message.reasoning_content;
101
+ result.reasoning_content = message.reasoning_content;
93
102
  }
94
103
 
95
104
  return result;
@@ -104,17 +113,12 @@ export class DeepSeekClient {
104
113
  const body = this.buildRequestBody(messages, tools, cfg, true);
105
114
 
106
115
  const response = await this.retryManager.execute(async () => {
107
- const result = await this.retryManager.withTimeout(
116
+ return this.retryManager.withTimeout(
108
117
  () => this.makeRequest(cfg, body),
109
118
  DEFAULT_TIMEOUT_MS,
119
+ cfg.signal,
110
120
  );
111
- return result;
112
- }, this.shouldRetry);
113
-
114
- if (!response.ok) {
115
- const errorBody = await response.text().catch(() => '');
116
- throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorBody}`);
117
- }
121
+ }, isRetryable);
118
122
 
119
123
  if (!response.body) {
120
124
  throw new Error('Response body is empty');
@@ -201,7 +205,7 @@ export class DeepSeekClient {
201
205
  if (m.name) {
202
206
  msg.name = m.name;
203
207
  }
204
- const rc = (m as unknown as Record<string, unknown>).reasoning_content || m.thinking;
208
+ const rc = m.reasoning_content || m.thinking;
205
209
  if (rc) msg.reasoning_content = rc;
206
210
  return msg;
207
211
  }),
@@ -210,6 +214,13 @@ export class DeepSeekClient {
210
214
  stream,
211
215
  };
212
216
 
217
+ if (config.think?.enabled) {
218
+ (body as Record<string, unknown>).thinking = {
219
+ type: 'enabled',
220
+ budget_tokens: config.think.budgetTokens,
221
+ };
222
+ }
223
+
213
224
  if (tools && tools.length > 0) {
214
225
  body.tools = tools.map((tool) => ({
215
226
  type: 'function',
@@ -230,7 +241,7 @@ export class DeepSeekClient {
230
241
 
231
242
  logger.debug('DeepSeek API request', { url, model: config.model });
232
243
 
233
- const response = await fetch(url, {
244
+ const fetchOpts: RequestInit = {
234
245
  method: 'POST',
235
246
  headers: {
236
247
  'Content-Type': 'application/json',
@@ -238,7 +249,13 @@ export class DeepSeekClient {
238
249
  'Accept': 'application/json',
239
250
  },
240
251
  body,
241
- });
252
+ };
253
+
254
+ if (config.signal) {
255
+ fetchOpts.signal = config.signal;
256
+ }
257
+
258
+ const response = await fetch(url, fetchOpts);
242
259
 
243
260
  if (!response.ok) {
244
261
  const errorBody = await response.text().catch(() => '');
@@ -253,25 +270,6 @@ export class DeepSeekClient {
253
270
  return response;
254
271
  }
255
272
 
256
- private shouldRetry(error: Error, attempt: number): boolean {
257
- const message = error.message.toLowerCase();
258
-
259
- if (message.includes('429') || message.includes('rate limit')) {
260
- return true;
261
- }
262
- if (message.includes('5') && (message.includes('500') || message.includes('502') || message.includes('503') || message.includes('504'))) {
263
- return true;
264
- }
265
- if (message.includes('timeout') || message.includes('abort')) {
266
- return attempt < 2;
267
- }
268
- if (message.includes('econnreset') || message.includes('econnrefused')) {
269
- return true;
270
- }
271
-
272
- return false;
273
- }
274
-
275
273
  private parseArguments(argsStr: string): Record<string, unknown> {
276
274
  try {
277
275
  return JSON.parse(argsStr) as Record<string, unknown>;
@@ -1,3 +1,7 @@
1
+ export interface RetryOptions {
2
+ onRetry?: (error: Error, attempt: number, delayMs: number) => void;
3
+ }
4
+
1
5
  export class RetryManager {
2
6
  private maxRetries: number;
3
7
  private baseDelayMs: number;
@@ -9,7 +13,11 @@ export class RetryManager {
9
13
  this.maxDelayMs = maxDelayMs;
10
14
  }
11
15
 
12
- async execute<T>(fn: () => Promise<T>, shouldRetry?: (error: Error, attempt: number) => boolean): Promise<T> {
16
+ async execute<T>(
17
+ fn: () => Promise<T>,
18
+ shouldRetry?: (error: Error, attempt: number) => boolean,
19
+ options?: RetryOptions,
20
+ ): Promise<T> {
13
21
  let lastError: Error | undefined;
14
22
 
15
23
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -26,10 +34,15 @@ export class RetryManager {
26
34
  break;
27
35
  }
28
36
 
29
- const delay = Math.min(
30
- this.baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000,
31
- this.maxDelayMs,
32
- );
37
+ if (!shouldRetry && this.isDefaultNonRetryable(lastError)) {
38
+ break;
39
+ }
40
+
41
+ const delay = this.calculateDelay(attempt);
42
+
43
+ if (options?.onRetry) {
44
+ options.onRetry(lastError, attempt, delay);
45
+ }
33
46
 
34
47
  await this.sleep(delay);
35
48
  }
@@ -39,19 +52,24 @@ export class RetryManager {
39
52
  }
40
53
 
41
54
  withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
55
+ const controller = new AbortController();
56
+
42
57
  return new Promise<T>((resolve, reject) => {
43
58
  const timer = setTimeout(() => {
59
+ controller.abort();
44
60
  reject(new Error(`Request timed out after ${timeoutMs}ms`));
45
61
  }, timeoutMs);
46
62
 
47
63
  const onAbort = (): void => {
48
64
  clearTimeout(timer);
65
+ controller.abort();
49
66
  reject(new Error('Request was aborted'));
50
67
  };
51
68
 
52
69
  if (signal) {
53
70
  if (signal.aborted) {
54
71
  clearTimeout(timer);
72
+ controller.abort();
55
73
  reject(new Error('Request was aborted'));
56
74
  return;
57
75
  }
@@ -76,7 +94,36 @@ export class RetryManager {
76
94
  });
77
95
  }
78
96
 
79
- private sleep(ms: number): Promise<void> {
80
- return new Promise((resolve) => setTimeout(resolve, ms));
97
+ private calculateDelay(attempt: number): number {
98
+ const exponentialDelay = this.baseDelayMs * Math.pow(2, attempt);
99
+ const jitter = Math.random() * exponentialDelay;
100
+ return Math.min(exponentialDelay + jitter, this.maxDelayMs);
101
+ }
102
+
103
+ private isDefaultNonRetryable(error: Error): boolean {
104
+ const msg = error.message.toLowerCase();
105
+ if (msg.includes('400') && !msg.includes('429')) return true;
106
+ if (msg.includes('401') || msg.includes('unauthorized')) return true;
107
+ if (msg.includes('403') || msg.includes('forbidden')) return true;
108
+ if (msg.includes('404') || msg.includes('not found')) return true;
109
+ return false;
110
+ }
111
+
112
+ private sleep(ms: number, signal?: AbortSignal): Promise<void> {
113
+ return new Promise((resolve, reject) => {
114
+ const timer = setTimeout(resolve, ms);
115
+ if (signal) {
116
+ const onAbort = () => {
117
+ clearTimeout(timer);
118
+ reject(new Error('Retry sleep was aborted'));
119
+ };
120
+ if (signal.aborted) {
121
+ clearTimeout(timer);
122
+ reject(new Error('Retry sleep was aborted'));
123
+ return;
124
+ }
125
+ signal.addEventListener('abort', onAbort, { once: true });
126
+ }
127
+ });
81
128
  }
82
129
  }
@@ -7,17 +7,27 @@ interface ParsedSSEEvent {
7
7
  id?: string;
8
8
  }
9
9
 
10
+ interface PendingToolCall {
11
+ id: string;
12
+ name: string;
13
+ argsStr: string;
14
+ index: number;
15
+ started: boolean;
16
+ }
17
+
10
18
  export class StreamHandler {
11
19
  private textBuffer: string;
12
20
  private thinkingBuffer: string;
13
- private toolCallBuffer: Map<number, ToolCall>;
21
+ private toolCallBuffer: Map<number, PendingToolCall>;
14
22
  private finished: boolean;
23
+ private lastYieldedIndex: number;
15
24
 
16
25
  constructor() {
17
26
  this.textBuffer = '';
18
27
  this.thinkingBuffer = '';
19
28
  this.toolCallBuffer = new Map();
20
29
  this.finished = false;
30
+ this.lastYieldedIndex = -1;
21
31
  }
22
32
 
23
33
  reset(): void {
@@ -25,11 +35,20 @@ export class StreamHandler {
25
35
  this.thinkingBuffer = '';
26
36
  this.toolCallBuffer.clear();
27
37
  this.finished = false;
38
+ this.lastYieldedIndex = -1;
28
39
  }
29
40
 
30
41
  handleEvent(event: string, data: string): StreamChunk | StreamChunk[] | null {
42
+ if (event === 'error') {
43
+ return { type: 'error', error: data || 'SSE error event' };
44
+ }
45
+
31
46
  if (event === '[DONE]' || data === '[DONE]') {
47
+ const results = this.finalizePendingToolCalls();
32
48
  this.finished = true;
49
+ if (results.length > 0) {
50
+ return [...results, { type: 'done' } as StreamChunk];
51
+ }
33
52
  return { type: 'done' };
34
53
  }
35
54
 
@@ -49,12 +68,24 @@ export class StreamHandler {
49
68
  const delta = choice.delta as Record<string, unknown> | undefined;
50
69
 
51
70
  if (!delta) {
71
+ const finishReason = choice.finish_reason as string | undefined;
72
+ if (finishReason === 'stop' || finishReason === 'length' || finishReason === 'tool_calls') {
73
+ const results = this.finalizePendingToolCalls();
74
+ this.finished = true;
75
+ if (results.length > 0) {
76
+ return [...results, { type: 'done' } as StreamChunk];
77
+ }
78
+ return { type: 'done' };
79
+ }
52
80
  return null;
53
81
  }
54
82
 
55
83
  if (delta.reasoning_content) {
56
84
  const thinkingChunk = delta.reasoning_content as string;
57
85
  this.thinkingBuffer += thinkingChunk;
86
+ if (this.thinkingBuffer.length > 100_000) {
87
+ this.thinkingBuffer = this.thinkingBuffer.slice(-80_000);
88
+ }
58
89
  return {
59
90
  type: 'thinking',
60
91
  content: thinkingChunk,
@@ -68,6 +99,9 @@ export class StreamHandler {
68
99
  if (delta.content) {
69
100
  const textChunk = delta.content as string;
70
101
  this.textBuffer += textChunk;
102
+ if (this.textBuffer.length > 500_000) {
103
+ this.textBuffer = this.textBuffer.slice(-400_000);
104
+ }
71
105
  return {
72
106
  type: 'text',
73
107
  content: textChunk,
@@ -76,7 +110,11 @@ export class StreamHandler {
76
110
 
77
111
  const finishReason = choice.finish_reason as string | undefined;
78
112
  if (finishReason === 'stop' || finishReason === 'length' || finishReason === 'tool_calls') {
113
+ const results = this.finalizePendingToolCalls();
79
114
  this.finished = true;
115
+ if (results.length > 0) {
116
+ return [...results, { type: 'done' } as StreamChunk];
117
+ }
80
118
  return { type: 'done' };
81
119
  }
82
120
  } catch {
@@ -99,10 +137,16 @@ export class StreamHandler {
99
137
 
100
138
  getToolCalls(): ToolCall[] {
101
139
  const result: ToolCall[] = [];
102
- for (let i = 0; i < this.toolCallBuffer.size; i++) {
103
- const tc = this.toolCallBuffer.get(i);
104
- if (tc) {
105
- result.push(tc);
140
+ const indices = [...this.toolCallBuffer.keys()].sort((a, b) => a - b);
141
+ for (const idx of indices) {
142
+ const pending = this.toolCallBuffer.get(idx);
143
+ if (pending) {
144
+ result.push({
145
+ id: pending.id,
146
+ name: pending.name,
147
+ arguments: this.parseArgsStr(pending.argsStr),
148
+ index: pending.index,
149
+ });
106
150
  }
107
151
  }
108
152
  return result;
@@ -114,6 +158,7 @@ export class StreamHandler {
114
158
 
115
159
  private handleToolCallsDelta(toolCalls: Array<Record<string, unknown>>): StreamChunk[] {
116
160
  const results: StreamChunk[] = [];
161
+
117
162
  for (const tc of toolCalls) {
118
163
  const index = tc.index as number;
119
164
  const id = tc.id as string | undefined;
@@ -123,8 +168,9 @@ export class StreamHandler {
123
168
  this.toolCallBuffer.set(index, {
124
169
  id: id ?? '',
125
170
  name: fn?.name as string ?? '',
126
- arguments: {},
171
+ argsStr: '',
127
172
  index,
173
+ started: false,
128
174
  });
129
175
  }
130
176
 
@@ -137,24 +183,72 @@ export class StreamHandler {
137
183
  existing.name = fn.name as string;
138
184
  }
139
185
  if (fn?.arguments) {
140
- try {
141
- const argsStr = fn.arguments as string;
142
- const parsed = JSON.parse(argsStr) as Record<string, unknown>;
143
- existing.arguments = { ...existing.arguments, ...parsed };
144
- } catch {
145
- existing.arguments = existing.arguments || {};
146
- }
186
+ existing.argsStr += fn.arguments as string;
147
187
  }
148
188
 
149
- const entry = this.toolCallBuffer.get(index);
150
- if (entry) {
189
+ if (!existing.started) {
190
+ existing.started = true;
191
+ if (this.lastYieldedIndex >= 0) {
192
+ const prev = this.toolCallBuffer.get(this.lastYieldedIndex);
193
+ if (prev && prev !== existing) {
194
+ results.push({
195
+ type: 'tool_call_end',
196
+ tool_call: {
197
+ id: prev.id,
198
+ name: prev.name,
199
+ arguments: this.parseArgsStr(prev.argsStr),
200
+ index: prev.index,
201
+ },
202
+ } as StreamChunk);
203
+ }
204
+ }
205
+ this.lastYieldedIndex = index;
151
206
  results.push({
152
- type: 'tool_call',
153
- tool_call: { ...entry, arguments: { ...entry.arguments } },
207
+ type: 'tool_call_start',
208
+ tool_call: { id: existing.id, name: existing.name, index: existing.index },
154
209
  } as StreamChunk);
155
210
  }
156
211
  }
157
212
 
158
213
  return results;
159
214
  }
215
+
216
+ private finalizePendingToolCalls(): StreamChunk[] {
217
+ const results: StreamChunk[] = [];
218
+ if (this.lastYieldedIndex >= 0) {
219
+ const last = this.toolCallBuffer.get(this.lastYieldedIndex);
220
+ if (last) {
221
+ results.push({
222
+ type: 'tool_call_end',
223
+ tool_call: {
224
+ id: last.id,
225
+ name: last.name,
226
+ arguments: this.parseArgsStr(last.argsStr),
227
+ index: last.index,
228
+ },
229
+ } as StreamChunk);
230
+ }
231
+ this.lastYieldedIndex = -1;
232
+ }
233
+ return results;
234
+ }
235
+
236
+ private parseArgsStr(argsStr: string): Record<string, unknown> {
237
+ if (!argsStr) return {};
238
+ try {
239
+ return JSON.parse(argsStr) as Record<string, unknown>;
240
+ } catch {
241
+ const trimmed = argsStr.trim();
242
+ if (trimmed.startsWith('{') && !trimmed.endsWith('}')) {
243
+ try {
244
+ return JSON.parse(trimmed + '}') as Record<string, unknown>;
245
+ } catch {
246
+ try {
247
+ return JSON.parse(trimmed + '}}') as Record<string, unknown>;
248
+ } catch {}
249
+ }
250
+ }
251
+ return {};
252
+ }
253
+ }
160
254
  }
@@ -5,17 +5,17 @@ export interface ChatMessage {
5
5
  role: 'system' | 'user' | 'assistant' | 'tool';
6
6
  content: string | null;
7
7
  tool_calls?: ToolCall[];
8
- toolCalls?: ToolCallRecord[];
9
8
  tool_call_id?: string;
10
9
  name?: string;
10
+ reasoning_content?: string;
11
11
  thinking?: string;
12
12
  timestamp?: number;
13
13
  }
14
14
 
15
15
  export interface StreamChunk {
16
- type: 'text' | 'thinking' | 'tool_call' | 'done' | 'error';
16
+ type: 'text' | 'thinking' | 'tool_call_start' | 'tool_call_end' | 'tool_call' | 'done' | 'error';
17
17
  content?: string;
18
- tool_call?: ToolCall;
18
+ tool_call?: ToolCall | { id: string; name: string; index?: number };
19
19
  error?: string;
20
20
  }
21
21
 
@@ -29,6 +29,7 @@ export interface DeepSeekConfig {
29
29
  enabled: boolean;
30
30
  budgetTokens: number;
31
31
  };
32
+ signal?: AbortSignal;
32
33
  }
33
34
 
34
35
  export interface ToolCallRecord {
@@ -56,13 +57,31 @@ export const SLASH_COMMANDS: SlashCommand[] = [
56
57
  { command: '/help', description: '显示帮助信息' },
57
58
  { command: '/clear', description: '清空对话' },
58
59
  { command: '/quit', description: '退出 DeeperCode' },
60
+ { command: '/save [name]', description: '保存当前会话' },
61
+ { command: '/load [name]', description: '加载历史会话' },
62
+ { command: '/resume [name]', description: '恢复历史会话' },
63
+ { command: '/sessions', description: '会话列表' },
59
64
  { command: '/model', description: '查看/切换模型' },
60
65
  { command: '/config', description: '查看/修改配置' },
61
- { command: '/tools', description: '列出可用工具' },
66
+ { command: '/tools [cat]', description: '列出可用工具' },
62
67
  { command: '/skills', description: '列出已加载技能' },
63
68
  { command: '/mcp', description: '管理 MCP 连接' },
64
- { command: '/save', description: '保存当前会话' },
65
- { command: '/load', description: '加载历史会话' },
69
+ { command: '/memory', description: '记忆系统' },
70
+ { command: '/tasks', description: '任务列表' },
71
+ { command: '/rules', description: '规则管理' },
72
+ { command: '/stats', description: '统计信息' },
73
+ { command: '/status', description: '当前状态' },
74
+ { command: '/cwd', description: '当前目录' },
75
+ { command: '/export', description: '导出对话' },
76
+ { command: '/init', description: '初始化项目' },
77
+ { command: '/plan <任务>', description: '先出方案再实施' },
78
+ { command: '/spec <任务>', description: '先出规格再实施' },
79
+ { command: '/review <路径>', description: '代码审查' },
80
+ { command: '/fix [目标]', description: '自动修复构建/测试错误' },
81
+ { command: '/commit', description: '智能分析变更并提交' },
82
+ { command: '/analyze [路径]', description: '项目架构分析' },
83
+ { command: '/diff <文件>', description: '查看文件变更' },
84
+ { command: '/undo', description: '撤销最近文件修改' },
66
85
  ];
67
86
 
68
87
  export type DeepSeekMessage = ChatMessage;