@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/README.md +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
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
|
+
}
|