@leviyuan/lodestar 0.1.0 → 2.0.14

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.
package/runtime-api.ts ADDED
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Runtime API client for DeepSeek TUI serve --http.
3
+ *
4
+ * Encapsulates HTTP calls to the Runtime API, per-thread message queueing,
5
+ * SSE event streaming, and MCP config injection.
6
+ *
7
+ * Verified against: deepseek 0.8.31, serve --http --port 7878
8
+ * Design doc: LODESTAR_DEEPSEEK_DESIGN.md §5-7
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
12
+ import { homedir } from 'os'
13
+ import { join } from 'path'
14
+
15
+ // ── API response types (verified against deepseek 0.8.31) ──────────────────
16
+
17
+ export interface ThreadInfo {
18
+ schema_version: number
19
+ id: string
20
+ created_at: string
21
+ updated_at: string
22
+ model: string
23
+ workspace: string
24
+ mode: string
25
+ allow_shell: boolean
26
+ trust_mode: boolean
27
+ auto_approve: boolean
28
+ archived: boolean
29
+ system_prompt?: string
30
+ coherence_state: string
31
+ latest_turn_id?: string
32
+ }
33
+
34
+ export interface TurnInfo {
35
+ schema_version: number
36
+ id: string
37
+ thread_id: string
38
+ status: string // 'in_progress' | 'completed' | 'error'
39
+ input_summary: string
40
+ created_at: string
41
+ started_at: string
42
+ ended_at?: string
43
+ duration_ms?: number
44
+ usage?: {
45
+ input_tokens: number
46
+ output_tokens: number
47
+ prompt_cache_hit_tokens: number
48
+ prompt_cache_miss_tokens: number
49
+ reasoning_tokens: number
50
+ }
51
+ item_ids: string[]
52
+ steer_count: number
53
+ }
54
+
55
+ export interface ItemInfo {
56
+ schema_version: number
57
+ id: string
58
+ turn_id: string
59
+ kind: 'user_message' | 'agent_reasoning' | 'agent_message' | 'tool_call' | 'status'
60
+ status: string
61
+ summary: string
62
+ detail: string
63
+ text?: string
64
+ artifact_refs: string[]
65
+ started_at: string
66
+ ended_at: string
67
+ }
68
+
69
+ export interface ThreadDetail {
70
+ thread: ThreadInfo
71
+ turns: TurnInfo[]
72
+ items: ItemInfo[]
73
+ }
74
+
75
+ export interface TurnResponse {
76
+ thread: ThreadInfo
77
+ turn: TurnInfo
78
+ }
79
+
80
+ // ── SSE event types (verified) ─────────────────────────────────────────────
81
+
82
+ export interface SseRawEvent {
83
+ seq: number
84
+ timestamp: string
85
+ thread_id: string
86
+ turn_id: string | null
87
+ item_id: string | null
88
+ event: string
89
+ payload: any
90
+ }
91
+
92
+ export interface SseDelta {
93
+ kind: 'agent_reasoning' | 'agent_message' | 'tool_call' | 'user_message'
94
+ delta: string
95
+ item_id?: string
96
+ }
97
+
98
+ export interface ApiConfig {
99
+ baseUrl: string // e.g. http://localhost:7878
100
+ authToken?: string // optional bearer token
101
+ }
102
+
103
+ // ── RuntimeApiClient ─────────────────────────────────────────────────────────
104
+
105
+ export class RuntimeApiClient {
106
+ private baseUrl: string
107
+ private authToken: string
108
+
109
+ constructor(config: ApiConfig) {
110
+ this.baseUrl = config.baseUrl.replace(/\/$/, '')
111
+ this.authToken = config.authToken || ''
112
+ }
113
+
114
+ private headers(): Record<string, string> {
115
+ const h: Record<string, string> = { 'Content-Type': 'application/json' }
116
+ if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`
117
+ return h
118
+ }
119
+
120
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
121
+ const url = `${this.baseUrl}${path}`
122
+ const res = await fetch(url, {
123
+ method,
124
+ headers: this.headers(),
125
+ body: body ? JSON.stringify(body) : undefined,
126
+ })
127
+ if (!res.ok) {
128
+ const text = await res.text()
129
+ throw new Error(`Runtime API ${method} ${path} failed (${res.status}): ${text.slice(0, 500)}`)
130
+ }
131
+ return res.json() as T
132
+ }
133
+
134
+ /** Check if serve --http is alive. */
135
+ async healthCheck(): Promise<boolean> {
136
+ try {
137
+ const res = await fetch(`${this.baseUrl}/health`)
138
+ return res.ok
139
+ } catch { return false }
140
+ }
141
+
142
+ /** Create a new thread. */
143
+ async createThread(params: {
144
+ title: string
145
+ workspace: string
146
+ mode?: string
147
+ auto_approve?: boolean
148
+ system_prompt?: string
149
+ model?: string
150
+ }): Promise<ThreadInfo> {
151
+ return this.request('POST', '/v1/threads', {
152
+ title: params.title,
153
+ workspace: params.workspace,
154
+ mode: params.mode ?? 'yolo',
155
+ auto_approve: params.auto_approve ?? true,
156
+ ...(params.system_prompt ? { system_prompt: params.system_prompt } : {}),
157
+ ...(params.model ? { model: params.model } : {}),
158
+ })
159
+ }
160
+
161
+ /** Get thread detail (includes turns and items). */
162
+ async getThread(threadId: string): Promise<ThreadDetail> {
163
+ return this.request('GET', `/v1/threads/${threadId}`)
164
+ }
165
+
166
+ /** Get thread metadata only (no turns/items). Returns null if not found. */
167
+ async getThreadInfo(threadId: string): Promise<ThreadInfo | null> {
168
+ try {
169
+ const detail = await this.getThread(threadId)
170
+ return detail.thread
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ /** List all threads (returns array directly). */
177
+ async listThreads(): Promise<ThreadInfo[]> {
178
+ return this.request<ThreadInfo[]>('GET', '/v1/threads')
179
+ }
180
+
181
+ /** Patch thread (e.g. archive). */
182
+ async patchThread(threadId: string, patch: Record<string, unknown>): Promise<ThreadInfo> {
183
+ return this.request('PATCH', `/v1/threads/${threadId}`, patch)
184
+ }
185
+
186
+ /** Archive a thread (equivalent to `kill`). */
187
+ async archiveThread(threadId: string): Promise<ThreadInfo> {
188
+ return this.patchThread(threadId, { archived: true })
189
+ }
190
+
191
+ /** Fork a thread (equivalent to `restart`). */
192
+ async forkThread(threadId: string): Promise<ThreadInfo> {
193
+ return this.request('POST', `/v1/threads/${threadId}/fork`, {})
194
+ }
195
+
196
+ /** Compact a thread (equivalent to `clear`). */
197
+ async compactThread(threadId: string): Promise<unknown> {
198
+ return this.request('POST', `/v1/threads/${threadId}/compact`, {})
199
+ }
200
+
201
+ /** Inject a new turn (message) into a thread. Returns {thread, turn}. */
202
+ async createTurn(threadId: string, params: {
203
+ prompt: string
204
+ auto_approve?: boolean
205
+ mode?: string
206
+ }): Promise<TurnResponse> {
207
+ return this.request('POST', `/v1/threads/${threadId}/turns`, {
208
+ prompt: params.prompt,
209
+ auto_approve: params.auto_approve ?? true,
210
+ mode: params.mode ?? 'yolo',
211
+ })
212
+ }
213
+
214
+ /** Interrupt the currently active turn. */
215
+ async interruptTurn(threadId: string, turnId: string): Promise<unknown> {
216
+ return this.request('POST', `/v1/threads/${threadId}/turns/${turnId}/interrupt`, {})
217
+ }
218
+
219
+ /** Steer (append context) to an active turn. */
220
+ async steerTurn(threadId: string, turnId: string, context: string): Promise<unknown> {
221
+ return this.request('POST', `/v1/threads/${threadId}/turns/${turnId}/steer`, {
222
+ context,
223
+ })
224
+ }
225
+
226
+ /** Get SSE event stream for a thread. */
227
+ async eventsStream(threadId: string): Promise<Response> {
228
+ const url = `${this.baseUrl}/v1/threads/${threadId}/events`
229
+ const headers: Record<string, string> = { Accept: 'text/event-stream' }
230
+ if (this.authToken) headers['Authorization'] = `Bearer ${this.authToken}`
231
+ const res = await fetch(url, { headers })
232
+ if (!res.ok) {
233
+ const text = await res.text()
234
+ throw new Error(`SSE stream failed (${res.status}): ${text.slice(0, 500)}`)
235
+ }
236
+ return res
237
+ }
238
+
239
+ /** Handle an approval. */
240
+ async handleApproval(approvalId: string, decision: 'allow' | 'deny'): Promise<unknown> {
241
+ return this.request('POST', `/v1/approvals/${approvalId}`, { decision })
242
+ }
243
+ }
244
+
245
+ // ── MessageQueue ─────────────────────────────────────────────────────────────
246
+
247
+ export interface QueuedMessage {
248
+ chatId: string
249
+ messageId: string
250
+ sender: string
251
+ text: string
252
+ filePath?: string
253
+ timestamp: number
254
+ }
255
+
256
+ export class MessageQueue {
257
+ private queues = new Map<string, QueuedMessage[]>()
258
+ private activeTurns = new Map<string, string>() // threadId → turnId
259
+
260
+ enqueue(threadId: string, msg: QueuedMessage): void {
261
+ if (!this.queues.has(threadId)) {
262
+ this.queues.set(threadId, [])
263
+ }
264
+ this.queues.get(threadId)!.push(msg)
265
+ }
266
+
267
+ dequeue(threadId: string): QueuedMessage | undefined {
268
+ return this.queues.get(threadId)?.shift()
269
+ }
270
+
271
+ hasPending(threadId: string): boolean {
272
+ const q = this.queues.get(threadId)
273
+ return q !== undefined && q.length > 0
274
+ }
275
+
276
+ queueSize(threadId: string): number {
277
+ return this.queues.get(threadId)?.length ?? 0
278
+ }
279
+
280
+ setActiveTurn(threadId: string, turnId: string): void {
281
+ this.activeTurns.set(threadId, turnId)
282
+ }
283
+
284
+ clearActiveTurn(threadId: string): void {
285
+ this.activeTurns.delete(threadId)
286
+ }
287
+
288
+ hasActiveTurn(threadId: string): boolean {
289
+ return this.activeTurns.has(threadId)
290
+ }
291
+
292
+ getActiveTurnId(threadId: string): string | undefined {
293
+ return this.activeTurns.get(threadId)
294
+ }
295
+
296
+ clearThread(threadId: string): void {
297
+ this.queues.delete(threadId)
298
+ this.activeTurns.delete(threadId)
299
+ }
300
+ }
301
+
302
+ // ── SSE Event parsing ──────────────────────────────────────────────────────
303
+
304
+ export function parseSseLine(line: string): SseRawEvent | null {
305
+ if (!line || line.startsWith(':')) return null
306
+ if (line.startsWith('data: ')) {
307
+ try {
308
+ return JSON.parse(line.slice(6))
309
+ } catch {
310
+ return null
311
+ }
312
+ }
313
+ return null
314
+ }
315
+
316
+ // ── SseEventHandler ──────────────────────────────────────────────────────────
317
+
318
+ export type DeltaHandler = (threadId: string, delta: SseDelta) => void
319
+ export type TurnCompletedHandler = (threadId: string, turn: TurnInfo) => void
320
+ export type ApprovalHandler = (threadId: string, approval: any) => void
321
+ export type ItemCompletedHandler = (threadId: string, item: ItemInfo) => void
322
+
323
+ export class SseEventHandler {
324
+ private connections = new Map<string, AbortController>()
325
+ private api: RuntimeApiClient
326
+
327
+ onDelta: DeltaHandler | null = null
328
+ onTurnCompleted: TurnCompletedHandler | null = null
329
+ onApprovalRequired: ApprovalHandler | null = null
330
+ onItemCompleted: ItemCompletedHandler | null = null
331
+
332
+ constructor(api: RuntimeApiClient) {
333
+ this.api = api
334
+ }
335
+
336
+ async connect(threadId: string): Promise<void> {
337
+ this.disconnect(threadId)
338
+
339
+ const controller = new AbortController()
340
+ this.connections.set(threadId, controller)
341
+
342
+ const res = await this.api.eventsStream(threadId)
343
+ if (!res.body) {
344
+ throw new Error('SSE response has no body')
345
+ }
346
+
347
+ this.readStream(threadId, res.body, controller.signal).catch(err => {
348
+ console.error(`[sse] stream error for thread ${threadId}:`, err)
349
+ })
350
+ }
351
+
352
+ private async readStream(threadId: string, body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
353
+ const reader = body.getReader()
354
+ const decoder = new TextDecoder('utf-8')
355
+ let buffer = ''
356
+
357
+ try {
358
+ while (!signal.aborted) {
359
+ const { done, value } = await reader.read()
360
+ if (done) break
361
+
362
+ buffer += decoder.decode(value, { stream: true })
363
+ const lines = buffer.split('\n')
364
+ buffer = lines.pop() ?? ''
365
+
366
+ for (const line of lines) {
367
+ const trimmed = line.trimEnd()
368
+ if (!trimmed || trimmed.startsWith(':')) continue
369
+ if (trimmed.startsWith('event: ')) continue // event type is in the data payload
370
+
371
+ if (trimmed.startsWith('data: ')) {
372
+ try {
373
+ const event: SseRawEvent = JSON.parse(trimmed.slice(6))
374
+ this.dispatch(threadId, event)
375
+ } catch {
376
+ // ignore malformed JSON
377
+ }
378
+ }
379
+ }
380
+ }
381
+ } finally {
382
+ reader.releaseLock()
383
+ }
384
+ }
385
+
386
+ private dispatch(threadId: string, event: SseRawEvent): void {
387
+ switch (event.event) {
388
+ case 'item.delta': {
389
+ if (this.onDelta && event.payload) {
390
+ this.onDelta(threadId, {
391
+ kind: event.payload.kind,
392
+ delta: event.payload.delta ?? '',
393
+ item_id: event.item_id ?? undefined,
394
+ })
395
+ }
396
+ break
397
+ }
398
+
399
+ case 'turn.completed': {
400
+ if (this.onTurnCompleted && event.payload?.turn) {
401
+ this.onTurnCompleted(threadId, event.payload.turn as TurnInfo)
402
+ }
403
+ break
404
+ }
405
+
406
+ case 'item.completed': {
407
+ if (this.onItemCompleted && event.payload?.item) {
408
+ this.onItemCompleted(threadId, event.payload.item as ItemInfo)
409
+ }
410
+ break
411
+ }
412
+
413
+ case 'approval.required': {
414
+ if (this.onApprovalRequired) {
415
+ this.onApprovalRequired(threadId, event.payload)
416
+ }
417
+ break
418
+ }
419
+
420
+ case 'turn.started':
421
+ case 'thread.started':
422
+ case 'item.started':
423
+ case 'turn.lifecycle':
424
+ // Informational, can be logged but not actionable
425
+ break
426
+
427
+ default:
428
+ break
429
+ }
430
+ }
431
+
432
+ disconnect(threadId: string): void {
433
+ const controller = this.connections.get(threadId)
434
+ if (controller) {
435
+ controller.abort()
436
+ this.connections.delete(threadId)
437
+ }
438
+ }
439
+
440
+ disconnectAll(): void {
441
+ for (const [threadId, controller] of this.connections.entries()) {
442
+ controller.abort()
443
+ }
444
+ this.connections.clear()
445
+ }
446
+ }
447
+
448
+ // ── ThreadMap ────────────────────────────────────────────────────────────────
449
+
450
+ const THREAD_MAP_FILE = join(homedir(), '.deepseek', 'lodestar', 'session-thread-map.json')
451
+
452
+ export class ThreadMap {
453
+ private map = new Map<string, string>() // sessionName → threadId
454
+
455
+ constructor() {
456
+ this.load()
457
+ }
458
+
459
+ private load(): void {
460
+ try {
461
+ const raw = readFileSync(THREAD_MAP_FILE, 'utf-8')
462
+ const obj = JSON.parse(raw)
463
+ for (const [name, id] of Object.entries(obj)) {
464
+ if (typeof id === 'string') this.map.set(name, id)
465
+ }
466
+ } catch {}
467
+ }
468
+
469
+ private save(): void {
470
+ try {
471
+ const dir = join(homedir(), '.deepseek', 'lodestar')
472
+ mkdirSync(dir, { recursive: true })
473
+ const obj: Record<string, string> = {}
474
+ for (const [name, id] of this.map.entries()) obj[name] = id
475
+ writeFileSync(THREAD_MAP_FILE, JSON.stringify(obj, null, 2))
476
+ } catch (err) {
477
+ console.error(`[thread-map] save failed: ${err}`)
478
+ }
479
+ }
480
+
481
+ getThreadId(sessionName: string): string | undefined {
482
+ return this.map.get(sessionName)
483
+ }
484
+
485
+ setThreadId(sessionName: string, threadId: string): void {
486
+ if (this.map.get(sessionName) === threadId) return
487
+ this.map.set(sessionName, threadId)
488
+ this.save()
489
+ }
490
+
491
+ removeThreadId(sessionName: string): void {
492
+ this.map.delete(sessionName)
493
+ this.save()
494
+ }
495
+
496
+ getSessionName(threadId: string): string | undefined {
497
+ for (const [name, id] of this.map.entries()) {
498
+ if (id === threadId) return name
499
+ }
500
+ return undefined
501
+ }
502
+
503
+ entries(): IterableIterator<[string, string]> {
504
+ return this.map.entries()
505
+ }
506
+
507
+ get size(): number {
508
+ return this.map.size
509
+ }
510
+ }
511
+
512
+ // ── McpConfigInjector ────────────────────────────────────────────────────────
513
+
514
+ const MCP_JSON_PATH = join(homedir(), '.deepseek', 'mcp.json')
515
+
516
+ export interface McpServerEntry {
517
+ command: string
518
+ args: string[]
519
+ env?: Record<string, string>
520
+ }
521
+
522
+ export function injectFeishuMcpConfig(): void {
523
+ let config: Record<string, McpServerEntry> = {}
524
+ try {
525
+ if (existsSync(MCP_JSON_PATH)) {
526
+ config = JSON.parse(readFileSync(MCP_JSON_PATH, 'utf-8'))
527
+ }
528
+ } catch {
529
+ config = {}
530
+ }
531
+
532
+ const mcpServerPath = join(homedir(), '.deepseek', 'lodestar', 'feishu-mcp.ts')
533
+
534
+ if (config.feishu) {
535
+ config.feishu.command = 'bun'
536
+ config.feishu.args = ['run', mcpServerPath]
537
+ console.log('[mcp-inject] feishu MCP entry already exists, updated path')
538
+ } else {
539
+ config.feishu = {
540
+ command: 'bun',
541
+ args: ['run', mcpServerPath],
542
+ }
543
+ console.log('[mcp-inject] added feishu MCP entry to mcp.json')
544
+ }
545
+
546
+ const dir = join(homedir(), '.deepseek')
547
+ mkdirSync(dir, { recursive: true })
548
+ writeFileSync(MCP_JSON_PATH, JSON.stringify(config, null, 2))
549
+ }
550
+
551
+ export function buildSystemPrompt(groupName: string, chatId: string, workspace: string): string {
552
+ return [
553
+ `你是飞书群「${groupName}」的 AI 助手。`,
554
+ '',
555
+ `当前群聊 chat_id: ${chatId}`,
556
+ `工作目录: ${workspace}`,
557
+ '',
558
+ '你可以使用以下工具与飞书群交互:',
559
+ '- feishu_reply: 发送消息到群里(支持 Markdown 卡片渲染)',
560
+ '- feishu_react: 给消息添加表情反应',
561
+ '- feishu_send_file: 发送文件或图片到群里',
562
+ '- feishu_fetch_history: 获取群聊历史消息',
563
+ '',
564
+ '使用 feishu_reply 回复时,chat_id 固定为上述 chat_id。',
565
+ '所有你觉得用户应该看到的内容,都通过 feishu_reply 发送到群里。',
566
+ '',
567
+ '你是「夜航星」驱动的 AI 助手,保持高效、直接、有洞察力的沟通风格。',
568
+ ].join('\n')
569
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # ── 夜航星 Runtime API 线程管理工具 ────────────────────────────────────
3
+ # 用法: source ~/.deepseek/channels/feishu/runtime-thread.sh
4
+ #
5
+ # 提供命令:
6
+ # ds-threads 列出所有 Runtime API 线程
7
+ # ds-resume <thread> 关闭 Runtime API 线程,在 TUI 中恢复会话
8
+ # ds-kill <thread> 归档指定线程
9
+
10
+ API_URL="${DEEPSEEK_API_URL:-http://localhost:7878}"
11
+
12
+ # Auto-detect token: env → file → default
13
+ if [ -n "$DEEPSEEK_API_TOKEN" ]; then
14
+ TOKEN="$DEEPSEEK_API_TOKEN"
15
+ elif [ -n "$DEEPSEEK_RUNTIME_TOKEN" ]; then
16
+ TOKEN="$DEEPSEEK_RUNTIME_TOKEN"
17
+ elif [ -f "$HOME/.deepseek/lodestar/.runtime_token" ]; then
18
+ TOKEN=$(cat "$HOME/.deepseek/lodestar/.runtime_token")
19
+ else
20
+ TOKEN="lodestar-runtime-token-v2"
21
+ fi
22
+
23
+ ds-threads() {
24
+ if ! curl -sf "$API_URL/health" > /dev/null 2>&1; then
25
+ echo "❌ deepseek serve --http 未运行在 $API_URL"
26
+ return 1
27
+ fi
28
+ echo "Runtime API 线程列表:"
29
+ echo "─────────────────────"
30
+ curl -sf "$API_URL/v1/threads" -H "Authorization: Bearer $TOKEN" | python3 -c "
31
+ import sys, json
32
+ threads = json.load(sys.stdin)
33
+ for t in threads:
34
+ arch = '📦' if t.get('archived') else '🟢'
35
+ ws = t.get('workspace','?').replace('/home/$USER/','~/')
36
+ turn = t.get('latest_turn_id','-')
37
+ print(f' {arch} {t[\"id\"]} {ws} turn={turn[:12]}...')
38
+ print(f'共 {len(threads)} 个线程')
39
+ " 2>/dev/null || echo " (无法解析响应,请检查 TOKEN)"
40
+ }
41
+
42
+ ds-resume() {
43
+ local tid="${1:-}"
44
+ if [ -z "$tid" ]; then
45
+ echo "用法: ds-resume <thread_id>"
46
+ echo " 关闭 Runtime API 线程,在工作目录启动 TUI 会话"
47
+ return 1
48
+ fi
49
+ # 获取线程信息
50
+ local info
51
+ info=$(curl -sf "$API_URL/v1/threads/$tid" -H "Authorization: Bearer $TOKEN" 2>/dev/null)
52
+ if [ -z "$info" ]; then
53
+ echo "❌ 无法获取线程 $tid"
54
+ return 1
55
+ fi
56
+ local ws
57
+ ws=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['thread']['workspace'])" 2>/dev/null)
58
+ if [ -z "$ws" ]; then
59
+ echo "❌ 无法解析 workspace"
60
+ return 1
61
+ fi
62
+ echo "线程: $tid"
63
+ echo "工作目录: $ws"
64
+ echo -n "归档线程并在 TUI 中恢复? [y/N] "
65
+ read -r confirm
66
+ if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
67
+ echo "已取消"
68
+ return 0
69
+ fi
70
+ # 归档线程
71
+ curl -s -X PATCH "$API_URL/v1/threads/$tid" \
72
+ -H "Authorization: Bearer $TOKEN" \
73
+ -H "Content-Type: application/json" \
74
+ -d '{"archived":true}' > /dev/null
75
+ echo "✅ 线程已归档"
76
+ echo "启动 TUI: cd $ws && deepseek"
77
+ cd "$ws" && deepseek
78
+ }
79
+
80
+ ds-kill() {
81
+ local tid="${1:-}"
82
+ if [ -z "$tid" ]; then
83
+ echo "用法: ds-kill <thread_id>"
84
+ return 1
85
+ fi
86
+ curl -s -X PATCH "$API_URL/v1/threads/$tid" \
87
+ -H "Authorization: Bearer $TOKEN" \
88
+ -H "Content-Type: application/json" \
89
+ -d '{"archived":true}' > /dev/null
90
+ echo "✅ 线程 $tid 已归档"
91
+ }