deeper-cli 1.0.5 → 1.2.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.
@@ -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');
@@ -145,7 +149,8 @@ export class DeepSeekClient {
145
149
  const data = trimmed.slice(6);
146
150
  const result = handler.handleEvent('message', data);
147
151
  if (result) {
148
- yield result;
152
+ if (Array.isArray(result)) { for (const r of result) yield r; }
153
+ else yield result;
149
154
  }
150
155
  } else if (trimmed === 'data: [DONE]') {
151
156
  yield { type: 'done' };
@@ -160,7 +165,8 @@ export class DeepSeekClient {
160
165
  const data = trimmed.slice(6);
161
166
  const result = handler.handleEvent('message', data);
162
167
  if (result) {
163
- yield result;
168
+ if (Array.isArray(result)) { for (const r of result) yield r; }
169
+ else yield result;
164
170
  }
165
171
  }
166
172
  }
@@ -199,7 +205,7 @@ export class DeepSeekClient {
199
205
  if (m.name) {
200
206
  msg.name = m.name;
201
207
  }
202
- const rc = (m as unknown as Record<string, unknown>).reasoning_content || m.thinking;
208
+ const rc = m.reasoning_content || m.thinking;
203
209
  if (rc) msg.reasoning_content = rc;
204
210
  return msg;
205
211
  }),
@@ -208,6 +214,13 @@ export class DeepSeekClient {
208
214
  stream,
209
215
  };
210
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
+
211
224
  if (tools && tools.length > 0) {
212
225
  body.tools = tools.map((tool) => ({
213
226
  type: 'function',
@@ -228,7 +241,7 @@ export class DeepSeekClient {
228
241
 
229
242
  logger.debug('DeepSeek API request', { url, model: config.model });
230
243
 
231
- const response = await fetch(url, {
244
+ const fetchOpts: RequestInit = {
232
245
  method: 'POST',
233
246
  headers: {
234
247
  'Content-Type': 'application/json',
@@ -236,7 +249,13 @@ export class DeepSeekClient {
236
249
  'Accept': 'application/json',
237
250
  },
238
251
  body,
239
- });
252
+ };
253
+
254
+ if (config.signal) {
255
+ fetchOpts.signal = config.signal;
256
+ }
257
+
258
+ const response = await fetch(url, fetchOpts);
240
259
 
241
260
  if (!response.ok) {
242
261
  const errorBody = await response.text().catch(() => '');
@@ -251,25 +270,6 @@ export class DeepSeekClient {
251
270
  return response;
252
271
  }
253
272
 
254
- private shouldRetry(error: Error, attempt: number): boolean {
255
- const message = error.message.toLowerCase();
256
-
257
- if (message.includes('429') || message.includes('rate limit')) {
258
- return true;
259
- }
260
- if (message.includes('5') && (message.includes('500') || message.includes('502') || message.includes('503') || message.includes('504'))) {
261
- return true;
262
- }
263
- if (message.includes('timeout') || message.includes('abort')) {
264
- return attempt < 2;
265
- }
266
- if (message.includes('econnreset') || message.includes('econnrefused')) {
267
- return true;
268
- }
269
-
270
- return false;
271
- }
272
-
273
273
  private parseArguments(argsStr: string): Record<string, unknown> {
274
274
  try {
275
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
- handleEvent(event: string, data: string): StreamChunk | null {
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;
@@ -112,7 +156,9 @@ export class StreamHandler {
112
156
  return this.finished;
113
157
  }
114
158
 
115
- private handleToolCallsDelta(toolCalls: Array<Record<string, unknown>>): StreamChunk | null {
159
+ private handleToolCallsDelta(toolCalls: Array<Record<string, unknown>>): StreamChunk[] {
160
+ const results: StreamChunk[] = [];
161
+
116
162
  for (const tc of toolCalls) {
117
163
  const index = tc.index as number;
118
164
  const id = tc.id as string | undefined;
@@ -122,7 +168,9 @@ export class StreamHandler {
122
168
  this.toolCallBuffer.set(index, {
123
169
  id: id ?? '',
124
170
  name: fn?.name as string ?? '',
125
- arguments: {},
171
+ argsStr: '',
172
+ index,
173
+ started: false,
126
174
  });
127
175
  }
128
176
 
@@ -135,24 +183,72 @@ export class StreamHandler {
135
183
  existing.name = fn.name as string;
136
184
  }
137
185
  if (fn?.arguments) {
138
- try {
139
- const argsStr = fn.arguments as string;
140
- const parsed = JSON.parse(argsStr) as Record<string, unknown>;
141
- existing.arguments = { ...existing.arguments, ...parsed };
142
- } catch {
143
- existing.arguments = existing.arguments || {};
186
+ existing.argsStr += fn.arguments as string;
187
+ }
188
+
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
+ }
144
204
  }
205
+ this.lastYieldedIndex = index;
206
+ results.push({
207
+ type: 'tool_call_start',
208
+ tool_call: { id: existing.id, name: existing.name, index: existing.index },
209
+ } as StreamChunk);
145
210
  }
146
211
  }
147
212
 
148
- const currentCall = this.toolCallBuffer.get(toolCalls[0].index as number);
149
- if (currentCall) {
150
- return {
151
- type: 'tool_call',
152
- tool_call: { ...currentCall, arguments: { ...currentCall.arguments } },
153
- };
213
+ return results;
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;
154
232
  }
233
+ return results;
234
+ }
155
235
 
156
- return null;
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
+ }
157
253
  }
158
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;
@@ -43,6 +43,7 @@ export interface ToolCall {
43
43
  id: string;
44
44
  name: string;
45
45
  arguments: Record<string, unknown>;
46
+ index?: number;
46
47
  }
47
48
 
48
49
  export interface ToolCallResult {