@kukonce/dsh-loomy 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where this plugin keeps everything it owns.
3
+ *
4
+ * One directory, `$DSH_HOME/dsh-loomy/`: the account list, the login session for
5
+ * each account, the model catalog cache and the daily-credit ledger. Keeping them
6
+ * together means "reset the plugin" is one folder to delete.
7
+ *
8
+ * Credentials are written here rather than through the harness credentials service
9
+ * for the same reason the WorkBuddy plugin does it: that service keeps one document
10
+ * for every plugin, and these tokens have no business sharing a file or a ref
11
+ * namespace with anything else.
12
+ */
13
+
14
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
17
+
18
+ /** `$DSH_HOME`, falling back to `~/.dsh` the same way the harness does. */
19
+ function dshHome() {
20
+ return typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0
21
+ ? process.env.DSH_HOME
22
+ : join(homedir(), '.dsh')
23
+ }
24
+
25
+ /** The plugin's own directory, created owner-only on demand. */
26
+ export function ensurePluginDir() {
27
+ const dir = join(dshHome(), 'dsh-loomy')
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
29
+ return dir
30
+ }
31
+
32
+ /** Absolute path of a file inside the plugin directory. */
33
+ export function pluginFile(name) {
34
+ return join(ensurePluginDir(), name)
35
+ }
36
+
37
+ /**
38
+ * Read a JSON file from the plugin directory, or `undefined` when it is missing or
39
+ * unreadable. A corrupt file reads as absent: a broken cache must never break a start.
40
+ */
41
+ export function readJson(name) {
42
+ try {
43
+ return JSON.parse(readFileSync(pluginFile(name), 'utf8'))
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Write a JSON file atomically: staged inside the directory (a temp file on another
51
+ * volume makes `rename` fail with EXDEV), then renamed over the target.
52
+ */
53
+ export function writeJson(name, value, options = {}) {
54
+ const target = pluginFile(name)
55
+ const staging = join(ensurePluginDir(), `.${name}.${process.pid}.tmp`)
56
+ writeFileSync(staging, JSON.stringify(value, null, options.pretty === true ? 2 : 0), { mode: 0o600 })
57
+ renameSync(staging, target)
58
+ if (options.mode !== undefined) chmodSync(target, options.mode)
59
+ return target
60
+ }
61
+
62
+ /** Delete a file from the plugin directory; a missing one is not an error. */
63
+ export function removeJson(name) {
64
+ try {
65
+ const target = pluginFile(name)
66
+ readFileSync(target)
67
+ renameSync(target, join(ensurePluginDir(), `.${name}.deleted.${Date.now()}.tmp`))
68
+ } catch {
69
+ // absent is the desired state
70
+ }
71
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * iFlytek CAccount request signing (HMAC-SHA1).
3
+ *
4
+ * The account service authenticates the *application* with a key pair and the
5
+ * *request* with a signature over a canonical string. Getting the canonical string
6
+ * exactly right is the whole problem: a single differing separator yields
7
+ * `010003 请求参数错误` with no hint about which part was wrong.
8
+ *
9
+ * The string is nine lines, joined with `\\n`:
10
+ *
11
+ * METHOD
12
+ * ESCAPED_PATH (/login/phone/sendMsgCode)
13
+ * ESCAPED_QUERY_STRING (sorted? no — insertion order)
14
+ * CONTENT_MD5 base64 MD5 of the body, '' when there is no body
15
+ * CONTENT_TYPE application/json
16
+ * DATE HTTP-date (toUTCString)
17
+ * NONCE uuid
18
+ * SIGNED_HEADERS comma/; joined `x-*` header names, '' when none
19
+ * CANONICALIZED_HEADERS `k:v\\n` per `x-*` header, '' when none
20
+ *
21
+ * Verified against the live endpoint: a signed `getPuKey` answers
22
+ * `{\"code\":\"000000\"}` with this implementation.
23
+ */
24
+
25
+ import { createHash, createHmac, randomUUID } from 'node:crypto'
26
+
27
+ /** base64 MD5 of the request body; empty string for a bodyless request. */
28
+ export function contentMD5(body) {
29
+ if (!body) return ''
30
+ const text = typeof body === 'string' ? body : JSON.stringify(body)
31
+ return createHash('md5').update(text, 'utf8').digest('base64')
32
+ }
33
+
34
+ /** RFC3986 path/query escaping: everything encodeURIComponent leaves alone becomes %XX. */
35
+ function escapeRFC3986(value) {
36
+ return encodeURIComponent(value)
37
+ .replace(/!/g, '%21')
38
+ .replace(/'/g, '%27')
39
+ .replace(/\(/g, '%28')
40
+ .replace(/\)/g, '%29')
41
+ .replace(/\*/g, '%2A')
42
+ }
43
+
44
+ function escapedPath(path) {
45
+ let clean = path.startsWith('/') ? path : `/${path}`
46
+ if (clean.length > 1 && clean.endsWith('/')) clean = clean.slice(0, -1)
47
+ return clean.split('/').map((segment) => (segment ? escapeRFC3986(segment) : '')).join('/')
48
+ }
49
+
50
+ function escapedQuery(queryParams) {
51
+ const entries = Object.entries(queryParams ?? {})
52
+ if (entries.length === 0) return ''
53
+ return entries
54
+ .map(([key, value]) => `${escapeRFC3986(key)}=${value === null || value === undefined ? '' : escapeRFC3986(String(value))}`)
55
+ .join('&')
56
+ }
57
+
58
+ /**
59
+ * Canonical headers, restricted to the `x-` prefix the protocol signs.
60
+ *
61
+ * The trailing newline on every line — including the last — is part of the format.
62
+ */
63
+ function canonicalizedHeaders(headers) {
64
+ const selected = new Map()
65
+ for (const [key, value] of Object.entries(headers ?? {})) {
66
+ const lower = key.toLowerCase()
67
+ if (lower.startsWith('x-')) selected.set(lower, String(value).trim())
68
+ }
69
+ const keys = [...selected.keys()].sort()
70
+ if (keys.length === 0) return { canonical: '', signed: '' }
71
+ return {
72
+ canonical: keys.map((key) => `${key}:${selected.get(key)}\n`).join(''),
73
+ signed: keys.join(';')
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Build the nine-line string that gets signed.
79
+ *
80
+ * Exported because it is the only way to debug a signature mismatch: print this and
81
+ * diff it against a known-good capture.
82
+ */
83
+ export function buildStringToSign({
84
+ method,
85
+ path,
86
+ queryParams = {},
87
+ body = '',
88
+ contentType = 'application/json',
89
+ date,
90
+ nonce,
91
+ customHeaders = {}
92
+ }) {
93
+ const { canonical, signed } = canonicalizedHeaders(customHeaders)
94
+ return [
95
+ String(method).toUpperCase(),
96
+ escapedPath(path),
97
+ escapedQuery(queryParams),
98
+ contentMD5(body),
99
+ contentType,
100
+ date,
101
+ nonce,
102
+ signed,
103
+ canonical.replace(/\n$/, '')
104
+ ].join('\n')
105
+ }
106
+
107
+ export function signRequest({
108
+ accessKeyId,
109
+ accessKeySecret,
110
+ method,
111
+ path,
112
+ queryParams = {},
113
+ body = '',
114
+ contentType = 'application/json',
115
+ date = new Date().toUTCString(),
116
+ nonce = randomUUID(),
117
+ customHeaders = {}
118
+ }) {
119
+ const stringToSign = buildStringToSign({ method, path, queryParams, body, contentType, date, nonce, customHeaders })
120
+ const signature = createHmac('sha1', accessKeySecret).update(stringToSign, 'utf8').digest('base64')
121
+ return { signature, stringToSign }
122
+ }
123
+
124
+ /**
125
+ * Full header set for one CAccount request.
126
+ *
127
+ * `Authorization` carries the `account` scheme name, the key id and the signature;
128
+ * `Content-MD5` is only present when there is a body (a bodyless GET with an MD5 of
129
+ * the empty string is rejected).
130
+ */
131
+ export function buildAccountHeaders({
132
+ accessKeyId,
133
+ accessKeySecret,
134
+ method,
135
+ path,
136
+ queryParams = {},
137
+ body = '',
138
+ contentType = 'application/json',
139
+ customHeaders = {}
140
+ }) {
141
+ const date = new Date().toUTCString()
142
+ const nonce = randomUUID()
143
+ const { signature } = signRequest({
144
+ accessKeyId,
145
+ accessKeySecret,
146
+ method,
147
+ path,
148
+ queryParams,
149
+ body,
150
+ contentType,
151
+ date,
152
+ nonce,
153
+ customHeaders
154
+ })
155
+ const md5 = contentMD5(body)
156
+ const headers = {
157
+ Authorization: `account ${accessKeyId}:${signature}`,
158
+ Date: date,
159
+ Nonce: nonce,
160
+ 'Content-Type': contentType,
161
+ 'User-Agent': 'Loomy-Desktop-DSH',
162
+ ...customHeaders
163
+ }
164
+ if (md5.length > 0) headers['Content-MD5'] = md5
165
+ return headers
166
+ }
package/src/index.js ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * dsh-loomy -- Loomy (讯飞 Loomy) accounts as DeepSeek Harness model providers.
3
+ *
4
+ * Loomy has no API keys. An account signs in to iFlytek's CAccount service — by SMS
5
+ * code, or by adopting the login the Loomy desktop app already has — and the `session`
6
+ * string that comes back is the credential for both the points API and the model
7
+ * gateway. So this plugin's job splits cleanly in two:
8
+ *
9
+ * accounts/ log in, keep the session, claim the daily credit, read the catalog
10
+ * llm/ turn that account into one OpenAI-compatible provider route
11
+ *
12
+ * One provider route per account, never one shared route: the catalog and the balance are
13
+ * both scoped to the account, and the picker groups by provider — two accounts on one
14
+ * route would present as one account whose model list keeps changing.
15
+ *
16
+ * The daily credit is the reason the timers below exist. Loomy grants 5000 "每日赠送"
17
+ * points per day, renewed by `POST /api/v1/points/first-login` — a call whose name says
18
+ * "first login" but whose behaviour is "claim today's quota, idempotently". Without it
19
+ * the balance stays at whatever yesterday left, so it is claimed once shortly after start
20
+ * and again at every local midnight.
21
+ */
22
+
23
+ import { randomUUID } from 'node:crypto'
24
+
25
+ import { readAccounts, accountOf, assertAccountId, importDesktopAccount, loginBySmsCode, logoutRemote, removeAccount, renameAccount, sendSmsCode } from './account/auth.js'
26
+ import { anchorLedger, ensureDailyGrant, fetchCredits, fetchOnboardingTasks, completeOnboardingTask, grantedToday } from './account/credits.js'
27
+ import { loadCatalog, readCachedCatalog } from './account/models.js'
28
+ import { accountsWithState, expiredInfo, isExpired, markExpired, sessionForId } from './account/session.js'
29
+ import { GATEWAY_BASE_URL } from './core/endpoints.js'
30
+ import { formatWhen, msUntilNextMidnight } from './core/http.js'
31
+ import { LoomyAdapter } from './llm/adapter.js'
32
+ import { registerLoomyApi } from './panel/api.js'
33
+
34
+ export const name = 'dsh-loomy'
35
+ export const inject = ['commands', 'llm']
36
+
37
+ /** The daily-credit claim runs a little after the rest of the plugin has settled. */
38
+ const GRANT_BOOT_DELAY_MS = 12_000
39
+
40
+ /**
41
+ * The in-flight SMS login: `{ phone, msgid, expiresAt }`.
42
+ *
43
+ * Kept in memory rather than on disk. An SMS code lives five minutes, and persisting a
44
+ * half-finished login would leave a stale `msgid` for the next attempt to trip over —
45
+ * a failure that reads as "the code was wrong" when it was really yesterday's code.
46
+ */
47
+ let pendingLogin
48
+
49
+ /**
50
+ * The provider route for one account.
51
+ *
52
+ * Kept in one place because three things depend on the exact spelling: registration,
53
+ * the model picker's persisted selection, and the `/loomy` command's output.
54
+ */
55
+ export function providerFor(accountId) {
56
+ return `@loomy/dsh-loomy/${accountId}`
57
+ }
58
+
59
+ /**
60
+ * Claim today's 5000 on every account that has not had it yet today.
61
+ *
62
+ * Serial rather than parallel on purpose: these are the same endpoint for different
63
+ * sessions, and a burst of claims is the one pattern a rate limiter would notice. The
64
+ * cost of serial is one HTTP round trip per account once a day.
65
+ */
66
+ async function runDailyGrant(ctx, reason) {
67
+ const outcomes = []
68
+ for (const account of readAccounts()) {
69
+ if (grantedToday(account.id)) {
70
+ outcomes.push({ id: account.id, skipped: true });
71
+ continue;
72
+ }
73
+ try {
74
+ const result = await ensureDailyGrant(account.id, account.session, { reason })
75
+ if (result.granted === true) {
76
+ ctx.logger.info(`[loomy] ${account.id}: 每日赠送积分已刷新(${result.balance ?? 0}/${result.quota ?? 0},${reason})`)
77
+ } else if (result.isAuthError === true) {
78
+ markExpired(account.id, '领取每日积分时登录失效')
79
+ ctx.logger.warn(`[loomy] ${account.id}: 每日赠送积分领取失败 -- 登录已失效,需要重新登录`)
80
+ } else if (result.error !== undefined) {
81
+ ctx.logger.warn(`[loomy] ${account.id}: 每日赠送积分领取失败 -- ${result.error}`)
82
+ }
83
+ outcomes.push({ id: account.id, ...result })
84
+ } catch (error) {
85
+ ctx.logger.warn(`[loomy] ${account.id}: 每日赠送积分领取异常 -- ${error instanceof Error ? error.message : String(error)}`)
86
+ outcomes.push({ id: account.id, error: error instanceof Error ? error.message : String(error) })
87
+ }
88
+ }
89
+ return outcomes
90
+ }
91
+
92
+ /** Format one account's balance line for the command output. */
93
+ function creditsLine(credits) {
94
+ if (credits === undefined) return '积分:读取失败'
95
+ const daily = typeof credits.dailyBalance === 'number' ? `${credits.dailyBalance}/${credits.dailyQuota}` : '未知'
96
+ const permanent = typeof credits.permanentBalance === 'number' ? String(credits.permanentBalance) : '未知'
97
+ return `每日赠送 ${daily} 长期 ${permanent} 合计 ${credits.availableBalance ?? '未知'}`
98
+ }
99
+
100
+ /**
101
+ * `/loomy` -- the command surface.
102
+ *
103
+ * Deliberately small: the Settings panel is where the interactive work happens, and a
104
+ * command that duplicates it would be a second implementation to keep in sync. What a
105
+ * command is genuinely better at is the two things that happen before the UI is
106
+ * trustworthy — logging in, and finding out why a model is not answering.
107
+ */
108
+ async function executeCommand(ctx, adapters, invocation) {
109
+ const input = String(invocation.rawInput ?? '').trim()
110
+ const parts = input.split(/\s+/u).filter((part) => part.length > 0)
111
+ const verb = (parts[0] ?? '').toLowerCase()
112
+
113
+ if (verb === '' || verb === 'status') {
114
+ const accounts = accountsWithState()
115
+ if (accounts.length === 0) {
116
+ return {
117
+ kind: 'success',
118
+ text: [
119
+ '还没有登录任何 Loomy 账号。',
120
+ '',
121
+ ' /loomy login <手机号> 用短信验证码登录',
122
+ ' /loomy import [目录] 导入 Loomy 桌面端已有的登录(默认只读本用户目录)',
123
+ '',
124
+ '登录也可以在「设置 → Loomy」里完成。'
125
+ ].join('\n')
126
+ };
127
+ }
128
+ const lines = []
129
+ for (const account of accounts) {
130
+ const adapter = adapters[account.id]
131
+ const expired = expiredInfo(account.id)
132
+ const state = expired === undefined ? '已登录' : `登录已失效(${formatWhen(expired.at)})`
133
+ lines.push(`${account.id}${account.label.length > 0 && account.label !== account.id ? `(${account.label})` : ''} ${state}`)
134
+ lines.push(` 路由:${providerFor(account.id)}`);
135
+ if (account.phone.length > 0) lines.push(` 手机:${account.phone}`);
136
+ if (expired === undefined && account.session.length > 0) {
137
+ try {
138
+ const credits = await fetchCredits(account.session)
139
+ anchorLedger(account.id, credits, '状态查询')
140
+ lines.push(` ${creditsLine(credits)}`);
141
+ const grant = grantedToday(account.id) ? '今日已领取' : '今日尚未领取';
142
+ lines.push(` 每日额度:${grant}`);
143
+ } catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error)
145
+ lines.push(` 积分:读取失败 -- ${message}`);
146
+ if (error?.isAuthError === true) markExpired(account.id, message);
147
+ }
148
+ if (adapter !== undefined) {
149
+ try {
150
+ adapter.invalidateCatalog()
151
+ const catalog = await adapter.models()
152
+ lines.push(` 模型:${catalog.length} 个(来源 ${adapter.catalogSource})`);
153
+ } catch (error) {
154
+ lines.push(` 模型:读取失败 -- ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ lines.push('', '命令:/loomy login <手机号>、login code <验证码>、import、logout [id]、models [id]、credits [id]、tasks [id]、claim [id]');
160
+ return { kind: 'success', text: lines.join('\n') }
161
+ }
162
+
163
+ if (verb === 'login') {
164
+ const sub = (parts[1] ?? '').toLowerCase()
165
+
166
+ // The two-step flow keeps state in memory rather than on disk: an SMS code lives
167
+ // for five minutes, and persisting a half-finished login would leave a stale msgid
168
+ // for the next attempt to trip over.
169
+ if (sub === 'code') {
170
+ const pending = pendingLogin
171
+ if (pending === undefined || pending.expiresAt < Date.now()) {
172
+ pendingLogin = undefined
173
+ return { kind: 'error', text: '没有进行中的登录(或已过期),请先运行 /loomy login <手机号>' }
174
+ }
175
+ try {
176
+ const account = await loginBySmsCode({ phone: pending.phone, code: parts[2], msgid: pending.msgid, label: pending.label })
177
+ pendingLogin = undefined
178
+ mountAccount(ctx, adapters, account);
179
+ await ensureDailyGrant(account.id, account.session, { reason: '登录后' })
180
+ return {
181
+ kind: 'success',
182
+ text: [`登录成功:${account.id}(${account.phone})`, `模型路由:${providerFor(account.id)}`, '', '现在可以在模型选择器里选 Loomy 了。'].join('\n')
183
+ }
184
+ } catch (error) {
185
+ return { kind: 'error', text: `验证码登录失败:${error instanceof Error ? error.message : String(error)}` }
186
+ }
187
+ }
188
+
189
+ const phone = parts[1] ?? ''
190
+ if (phone.length === 0) {
191
+ return { kind: 'error', text: '用法:/loomy login <手机号> (或 /loomy import [目录] 导入桌面端登录)' }
192
+ }
193
+ try {
194
+ const sent = await sendSmsCode(phone)
195
+ pendingLogin = { phone: sent.phone, msgid: sent.msgid, label: '', expiresAt: Date.now() + (sent.expire * 1000) };
196
+ return {
197
+ kind: 'success',
198
+ text: [`验证码已发送到 ${sent.phone}(${sent.expire} 秒内有效)。`, '', `接着运行:/loomy login code <验证码>`].join('\n')
199
+ }
200
+ } catch (error) {
201
+ return { kind: 'error', text: `发送验证码失败:${error instanceof Error ? error.message : String(error)}` }
202
+ }
203
+ }
204
+
205
+ if (verb === 'import') {
206
+ // An optional directory: the client's layout can differ from the default, and naming
207
+ // it is the user's own call. Without one, only this user's own directories are read.
208
+ const directory = parts[1] ?? ''
209
+ try {
210
+ const result = await importDesktopAccount({ directory })
211
+ mountAccount(ctx, adapters, result.record);
212
+ await ensureDailyGrant(result.record.id, result.record.session, { reason: '导入后' })
213
+ return {
214
+ kind: 'success',
215
+ text: [`已导入 Loomy 桌面端登录:${result.record.id}(${result.record.phone || result.record.userid})`, `模型路由:${providerFor(result.record.id)}`].join('\n')
216
+ }
217
+ } catch (error) {
218
+ return { kind: 'error', text: `导入失败:${error instanceof Error ? error.message : String(error)}` }
219
+ }
220
+ }
221
+
222
+ if (verb === 'logout') {
223
+ const target = parts[1] ?? (readAccounts()[0]?.id ?? '')
224
+ if (target.length === 0) return { kind: 'error', text: '没有可退出的账号' }
225
+ const account = accountOf(target)
226
+ if (account === undefined) return { kind: 'error', text: `未找到账号 "${target}"` }
227
+ await logoutRemote(account.session)
228
+ removeAccount(account.id)
229
+ adapters[account.id]?.dispose?.()
230
+ delete adapters[account.id]
231
+ return { kind: 'success', text: `已退出 ${account.id}` }
232
+ }
233
+
234
+ // The remaining verbs all answer "about one account", defaulting to the first.
235
+ const targetId = parts[1] ?? (readAccounts()[0]?.id ?? '')
236
+ const account = accountOf(targetId)
237
+ if (account === undefined) {
238
+ return { kind: 'error', text: `未找到账号 "${targetId}"(先运行 /loomy status 看看有哪些)` }
239
+ }
240
+ const adapter = adapters[account.id]
241
+
242
+ if (verb === 'models') {
243
+ if (adapter === undefined) return { kind: 'error', text: `${account.id} 的模型路由未挂载` }
244
+ adapter.invalidateCatalog()
245
+ try {
246
+ const catalog = await adapter.models()
247
+ const lines = [`${account.id}:${catalog.length} 个模型(来源 ${adapter.catalogSource})`]
248
+ for (const entry of catalog) {
249
+ const flags = [
250
+ entry.type === 'image' ? '生图' : '',
251
+ entry.vision ? '可读图' : '',
252
+ entry.toolCalls ? '工具' : '',
253
+ entry.reasoning ? '推理' : ''
254
+ ].filter((flag) => flag.length > 0).join('/')
255
+ // A generation model cannot serve a chat turn, so it is marked rather than
256
+ // hidden: "why is my model missing" is worse than "this one is not for chat".
257
+ lines.push(` ${entry.id} -- ${entry.name}${flags.length > 0 ? `(${flags})` : ''}${entry.type === 'image' ? ' [不支持对话]' : ''}`)
258
+ }
259
+ return { kind: 'success', text: lines.join('\n') }
260
+ } catch (error) {
261
+ return { kind: 'error', text: `读取模型列表失败:${error instanceof Error ? error.message : String(error)}` }
262
+ }
263
+ }
264
+
265
+ if (verb === 'credits') {
266
+ if (isExpired(account.id)) {
267
+ return { kind: 'error', text: `${account.id} 的登录已失效,请重新登录` }
268
+ }
269
+ try {
270
+ const credits = await fetchCredits(account.session)
271
+ anchorLedger(account.id, credits, '积分查询')
272
+ const lines = [
273
+ `${account.id}:`,
274
+ ` ${creditsLine(credits)}`,
275
+ ` 今日消耗 ${credits.dailyConsumed} 周期 ${credits.dailyCycleDate}`,
276
+ ` 今日赠送:${grantedToday(account.id) ? '已领取' : '尚未领取'}`
277
+ ]
278
+ return { kind: 'success', text: lines.join('\n') }
279
+ } catch (error) {
280
+ const message = error instanceof Error ? error.message : String(error)
281
+ if (error?.isAuthError === true) markExpired(account.id, message)
282
+ return { kind: 'error', text: `读取积分失败:${message}` }
283
+ }
284
+ }
285
+
286
+ if (verb === 'claim') {
287
+ const result = await ensureDailyGrant(account.id, account.session, { force: true, reason: '手动' })
288
+ if (result.error !== undefined) return { kind: 'error', text: `领取失败:${result.error}` }
289
+ return {
290
+ kind: 'success',
291
+ text: [`${account.id}:每日赠送 ${result.balance ?? 0}/${result.quota ?? 0}${result.granted ? '(今日额度已就绪)' : ''}`].join('\n')
292
+ }
293
+ }
294
+
295
+ if (verb === 'tasks') {
296
+ try {
297
+ const onboarding = await fetchOnboardingTasks(account.session)
298
+ const lines = [`${account.id}:新手任务 ${onboarding.earned}/${onboarding.total}`]
299
+ for (const [key, done] of Object.entries(onboarding.tasks)) {
300
+ lines.push(` ${done ? '[x]' : '[ ]'} ${key}`)
301
+ }
302
+ const open = Object.entries(onboarding.tasks).filter(([, done]) => done !== true).map(([key]) => key)
303
+ if (open.length > 0) lines.push('', `可领取:/loomy claimtask ${open[0]}`, '(任务奖励是每次账号只有一份的长期积分,不会自动领取)')
304
+ return { kind: 'success', text: lines.join('\n') }
305
+ } catch (error) {
306
+ return { kind: 'error', text: `读取新手任务失败:${error instanceof Error ? error.message : String(error)}` }
307
+ }
308
+ }
309
+
310
+ if (verb === 'claimtask') {
311
+ const key = parts[2] ?? ''
312
+ if (key.length === 0) return { kind: 'error', text: '用法:/loomy claimtask <任务名>(先运行 /loomy tasks 查看)' }
313
+ try {
314
+ const result = await completeOnboardingTask(account.session, key)
315
+ return {
316
+ kind: 'success',
317
+ text: [`${account.id}:${key} ${result.alreadyCompleted ? '此前已完成' : '已完成'},当前永久积分 ${result.balance}`].join('\n')
318
+ }
319
+ } catch (error) {
320
+ return { kind: 'error', text: `完成任务失败:${error instanceof Error ? error.message : String(error)}` }
321
+ }
322
+ }
323
+
324
+ return { kind: 'error', text: `未知子命令 "${verb}"。请使用 status、login、import、logout、models、credits、claim、tasks 或 claimtask。` }
325
+ }
326
+
327
+ /**
328
+ * Register (or refresh) the provider route for one account.
329
+ *
330
+ * `registerAdapter` is all-or-nothing per call and throws on a duplicate route, so a
331
+ * re-login of an already-mounted account has to drop the old registration first — which
332
+ * is what the returned disposer is for. Called from every path that can produce an
333
+ * account: boot, SMS login, desktop import, and the panel.
334
+ */
335
+ export function mountAccount(ctx, adapters, account) {
336
+ adapters[account.id]?.dispose?.()
337
+ delete adapters[account.id]
338
+ const provider = providerFor(account.id)
339
+ const adapter = new LoomyAdapter(ctx, account, provider)
340
+ const handle = ctx.llm.registerAdapter([provider], adapter)
341
+ adapter.dispose = () => {
342
+ try {
343
+ handle()
344
+ } catch {
345
+ // already released
346
+ }
347
+ };
348
+ adapters[account.id] = adapter
349
+ ctx.logger.info(`[loomy] 已注册模型路由 ${provider}(账号 ${account.id})`)
350
+ return adapter
351
+ }
352
+
353
+ export function apply(ctx, config) {
354
+ // Every load gets a short tag: the host can hold more than one instance alive across
355
+ // reloads, and their timers then interleave in the log.
356
+ const instance = randomUUID().slice(0, 4)
357
+ /** @type {Record<string, LoomyAdapter>} */
358
+ const adapters = {}
359
+
360
+ // Mount every stored account before anything waits on the network, so the model picker
361
+ // has routes as soon as the plugin is applied. An account whose session turns out to be
362
+ // dead still gets a route; its requests fail with AUTH, which the panel explains.
363
+ for (const account of readAccounts()) mountAccount(ctx, adapters, account)
364
+
365
+ ctx.commands.register({
366
+ definitionId: 'dsh-loomy',
367
+ name: 'loomy',
368
+ description: '登录并管理 Loomy 账号(每日赠送 5000 积分),把 Loomy 模型接入 DSH',
369
+ input: { hint: '[status|login|import|logout|models|credits|claim|tasks]' },
370
+ handler: (invocation) => executeCommand(ctx, adapters, invocation)
371
+ })
372
+
373
+ // Settings panel surface. Optional: a composition without an HTTP carrier (headless
374
+ // runs) keeps the provider routes and the command.
375
+ ctx.inject(['webServer'], (webCtx) => {
376
+ webCtx.effect(
377
+ () => registerLoomyApi(webCtx, {
378
+ adapters,
379
+ mountAccount: (account) => mountAccount(ctx, adapters, account),
380
+ unmountAccount: (id) => {
381
+ adapters[id]?.dispose?.()
382
+ delete adapters[id]
383
+ },
384
+ runDailyGrant: (reason) => runDailyGrant(ctx, reason)
385
+ }),
386
+ 'dsh-loomy: /loomy HTTP routes'
387
+ )
388
+ })
389
+
390
+ // Boot: claim the day's credits, and point a fresh install at the ways in.
391
+ //
392
+ // Nothing is imported here on purpose. Adopting a desktop login is a decision, and the
393
+ // desktop client parks its session under C:\Users\Public\Loomy — a directory shared by
394
+ // every local user — so a silent import on first start is how a plugin ends up holding
395
+ // someone else's account. `/loomy import` and the panel button do the same work, on
396
+ // request, for this user's own directories.
397
+ const bootTimer = setTimeout(() => {
398
+ void (async () => {
399
+ // The daily claim, which is the whole point of the timer.
400
+ await runDailyGrant(ctx, '启动')
401
+ })().catch((error) => {
402
+ ctx.logger.warn(`[loomy] 启动初始化失败 -- ${error instanceof Error ? error.message : String(error)}`);
403
+ })
404
+ }, GRANT_BOOT_DELAY_MS)
405
+ bootTimer.unref?.()
406
+
407
+ // Daily claim at local midnight. The endpoint is idempotent per day, so a missed run
408
+ // costs nothing: the next one simply claims the day that is now current.
409
+ let midnightTimer
410
+ const scheduleMidnight = () => {
411
+ midnightTimer = setTimeout(() => {
412
+ void runDailyGrant(ctx, '午夜').catch(() => {})
413
+ scheduleMidnight()
414
+ }, msUntilNextMidnight())
415
+ midnightTimer.unref?.()
416
+ };
417
+ scheduleMidnight()
418
+
419
+ ctx.logger.info(`[loomy] 已注册 ${Object.keys(adapters).length} 个模型路由;网关 ${GATEWAY_BASE_URL}(实例 ${instance})`)
420
+ if (Object.keys(adapters).length === 0) {
421
+ ctx.logger.info('[loomy] 还没有账号:运行 /loomy import 导入本机 Loomy 桌面端登录,或 /loomy login <手机号> 短信登录')
422
+ }
423
+
424
+ ctx.effect(() => () => {
425
+ ctx.logger.info(`[loomy] 定时器全部撤销(实例 ${instance})`)
426
+ clearTimeout(bootTimer)
427
+ clearTimeout(midnightTimer)
428
+ }, 'dsh-loomy: daily credit timers')
429
+ }