@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/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
- }
@@ -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')