@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,406 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live protocol check for the Loomy plugin.
4
+ *
5
+ * Re-asserts every claim in docs/protocol.md against the real service and prints
6
+ * one PASS/FAIL line per assertion. Exit code is 1 if any assertion failed, so CI
7
+ * can gate on it.
8
+ *
9
+ * node docs/verify/live-check.mjs # full run
10
+ * node docs/verify/live-check.mjs --offline # skip the one paid call
11
+ * node docs/verify/live-check.mjs --session <path>
12
+ *
13
+ * Auth: reads the Loomy desktop session file by default. Set LOOMY_SESSION to a
14
+ * session string instead.
15
+ *
16
+ * SECRETS: the session is never printed. Values are redacted to
17
+ * "<first2>…<last2> (len N)" wherever a message might carry one. Do not add
18
+ * logging that bypasses redact().
19
+ *
20
+ * Points: every call here is a free read except the single chat probe, which
21
+ * targets the free model (spark-x2.6) so it costs 0 points. --offline drops it.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto'
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+
29
+ const API = 'https://loomyad.xunfei.cn'
30
+ const GATEWAY = API + '/api/v1/chat/completions'
31
+ const VERSION = '0.9.39'
32
+
33
+ const args = process.argv.slice(2)
34
+ const offline = args.includes('--offline')
35
+ const sessionArgIndex = args.indexOf('--session')
36
+
37
+ /**
38
+ * Where this user's Loomy desktop session may live.
39
+ *
40
+ * On Windows the desktop client relocates its state to
41
+ * `C:\\Users\\Public\\Loomy\\<sha256(username)[:12]>\\userData\`, so the directory is
42
+ * derived rather than hard-coded — a literal hash would only work on the machine it was
43
+ * copied from.
44
+ *
45
+ * Only this user's directory is probed. `C:\\Users\\Public\` is shared
46
+ * by every local account, so listing that root would pick up whoever ran Loomy last —
47
+ * exactly the behaviour the plugin was changed to stop doing.
48
+ */
49
+ function windowsSessionPaths() {
50
+ const candidates = []
51
+ try {
52
+ const hash = createHash('sha256').update(os.userInfo().username).digest('hex').slice(0, 12)
53
+ candidates.push(path.join('C:\\Users\\Public\\Loomy', hash, 'userData', 'auth-session.json'))
54
+ } catch {
55
+ // userInfo can throw in a container; APPDATA below still applies
56
+ }
57
+ const roaming = process.env.APPDATA
58
+ if (typeof roaming === 'string' && roaming.length > 0) {
59
+ candidates.push(path.join(roaming, 'Loomy', 'auth-session.json'))
60
+ }
61
+ return candidates
62
+ }
63
+
64
+ const DEFAULT_SESSION_PATHS = process.platform === 'win32'
65
+ ? windowsSessionPaths()
66
+ : [
67
+ path.join(os.homedir(), 'Library', 'Application Support', 'Loomy', 'auth-session.json'),
68
+ path.join(os.homedir(), '.config', 'loomy', 'auth-session.json')
69
+ ]
70
+
71
+ let session = ''
72
+ let userid = ''
73
+ let accountSource = ''
74
+
75
+ /** Never let a session string reach stdout. */
76
+ function redact(value) {
77
+ const text = String(value ?? '')
78
+ if (session.length > 4 && text.includes(session)) {
79
+ return text.split(session).join(`<${mask(session)}>`)
80
+ }
81
+ return text
82
+ }
83
+
84
+ function mask(value) {
85
+ const text = String(value ?? '')
86
+ if (text.length <= 4) return '*'.repeat(text.length)
87
+ return `${text.slice(0, 2)}…${text.slice(-2)} (len ${text.length})`
88
+ }
89
+
90
+ const results = []
91
+ function record(ok, name, detail = '') {
92
+ results.push({ ok, name, detail })
93
+ const tag = ok ? 'PASS' : 'FAIL'
94
+ console.log(`${tag} ${name}${detail ? ' — ' + redact(detail) : ''}`)
95
+ }
96
+
97
+ function sleep(ms) {
98
+ return new Promise((resolve) => setTimeout(resolve, ms))
99
+ }
100
+
101
+ /** Load the session, preferring an explicit path, then env, then known locations. */
102
+ function loadSession() {
103
+ if (process.env.LOOMY_SESSION) {
104
+ session = process.env.LOOMY_SESSION.trim()
105
+ accountSource = 'env LOOMY_SESSION'
106
+ return true
107
+ }
108
+ const explicit = sessionArgIndex !== -1 ? args[sessionArgIndex + 1] : undefined
109
+ const candidates = explicit ? [explicit] : DEFAULT_SESSION_PATHS
110
+ for (const candidate of candidates) {
111
+ try {
112
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'))
113
+ if (typeof parsed?.session === 'string' && parsed.session.length > 0) {
114
+ session = parsed.session
115
+ userid = String(parsed.userid ?? '')
116
+ accountSource = candidate
117
+ return true
118
+ }
119
+ } catch {
120
+ // try the next candidate
121
+ }
122
+ }
123
+ return false
124
+ }
125
+
126
+ /** JSON GET against the points/business API (token header, envelope expected). */
127
+ async function apiGet(pathname, { token } = {}) {
128
+ const headers = { Accept: 'application/json', 'loomy-version': VERSION }
129
+ if (token !== null) headers.token = token ?? session
130
+ const response = await fetch(API + pathname, { headers, signal: AbortSignal.timeout(30_000) })
131
+ const text = await response.text()
132
+ let json = null
133
+ try { json = JSON.parse(text) } catch { json = null }
134
+ return { status: response.status, headers: response.headers, text, json }
135
+ }
136
+
137
+ async function apiPost(pathname, body, { token } = {}) {
138
+ const headers = {
139
+ Accept: 'application/json',
140
+ 'Content-Type': 'application/json',
141
+ 'loomy-version': VERSION
142
+ }
143
+ if (token !== null) headers.token = token ?? session
144
+ const response = await fetch(API + pathname, {
145
+ method: 'POST', headers, body: JSON.stringify(body ?? {}),
146
+ signal: AbortSignal.timeout(30_000)
147
+ })
148
+ const text = await response.text()
149
+ let json = null
150
+ try { json = JSON.parse(text) } catch { json = null }
151
+ return { status: response.status, headers: response.headers, text, json }
152
+ }
153
+
154
+ function hex(count) {
155
+ let out = ''
156
+ while (out.length < count) out += Math.floor(Math.random() * 16).toString(16)
157
+ return out
158
+ }
159
+
160
+ /** The full header set the desktop client sends for one chat turn. */
161
+ function chatHeaders() {
162
+ return {
163
+ 'Content-Type': 'application/json',
164
+ Accept: 'text/event-stream',
165
+ Authorization: 'Bearer ' + session,
166
+ token: session,
167
+ 'loomy-version': VERSION,
168
+ 'X-Loomy-Request-Purpose': 'chat.message',
169
+ traceparent: `00-${hex(32)}-${hex(16)}-01`
170
+ }
171
+ }
172
+
173
+ async function chatProbe(body, { headers } = {}) {
174
+ const response = await fetch(GATEWAY, {
175
+ method: 'POST',
176
+ headers: headers ?? chatHeaders(),
177
+ body: JSON.stringify(body),
178
+ signal: AbortSignal.timeout(60_000)
179
+ })
180
+ const text = await response.text()
181
+ return { status: response.status, contentType: response.headers.get('content-type') || '', text }
182
+ }
183
+
184
+ /** Split an SSE body into its `data:` payloads, preserving the [DONE] sentinel. */
185
+ function sseEvents(text) {
186
+ const out = []
187
+ for (const line of text.split('\n')) {
188
+ const trimmed = line.trim()
189
+ if (!trimmed.startsWith('data:')) continue
190
+ const payload = trimmed.slice(5).trim()
191
+ if (payload === '[DONE]') { out.push({ done: true }); continue }
192
+ try { out.push({ json: JSON.parse(payload) }) } catch { out.push({ raw: payload }) }
193
+ }
194
+ return out
195
+ }
196
+
197
+ async function main() {
198
+ console.log('Loomy protocol live check')
199
+ console.log('='.repeat(60))
200
+
201
+ /* ── 0. credentials ─────────────────────────────────────────────── */
202
+ if (!loadSession()) {
203
+ console.error('FAIL no session found. Set LOOMY_SESSION or pass --session <path>.')
204
+ process.exit(1)
205
+ }
206
+ record(true, 'session loaded', `${accountSource}; userid=${userid ? mask(userid) : '(absent)'}; session=${mask(session)}`)
207
+
208
+ /* ── 1. model catalog ───────────────────────────────────────────── */
209
+ const models = await apiGet('/api/v1/models')
210
+ record(models.status === 200, 'GET /api/v1/models -> 200', `status ${models.status}`)
211
+ const isBareList = models.json !== null && Array.isArray(models.json?.data) && models.json?.code === undefined
212
+ record(isBareList, 'model list is a BARE list (no envelope, no code field)',
213
+ isBareList ? `keys: ${Object.keys(models.json).join(',')}` : `body: ${models.text.slice(0, 160)}`)
214
+ const catalog = Array.isArray(models.json?.data) ? models.json.data : []
215
+ record(catalog.length > 0, 'catalog is non-empty', `${catalog.length} models`)
216
+ record(typeof models.json?.reasoning_catalog_version === 'string',
217
+ 'reasoning_catalog_version present', String(models.json?.reasoning_catalog_version))
218
+ const defaultModel = catalog.find((entry) => entry?.is_default_model === true)
219
+ record(defaultModel !== undefined, 'exactly one is_default_model',
220
+ defaultModel ? defaultModel.id : 'none flagged')
221
+ record(catalog.every((entry) => typeof entry?.id === 'string' && entry.id.length > 0),
222
+ 'every catalog entry has a string id')
223
+ for (const entry of catalog.slice(0, 3)) {
224
+ record(typeof entry.protocol === 'string' && entry.protocol === 'openai_chat',
225
+ `${entry.id}: protocol openai_chat`, String(entry.protocol))
226
+ }
227
+
228
+ /* ── 2. auth header matrix ──────────────────────────────────────── */
229
+ const tokenOnly = await apiGet('/api/v1/models')
230
+ const authOnly = await apiGet('/api/v1/models', { token: null })
231
+ record(Array.isArray(tokenOnly.json?.data), 'models accepts token header only')
232
+ record(authOnly.json?.code === '100002', 'models REJECTS Authorization-only with code 100002',
233
+ `code=${authOnly.json?.code} desc=${authOnly.json?.desc}`)
234
+
235
+ const chatTokenOnly = await chatProbe(
236
+ { model: defaultModel?.id ?? 'spark-x2.6', messages: [{ role: 'user', content: 'hi' }], stream: true },
237
+ { headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', token: session, 'loomy-version': VERSION } }
238
+ )
239
+ record(chatTokenOnly.contentType.includes('application/json') && chatTokenOnly.text.includes('100002'),
240
+ 'chat REJECTS token-only with code 100002 (misleading "缺少 token")',
241
+ chatTokenOnly.text.slice(0, 120))
242
+
243
+ /* ── 3. points / daily grant ────────────────────────────────────── */
244
+ const first = await apiPost('/api/v1/points/first-login', {})
245
+ record(first.json?.code === '000000', 'POST points/first-login -> code 000000',
246
+ `code=${first.json?.code} desc=${first.json?.desc}`)
247
+ const balance = first.json?.data ?? {}
248
+ record(typeof balance.dailyQuota === 'number' && balance.dailyQuota > 0,
249
+ 'dailyQuota reported', String(balance.dailyQuota))
250
+ record(typeof balance.dailyCycleDate === 'string', 'dailyCycleDate is a date string',
251
+ String(balance.dailyCycleDate))
252
+ const identityHolds = balance.currentBalance === (balance.permanentBalance ?? 0) + (balance.dailyBalance ?? 0)
253
+ record(identityHolds, 'currentBalance === permanentBalance + dailyBalance',
254
+ `${balance.currentBalance} === ${balance.permanentBalance} + ${balance.dailyBalance}`)
255
+
256
+ // Idempotency: a second call in the same dailyCycleDate must not change the balance.
257
+ const second = await apiPost('/api/v1/points/first-login', {})
258
+ const secondBalance = second.json?.data ?? {}
259
+ record(second.json?.code === '000000' && secondBalance.currentBalance === balance.currentBalance,
260
+ 'first-login is idempotent within the same dailyCycleDate',
261
+ `alreadyProcessed=${secondBalance.alreadyProcessed} balance ${balance.currentBalance} -> ${secondBalance.currentBalance}`)
262
+ record(secondBalance.dailyCycleDate === balance.dailyCycleDate,
263
+ 'dailyCycleDate stable across repeated calls', String(secondBalance.dailyCycleDate))
264
+
265
+ /* ── 4. records (v1 vs v2 shape) ────────────────────────────────── */
266
+ const recordsV1 = await apiGet('/api/v1/points/records?pageNo=1&pageSize=5&recordType=all')
267
+ record(recordsV1.json?.code === '000000', 'GET v1 points/records -> 000000')
268
+ const v1List = recordsV1.json?.data?.list
269
+ record(Array.isArray(v1List), 'v1 records expose a list array')
270
+ if (Array.isArray(v1List) && v1List.length > 0) {
271
+ record('pointsActual' in v1List[0], 'v1 record carries pointsActual (not points)',
272
+ Object.keys(v1List[0]).join(','))
273
+ record('dailyCycleDate' in v1List[0], 'v1 record carries dailyCycleDate')
274
+ }
275
+ const recordsV2 = await apiGet('/api/v2/points/records?pageNo=1&pageSize=5&direction=credit')
276
+ record(recordsV2.json?.code === '000000', 'GET v2 points/records -> 000000')
277
+ const v2List = recordsV2.json?.data?.list
278
+ if (Array.isArray(v2List) && v2List.length > 0) {
279
+ record('points' in v2List[0] && !('pointsActual' in v2List[0]),
280
+ 'v2 record carries points (not pointsActual)', Object.keys(v2List[0]).join(','))
281
+ }
282
+
283
+ /* ── 5. read-only business endpoints ────────────────────────────── */
284
+ for (const [label, pathname] of [
285
+ ['user/identity', '/api/v1/user/identity'],
286
+ ['onboarding/tasks', '/api/v1/onboarding/tasks'],
287
+ ['pet-work', '/api/v1/pet-work']
288
+ ]) {
289
+ const res = await apiGet(pathname)
290
+ record(res.json?.code === '000000', `GET ${label} -> 000000`, String(res.json?.desc ?? ''))
291
+ }
292
+ const tasks = await apiGet('/api/v1/onboarding/tasks')
293
+ record(tasks.json?.data?.total === 10000, 'onboarding total pool is 10000',
294
+ String(tasks.json?.data?.total))
295
+
296
+ /* ── 6. error shapes ────────────────────────────────────────────── */
297
+ const badToken = await apiGet('/api/v1/models', { token: '0'.repeat(32) })
298
+ record(badToken.status === 200 && badToken.json?.code === '100002',
299
+ 'bad token -> HTTP 200 with code 100002 (status is not the failure signal)',
300
+ `status=${badToken.status} code=${badToken.json?.code}`)
301
+ record(typeof badToken.json?.desc === 'string', 'error envelope carries a human desc',
302
+ String(badToken.json?.desc))
303
+
304
+ const emptyMessages = await chatProbe({ model: defaultModel?.id ?? 'spark-x2.6', messages: [], stream: true })
305
+ record(emptyMessages.status === 400 && emptyMessages.text.includes('messages 不能为空'),
306
+ 'empty messages -> 400 "messages 不能为空" (no free probe via empty array)',
307
+ `status=${emptyMessages.status}`)
308
+
309
+ /* ── 7. rate-limit headers ──────────────────────────────────────── */
310
+ const headerNames = [...models.headers.keys()]
311
+ const rateLimits = headerNames.filter((name) => /ratelimit|retry-after|x-rate/i.test(name))
312
+ record(rateLimits.length === 0, 'no rate-limit headers on responses',
313
+ rateLimits.length ? rateLimits.join(',') : `headers: ${headerNames.join(',')}`)
314
+
315
+ /* ── 8. the one chat probe (free model, costs 0 points) ─────────── */
316
+ if (offline) {
317
+ console.log('SKIP chat/SSE probe (--offline)')
318
+ } else {
319
+ const freeModel = catalog.find((entry) => /x0\.0/.test(String(entry.name ?? '')))?.id ?? 'spark-x2.6'
320
+ const stream = await chatProbe({
321
+ model: freeModel,
322
+ messages: [{ role: 'user', content: 'Reply with the single word: ok' }],
323
+ stream: true,
324
+ tools: [{
325
+ type: 'function',
326
+ function: {
327
+ name: 'noop_tool',
328
+ description: 'Does nothing',
329
+ parameters: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }
330
+ }
331
+ }]
332
+ })
333
+ record(stream.contentType.includes('text/event-stream'),
334
+ 'chat with Authorization -> text/event-stream', stream.contentType)
335
+
336
+ const events = sseEvents(stream.text)
337
+ record(events.length > 0, 'SSE stream carries data: events', `${events.length} events`)
338
+ record(events.some((event) => event.done === true), 'SSE stream ends with data: [DONE]')
339
+ const firstChunk = events.find((event) => event.json)?.json
340
+ record(firstChunk?.object === 'chat.completion.chunk', 'first event is a chat.completion.chunk',
341
+ String(firstChunk?.object))
342
+ record(typeof firstChunk?.model === 'string' && firstChunk.model === freeModel,
343
+ 'stream reports the model actually used', String(firstChunk?.model))
344
+
345
+ const usageEvent = events.find((event) => event.json?.usage !== undefined)
346
+ record(usageEvent !== undefined, 'stream carries a usage block',
347
+ usageEvent ? JSON.stringify(usageEvent.json.usage) : 'absent')
348
+ if (usageEvent) {
349
+ const usage = usageEvent.json.usage
350
+ record(typeof usage.points_consumed === 'number', 'usage.points_consumed is present and numeric',
351
+ `points_consumed=${usage.points_consumed}`)
352
+ record(Array.isArray(usageEvent.json.choices) && usageEvent.json.choices.length === 0,
353
+ 'usage chunk has an EMPTY choices array (adapter must guard choices[0])')
354
+ }
355
+
356
+ // Reasoning and/or content must arrive as deltas.
357
+ const hasDelta = events.some((event) => {
358
+ const delta = event.json?.choices?.[0]?.delta
359
+ return delta && (typeof delta.content === 'string' || typeof delta.reasoning_content === 'string')
360
+ })
361
+ record(hasDelta, 'stream carries delta.content and/or delta.reasoning_content')
362
+
363
+ const finish = events.find((event) => typeof event.json?.choices?.[0]?.finish_reason === 'string' && event.json.choices[0].finish_reason !== null)
364
+ record(finish !== undefined, 'stream carries a finish_reason',
365
+ finish ? String(finish.json.choices[0].finish_reason) : 'absent')
366
+
367
+ // Non-streaming must be accepted.
368
+ const plain = await chatProbe({ model: freeModel, messages: [{ role: 'user', content: 'Reply with: ok' }], stream: false })
369
+ let plainJson = null
370
+ try { plainJson = JSON.parse(plain.text) } catch { plainJson = null }
371
+ record(plain.contentType.includes('application/json') && plainJson?.object === 'chat.completion',
372
+ 'stream:false is accepted and answers a chat.completion object', plain.contentType)
373
+ record(Array.isArray(plainJson?.choices) && typeof plainJson?.choices?.[0]?.message?.content === 'string',
374
+ 'non-streaming answer carries choices[0].message.content')
375
+
376
+ // An unknown model must NOT error — it silently falls back. Documented trap.
377
+ const unknown = await chatProbe({ model: 'definitely-not-a-real-model-xyz', messages: [{ role: 'user', content: 'hi' }], stream: true })
378
+ const unknownFirst = sseEvents(unknown.text).find((event) => event.json)?.json
379
+ record(unknown.status === 200 && unknownFirst?.model !== 'definitely-not-a-real-model-xyz',
380
+ 'unknown model id silently falls back instead of erroring (trap)',
381
+ `requested=definitely-not-a-real-model-xyz served=${unknownFirst?.model}`)
382
+ }
383
+
384
+ /* ── 9. balance did not move ────────────────────────────────────── */
385
+ await sleep(400)
386
+ const after = await apiPost('/api/v1/points/first-login', {})
387
+ const afterBalance = after.json?.data ?? {}
388
+ record(afterBalance.currentBalance === balance.currentBalance,
389
+ 'checks spent no points (balance unchanged)',
390
+ `${balance.currentBalance} -> ${afterBalance.currentBalance}`)
391
+
392
+ /* ── summary ────────────────────────────────────────────────────── */
393
+ const failed = results.filter((entry) => !entry.ok)
394
+ console.log('='.repeat(60))
395
+ console.log(`${results.length - failed.length}/${results.length} PASS, ${failed.length} FAIL`)
396
+ if (failed.length > 0) {
397
+ console.log('\nFailed assertions:')
398
+ for (const entry of failed) console.log(` - ${entry.name}${entry.detail ? ': ' + redact(entry.detail) : ''}`)
399
+ }
400
+ process.exit(failed.length === 0 ? 0 : 1)
401
+ }
402
+
403
+ main().catch((error) => {
404
+ console.error('FAIL uncaught: ' + redact(error instanceof Error ? error.message : String(error)))
405
+ process.exit(1)
406
+ })
package/icon-mask.png ADDED
Binary file
package/icon.png ADDED
Binary file
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Loomy Models",
4
+ "description": "Connects iFlytek's Loomy desktop model service: sign in with an SMS code or adopt the desktop app's session, metered in points with the 5,000 daily quota claimed automatically."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Loomy 模型",
4
+ "description": "接入讯飞 Loomy 桌面端的模型服务:用手机号短信或本机桌面端登录态接入账号,按积分计费,每天自动领取 5000 每日赠送积分。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@kukonce/dsh-loomy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.2",
4
+ "description": "Loomy (讯飞 Loomy) model provider for DeepSeek Harness — session login, live model catalog, daily 5000-credit renewal",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./client/index.js",
10
+ "./package.json": "./package.json",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.png"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "client",
17
+ "docs",
18
+ "locale/*.json",
19
+ "cordis.patch.yml",
20
+ "icon.png",
21
+ "icon-mask.png",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "llm-provider",
30
+ "loomy",
31
+ "xfyun"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20.11"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "immediately": true,
43
+ "inject": []
44
+ }
45
+ },
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/kukonce/dsh-loomy.git"
50
+ },
51
+ "homepage": "https://github.com/kukonce/dsh-loomy#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/kukonce/dsh-loomy/issues"
54
+ },
55
+ "author": "kukonce",
56
+ "scripts": {
57
+ "verify": "node docs/verify/live-check.mjs",
58
+ "verify:offline": "node docs/verify/live-check.mjs --offline"
59
+ }
6
60
  }