@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/src/feishu.ts
DELETED
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Feishu (Lark) primitives: Lark client, tenant token cache, chat
|
|
3
|
-
* directory, sendText/sendCard, reactions, attachment download, project
|
|
4
|
-
* provisioning, and Anthropic-auth check.
|
|
5
|
-
*
|
|
6
|
-
* Higher layers (cardkit / session / daemon) build on this.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as lark from '@larksuiteoapi/node-sdk'
|
|
10
|
-
import { execSync } from 'node:child_process'
|
|
11
|
-
import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
-
import { homedir } from 'node:os'
|
|
13
|
-
import { basename, extname, join } from 'node:path'
|
|
14
|
-
import { config } from './config'
|
|
15
|
-
import { DATA_DIR, INBOX_DIR, SESSION_CHAT_MAP_FILE } from './paths'
|
|
16
|
-
import { log } from './log'
|
|
17
|
-
|
|
18
|
-
const APP_ID = config.feishu.app_id
|
|
19
|
-
const APP_SECRET = config.feishu.app_secret
|
|
20
|
-
export const PROJECTS_ROOT = config.runtime.projects_root
|
|
21
|
-
|
|
22
|
-
export const client = new lark.Client({
|
|
23
|
-
appId: APP_ID, appSecret: APP_SECRET, disableTokenCache: false,
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
// ── Tenant token (cached, used by raw fetch wrappers) ──────────────────
|
|
27
|
-
let cachedToken = ''
|
|
28
|
-
let tokenExpiry = 0
|
|
29
|
-
export async function getTenantToken(): Promise<string> {
|
|
30
|
-
if (cachedToken && Date.now() < tokenExpiry) return cachedToken
|
|
31
|
-
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
32
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
33
|
-
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
34
|
-
})
|
|
35
|
-
const data = await res.json() as { tenant_access_token?: string; expire?: number }
|
|
36
|
-
if (!data.tenant_access_token) throw new Error('feishu: failed to obtain tenant token')
|
|
37
|
-
cachedToken = data.tenant_access_token
|
|
38
|
-
tokenExpiry = Date.now() + ((data.expire ?? 7200) - 60) * 1000
|
|
39
|
-
return cachedToken
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── Chat directory ─────────────────────────────────────────────────────
|
|
43
|
-
export const chatNameCache = new Map<string, string>()
|
|
44
|
-
export const preferredChatForSession = new Map<string, string>()
|
|
45
|
-
|
|
46
|
-
export function loadSessionChatMap(): void {
|
|
47
|
-
try {
|
|
48
|
-
const obj = JSON.parse(readFileSync(SESSION_CHAT_MAP_FILE, 'utf8'))
|
|
49
|
-
for (const [name, id] of Object.entries(obj)) {
|
|
50
|
-
if (typeof id === 'string') preferredChatForSession.set(name, id)
|
|
51
|
-
}
|
|
52
|
-
log(`feishu: loaded ${preferredChatForSession.size} session→chat bindings`)
|
|
53
|
-
} catch {}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function saveSessionChatMap(): void {
|
|
57
|
-
try {
|
|
58
|
-
const obj: Record<string, string> = {}
|
|
59
|
-
for (const [k, v] of preferredChatForSession) obj[k] = v
|
|
60
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
61
|
-
writeFileSync(SESSION_CHAT_MAP_FILE, JSON.stringify(obj, null, 2))
|
|
62
|
-
} catch (e) { log(`feishu: save session-chat-map failed: ${e}`) }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function bindSessionToChat(sessionName: string, chatId: string): void {
|
|
66
|
-
if (preferredChatForSession.get(sessionName) === chatId) return
|
|
67
|
-
const prev = preferredChatForSession.get(sessionName)
|
|
68
|
-
preferredChatForSession.set(sessionName, chatId)
|
|
69
|
-
saveSessionChatMap()
|
|
70
|
-
log(`feishu: bound session "${sessionName}" → ${chatId}${prev ? ` (was ${prev})` : ''}`)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function chatIdForSession(sessionName: string): string | null {
|
|
74
|
-
const preferred = preferredChatForSession.get(sessionName)
|
|
75
|
-
if (preferred && chatNameCache.get(preferred) === sessionName) return preferred
|
|
76
|
-
const matches: string[] = []
|
|
77
|
-
for (const [id, name] of chatNameCache) if (name === sessionName) matches.push(id)
|
|
78
|
-
if (matches.length === 1) return matches[0]
|
|
79
|
-
if (matches.length > 1) {
|
|
80
|
-
log(`feishu: chatIdForSession("${sessionName}"): ${matches.length} candidates with no binding — [${matches.join(', ')}]`)
|
|
81
|
-
}
|
|
82
|
-
return null
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function refreshChatList(): Promise<void> {
|
|
86
|
-
try {
|
|
87
|
-
let pageToken: string | undefined
|
|
88
|
-
do {
|
|
89
|
-
const res = await client.im.chat.list({
|
|
90
|
-
params: { page_size: 100, ...(pageToken ? { page_token: pageToken } : {}) },
|
|
91
|
-
})
|
|
92
|
-
for (const chat of res.data?.items ?? []) {
|
|
93
|
-
if (chat.chat_id && chat.name) chatNameCache.set(chat.chat_id, chat.name)
|
|
94
|
-
}
|
|
95
|
-
pageToken = res.data?.page_token
|
|
96
|
-
} while (pageToken)
|
|
97
|
-
log(`feishu: refreshed chat list — ${chatNameCache.size} groups`)
|
|
98
|
-
} catch (e) { log(`feishu: refresh chat list failed: ${e}`) }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── Outbound: text + card ──────────────────────────────────────────────
|
|
102
|
-
export async function sendText(chatId: string, text: string): Promise<string | null> {
|
|
103
|
-
try {
|
|
104
|
-
const res: any = await client.im.message.create({
|
|
105
|
-
params: { receive_id_type: 'chat_id' },
|
|
106
|
-
data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) },
|
|
107
|
-
})
|
|
108
|
-
if (res?.code && res.code !== 0) {
|
|
109
|
-
log(`feishu: sendText rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
110
|
-
return null
|
|
111
|
-
}
|
|
112
|
-
return res?.data?.message_id ?? null
|
|
113
|
-
} catch (e) { log(`feishu: sendText failed chat=${chatId}: ${e}`); return null }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export async function sendCard(chatId: string, card: object): Promise<string | null> {
|
|
117
|
-
try {
|
|
118
|
-
const res: any = await client.im.message.create({
|
|
119
|
-
params: { receive_id_type: 'chat_id' },
|
|
120
|
-
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
121
|
-
})
|
|
122
|
-
if (res?.code && res.code !== 0) {
|
|
123
|
-
log(`feishu: sendCard rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
124
|
-
return null
|
|
125
|
-
}
|
|
126
|
-
return res?.data?.message_id ?? null
|
|
127
|
-
} catch (e) { log(`feishu: sendCard failed chat=${chatId}: ${e}`); return null }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// PATCH a regular interactive message (i.e. a card NOT promoted to a
|
|
131
|
-
// cardkit entity). Used for permission cards that flip allow/deny once.
|
|
132
|
-
export async function patchCardMessage(messageId: string, card: object): Promise<void> {
|
|
133
|
-
try {
|
|
134
|
-
const token = await getTenantToken()
|
|
135
|
-
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`, {
|
|
136
|
-
method: 'PATCH',
|
|
137
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
138
|
-
body: JSON.stringify({ content: JSON.stringify(card) }),
|
|
139
|
-
})
|
|
140
|
-
const data = await res.json() as any
|
|
141
|
-
if (data?.code && data.code !== 0) {
|
|
142
|
-
log(`feishu: patchCardMessage ${messageId} code=${data.code} msg=${data.msg}`)
|
|
143
|
-
}
|
|
144
|
-
} catch (e) { log(`feishu: patchCardMessage ${messageId} failed: ${e}`) }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── Reactions ──────────────────────────────────────────────────────────
|
|
148
|
-
export async function addReaction(messageId: string, emojiType: string): Promise<void> {
|
|
149
|
-
if (!messageId) return
|
|
150
|
-
try {
|
|
151
|
-
await client.im.messageReaction.create({
|
|
152
|
-
path: { message_id: messageId },
|
|
153
|
-
data: { reaction_type: { emoji_type: emojiType } },
|
|
154
|
-
})
|
|
155
|
-
} catch (e) { log(`feishu: addReaction ${emojiType} on ${messageId} failed: ${e}`) }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── Attachment download (image/file) ───────────────────────────────────
|
|
159
|
-
export async function downloadAttachment(
|
|
160
|
-
messageId: string, key: string, type: 'image' | 'file', name?: string,
|
|
161
|
-
): Promise<string | undefined> {
|
|
162
|
-
try {
|
|
163
|
-
const token = await getTenantToken()
|
|
164
|
-
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${key}?type=${type}`
|
|
165
|
-
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
166
|
-
if (!res.ok) {
|
|
167
|
-
log(`feishu: download ${type} HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
168
|
-
return undefined
|
|
169
|
-
}
|
|
170
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
171
|
-
mkdirSync(INBOX_DIR, { recursive: true })
|
|
172
|
-
const safeName = name
|
|
173
|
-
? name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
174
|
-
: `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`
|
|
175
|
-
const path = join(INBOX_DIR, `${Date.now()}-${safeName}`)
|
|
176
|
-
writeFileSync(path, buf)
|
|
177
|
-
log(`feishu: downloaded ${type} ${path} (${buf.length}B)`)
|
|
178
|
-
return path
|
|
179
|
-
} catch (e) {
|
|
180
|
-
log(`feishu: download ${type} failed: ${e instanceof Error ? e.message : e}`)
|
|
181
|
-
return undefined
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ── Outbound: upload + send file/image ────────────────────────────────
|
|
186
|
-
// Lark caps message images at ~30 MB; files vary by tenant (default 30 MB).
|
|
187
|
-
// We refuse anything above 30 MB up front rather than chasing per-tenant
|
|
188
|
-
// limits and surfacing opaque API errors mid-upload.
|
|
189
|
-
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
|
190
|
-
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
|
|
191
|
-
|
|
192
|
-
function looksLikeImage(filePath: string): boolean {
|
|
193
|
-
return IMAGE_EXTS.has(extname(filePath).toLowerCase())
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Defense against prompt-injection-driven exfiltration via the
|
|
197
|
-
* [[send: /path]] marker. The resolved (symlink-followed) real path
|
|
198
|
-
* must sit inside one of the explicitly-trusted roots. Anything
|
|
199
|
-
* outside — `/etc/*`, `~/.ssh`, `~/.config/lodestar/config.toml`,
|
|
200
|
-
* `~/.claude.json`, the user's home dotfiles — is rejected, even if a
|
|
201
|
-
* symlink in an allowed root points to it.
|
|
202
|
-
*
|
|
203
|
-
* Returns [realPath, ''] on accept, [null, reason] on reject. */
|
|
204
|
-
function gateOutboundPath(rawPath: string, allowedRoots: string[]): [string | null, string] {
|
|
205
|
-
let real: string
|
|
206
|
-
try { real = realpathSync(rawPath) }
|
|
207
|
-
catch (e) { return [null, `realpath failed: ${e instanceof Error ? e.message : e}`] }
|
|
208
|
-
for (const root of allowedRoots) {
|
|
209
|
-
if (!root) continue
|
|
210
|
-
if (real === root) return [real, '']
|
|
211
|
-
const prefix = root.endsWith('/') ? root : root + '/'
|
|
212
|
-
if (real.startsWith(prefix)) return [real, '']
|
|
213
|
-
// Special-case the `/tmp/lodestar-` namespace: caller can pass
|
|
214
|
-
// the bare prefix without a trailing slash to mean "any tmp file
|
|
215
|
-
// whose basename starts with lodestar-".
|
|
216
|
-
if (root.endsWith('-') && real.startsWith(root)) return [real, '']
|
|
217
|
-
}
|
|
218
|
-
return [null, `not inside any allowed root (real=${real})`]
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function uploadImageMultipart(filePath: string): Promise<string | null> {
|
|
222
|
-
const token = await getTenantToken()
|
|
223
|
-
const file = Bun.file(filePath)
|
|
224
|
-
const form = new FormData()
|
|
225
|
-
form.append('image_type', 'message')
|
|
226
|
-
form.append('image', file, basename(filePath))
|
|
227
|
-
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
|
|
228
|
-
method: 'POST',
|
|
229
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
230
|
-
body: form,
|
|
231
|
-
})
|
|
232
|
-
const data = await res.json() as any
|
|
233
|
-
if (data?.code !== 0) {
|
|
234
|
-
log(`feishu: uploadImage ${filePath} code=${data.code} msg=${data.msg}`)
|
|
235
|
-
return null
|
|
236
|
-
}
|
|
237
|
-
return data.data?.image_key ?? null
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function uploadFileMultipart(filePath: string): Promise<string | null> {
|
|
241
|
-
const token = await getTenantToken()
|
|
242
|
-
const file = Bun.file(filePath)
|
|
243
|
-
const form = new FormData()
|
|
244
|
-
// 'stream' is the catch-all type and works for arbitrary binaries.
|
|
245
|
-
form.append('file_type', 'stream')
|
|
246
|
-
form.append('file_name', basename(filePath))
|
|
247
|
-
form.append('file', file, basename(filePath))
|
|
248
|
-
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/files', {
|
|
249
|
-
method: 'POST',
|
|
250
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
251
|
-
body: form,
|
|
252
|
-
})
|
|
253
|
-
const data = await res.json() as any
|
|
254
|
-
if (data?.code !== 0) {
|
|
255
|
-
log(`feishu: uploadFile ${filePath} code=${data.code} msg=${data.msg}`)
|
|
256
|
-
return null
|
|
257
|
-
}
|
|
258
|
-
return data.data?.file_key ?? null
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export async function sendImage(chatId: string, imageKey: string): Promise<string | null> {
|
|
262
|
-
try {
|
|
263
|
-
const res: any = await client.im.message.create({
|
|
264
|
-
params: { receive_id_type: 'chat_id' },
|
|
265
|
-
data: { receive_id: chatId, msg_type: 'image', content: JSON.stringify({ image_key: imageKey }) },
|
|
266
|
-
})
|
|
267
|
-
if (res?.code && res.code !== 0) {
|
|
268
|
-
log(`feishu: sendImage rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
269
|
-
return null
|
|
270
|
-
}
|
|
271
|
-
return res?.data?.message_id ?? null
|
|
272
|
-
} catch (e) { log(`feishu: sendImage failed chat=${chatId}: ${e}`); return null }
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export async function sendFile(chatId: string, fileKey: string): Promise<string | null> {
|
|
276
|
-
try {
|
|
277
|
-
const res: any = await client.im.message.create({
|
|
278
|
-
params: { receive_id_type: 'chat_id' },
|
|
279
|
-
data: { receive_id: chatId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) },
|
|
280
|
-
})
|
|
281
|
-
if (res?.code && res.code !== 0) {
|
|
282
|
-
log(`feishu: sendFile rejected chat=${chatId} code=${res.code} msg=${res.msg}`)
|
|
283
|
-
return null
|
|
284
|
-
}
|
|
285
|
-
return res?.data?.message_id ?? null
|
|
286
|
-
} catch (e) { log(`feishu: sendFile failed chat=${chatId}: ${e}`); return null }
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Upload a local file and post it as an image or file message in the
|
|
290
|
-
* chat. Type is inferred from extension. The path MUST resolve inside
|
|
291
|
-
* one of `allowedRoots` (defense against prompt-injection-driven
|
|
292
|
-
* exfiltration via the `[[send: /path]]` marker — see isPathAllowed).
|
|
293
|
-
* Returns true on success. All failures (missing file, oversize,
|
|
294
|
-
* outside allowed roots, upload reject, send reject) log and surface
|
|
295
|
-
* an inline error message in the chat so the user knows. */
|
|
296
|
-
export async function uploadAndSend(chatId: string, filePath: string, allowedRoots: string[]): Promise<boolean> {
|
|
297
|
-
const [realPath, reason] = gateOutboundPath(filePath, allowedRoots)
|
|
298
|
-
if (!realPath) {
|
|
299
|
-
log(`feishu: uploadAndSend REJECTED ${filePath}: ${reason}`)
|
|
300
|
-
await sendText(chatId, `❌ 出站文件被拒绝(路径不在允许范围): ${filePath}`)
|
|
301
|
-
return false
|
|
302
|
-
}
|
|
303
|
-
try {
|
|
304
|
-
const stats = statSync(realPath)
|
|
305
|
-
if (!stats.isFile()) {
|
|
306
|
-
await sendText(chatId, `❌ 出站文件: 路径不是文件 — ${filePath}`)
|
|
307
|
-
return false
|
|
308
|
-
}
|
|
309
|
-
if (stats.size > MAX_UPLOAD_BYTES) {
|
|
310
|
-
await sendText(chatId, `❌ 出站文件: ${basename(realPath)} 超过 30 MB (${(stats.size / 1024 / 1024).toFixed(1)} MB)`)
|
|
311
|
-
return false
|
|
312
|
-
}
|
|
313
|
-
} catch (e) {
|
|
314
|
-
await sendText(chatId, `❌ 出站文件: 无法读取 ${filePath} (${e})`)
|
|
315
|
-
return false
|
|
316
|
-
}
|
|
317
|
-
const isImage = looksLikeImage(realPath)
|
|
318
|
-
try {
|
|
319
|
-
if (isImage) {
|
|
320
|
-
const key = await uploadImageMultipart(realPath)
|
|
321
|
-
if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(realPath)}`); return false }
|
|
322
|
-
const msgId = await sendImage(chatId, key)
|
|
323
|
-
return msgId != null
|
|
324
|
-
} else {
|
|
325
|
-
const key = await uploadFileMultipart(realPath)
|
|
326
|
-
if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(realPath)}`); return false }
|
|
327
|
-
const msgId = await sendFile(chatId, key)
|
|
328
|
-
return msgId != null
|
|
329
|
-
}
|
|
330
|
-
} catch (e) {
|
|
331
|
-
log(`feishu: uploadAndSend ${filePath} failed: ${e}`)
|
|
332
|
-
await sendText(chatId, `❌ 出站文件异常: ${basename(realPath)} — ${e}`)
|
|
333
|
-
return false
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ── Project provisioning ──────────────────────────────────────────────
|
|
338
|
-
// Bootstrap ~/{name}: create dir, mark as trusted in ~/.claude.json so
|
|
339
|
-
// Claude skips the trust dialog, and `git init` so the project starts as
|
|
340
|
-
// a real repo.
|
|
341
|
-
export function provisionProject(workDir: string): void {
|
|
342
|
-
mkdirSync(workDir, { recursive: true })
|
|
343
|
-
log(`feishu: provisioned ${workDir}`)
|
|
344
|
-
const claudeJsonPath = join(homedir(), '.claude.json')
|
|
345
|
-
try {
|
|
346
|
-
let config: any = {}
|
|
347
|
-
try { config = JSON.parse(readFileSync(claudeJsonPath, 'utf8')) } catch { config = {} }
|
|
348
|
-
if (!config.projects || typeof config.projects !== 'object') config.projects = {}
|
|
349
|
-
config.projects[workDir] = { ...(config.projects[workDir] ?? {}), hasTrustDialogAccepted: true }
|
|
350
|
-
writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2))
|
|
351
|
-
} catch (e) { log(`feishu: trust write failed for ${workDir}: ${e}`) }
|
|
352
|
-
try { execSync('git init -q', { cwd: workDir, stdio: 'ignore' }) } catch {}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export function isAnthropicAuthenticated(): boolean {
|
|
356
|
-
try {
|
|
357
|
-
const out = execSync(`${join(homedir(), '.local', 'bin', 'claude')} auth status 2>&1`, { timeout: 10_000 }).toString()
|
|
358
|
-
const status = JSON.parse(out)
|
|
359
|
-
return status.loggedIn === true && status.apiProvider === 'firstParty'
|
|
360
|
-
} catch { return false }
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function sanitizeSessionName(raw: string): string {
|
|
364
|
-
return raw.replace(/[^\w一-鿿\-]/g, '_').slice(0, 64)
|
|
365
|
-
}
|
package/src/instructions.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The system-prompt fragment Lodestar appends on every claude headless
|
|
3
|
-
* launch. Carry-over of the original "channel instructions" from
|
|
4
|
-
* Lodestar 1.x's MCP server, adapted for the streaming-card model where
|
|
5
|
-
* Claude's own stdout already renders live in the Feishu group, so there
|
|
6
|
-
* is no separate `reply` tool to call.
|
|
7
|
-
*/
|
|
8
|
-
export const CHANNEL_INSTRUCTIONS = [
|
|
9
|
-
'You are running inside Lodestar, a daemon that bridges this session to a Feishu (Lark) group chat.',
|
|
10
|
-
'Your assistant text is streamed live as a Feishu card in that group; tool calls appear as collapsible panels; thinking is shown but de-emphasized. There is no separate reply tool — your normal conversational output IS the reply.',
|
|
11
|
-
'',
|
|
12
|
-
'Conventions for every turn:',
|
|
13
|
-
'- Open with one short acknowledgement so the user sees you started.',
|
|
14
|
-
'- Stream your conclusion before the turn ends; never end on a silent tool call. The card is your voice.',
|
|
15
|
-
'- For long work, drop progress sentences between tool calls so the user is not staring at a loading dot.',
|
|
16
|
-
'',
|
|
17
|
-
'Inbound user messages may carry a [file: /abs/path] hint when the user sent an image or attachment in Feishu. Read those files when relevant.',
|
|
18
|
-
'',
|
|
19
|
-
'To send a local file or image back to the user in this Feishu group, write the marker `[[send: /abs/path]]` (absolute path) anywhere in your reply, preferably on its own line at the end. The daemon strips every marker from the displayed card and posts the file as a separate Feishu message. Emit the marker only when the user asked for a file or when delivering a generated artifact (screenshot, diagram, exported doc) — not for arbitrary paths.',
|
|
20
|
-
'',
|
|
21
|
-
'The group name equals the working directory under $HOME and equals the Lodestar session name. Treat that binding as load-bearing — do not rename or move the directory.',
|
|
22
|
-
].join('\n')
|
package/src/log.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
3
|
-
import { LOG_FILE } from './paths'
|
|
4
|
-
|
|
5
|
-
try { mkdirSync(dirname(LOG_FILE), { recursive: true }) } catch {}
|
|
6
|
-
|
|
7
|
-
export function log(msg: string): void {
|
|
8
|
-
const line = `[${new Date().toISOString()}] ${msg}\n`
|
|
9
|
-
process.stderr.write(line)
|
|
10
|
-
try { appendFileSync(LOG_FILE, line) } catch {}
|
|
11
|
-
}
|
package/src/paths.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem layout — XDG Base Directory spec, with env-var overrides.
|
|
3
|
-
*
|
|
4
|
-
* Config: $LODESTAR_CONFIG_DIR | $XDG_CONFIG_HOME/lodestar | ~/.config/lodestar
|
|
5
|
-
* Data: $LODESTAR_DATA_DIR | $XDG_DATA_HOME/lodestar | ~/.local/share/lodestar
|
|
6
|
-
*
|
|
7
|
-
* config.toml — credentials + preferences (in CONFIG_DIR)
|
|
8
|
-
* daemon.pid — single-instance lock (in DATA_DIR)
|
|
9
|
-
* daemon.log — append-only run log (in DATA_DIR)
|
|
10
|
-
* session-chat-map.json — duplicate-name routing (in DATA_DIR)
|
|
11
|
-
* inbox/ — downloaded attachments (in DATA_DIR)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { homedir } from 'node:os'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
|
-
|
|
17
|
-
const HOME = homedir()
|
|
18
|
-
|
|
19
|
-
function pickDir(envOverride: string | undefined, xdgVar: string | undefined, fallback: string): string {
|
|
20
|
-
if (envOverride) return envOverride
|
|
21
|
-
if (xdgVar) return join(xdgVar, 'lodestar')
|
|
22
|
-
return fallback
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const CONFIG_DIR = pickDir(
|
|
26
|
-
process.env.LODESTAR_CONFIG_DIR,
|
|
27
|
-
process.env.XDG_CONFIG_HOME,
|
|
28
|
-
join(HOME, '.config', 'lodestar'),
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
export const DATA_DIR = pickDir(
|
|
32
|
-
process.env.LODESTAR_DATA_DIR,
|
|
33
|
-
process.env.XDG_DATA_HOME,
|
|
34
|
-
join(HOME, '.local', 'share', 'lodestar'),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
export const CONFIG_FILE = process.env.LODESTAR_CONFIG ?? join(CONFIG_DIR, 'config.toml')
|
|
38
|
-
export const PID_FILE = join(DATA_DIR, 'daemon.pid')
|
|
39
|
-
export const LOG_FILE = join(DATA_DIR, 'daemon.log')
|
|
40
|
-
export const SESSION_CHAT_MAP_FILE = join(DATA_DIR, 'session-chat-map.json')
|
|
41
|
-
export const INBOX_DIR = join(DATA_DIR, 'inbox')
|