@whyshu/argent 0.1.27 → 0.2.1
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 +16 -65
- package/cordis.patch.yml +6 -0
- package/dist/client.js +691 -0
- package/mcp/billing-upload.mjs +77 -0
- package/mcp/erp-server.mjs +296 -0
- package/mcp/gateway.mjs +336 -0
- package/mcp/meter.mjs +101 -0
- package/mcp/vault.mjs +78 -0
- package/mcp/vendor/account-token.mjs +37 -0
- package/mcp/vendor/redact.mjs +34 -0
- package/package.json +28 -18
- package/src/account-token.mjs +40 -0
- package/src/actions.mjs +114 -0
- package/src/config.mjs +47 -0
- package/src/error-log.mjs +56 -0
- package/src/fulfillment-result.mjs +84 -0
- package/src/health.mjs +231 -0
- package/src/index.js +239 -0
- package/src/kernel.mjs +111 -0
- package/src/lease.mjs +90 -0
- package/src/llm-whyshu.mjs +155 -0
- package/src/machine-session.mjs +114 -0
- package/src/office-runtime.js +1102 -0
- package/src/skuflow-runner.mjs +57 -0
- package/src/timer-status.mjs +160 -0
- package/src/whyshu-auth.mjs +277 -0
- package/bin/argent.mjs +0 -385
- package/lib/index.mjs +0 -604
- package/runner/argent-runner.mjs +0 -223
package/src/index.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @whyshu/argent —— **单插件**(dsh web / DSH Desktop 同一份,2026-11-07 合并)
|
|
3
|
+
*
|
|
4
|
+
* 形态:一个 bundle 一行 insert,内部自包含;不再区分 argent-web / argent-desktop。
|
|
5
|
+
* 两个宿主是同一个 web app(Electron 包着 dsh web),宿主注入均为 `webServer` ⇒ 一套代码适配两端:
|
|
6
|
+
* ① 问述 provider(llm-whyshu,动态 import:宿主缺 @deepseek-ai/dsh-llm* 时只降级不炸 boot)
|
|
7
|
+
* ② 一键登录端点(设备码,只写 credentials;含 refresh_token 自动续期)
|
|
8
|
+
* ③ MCP 网关(engine.* / erp.* / ml.*)自注册,不需要 profile patch 再挂 mcp-argent-*
|
|
9
|
+
* ④ ERP 机器通道(office-runtime)——受「先到先得」租约保护:抢到 poller 才轮询,否则纯 UI
|
|
10
|
+
* ⑤ 桌宠/积分面板端点(/api/argent/account、/api/argent/health;客户端 bundle 5s 轮询)
|
|
11
|
+
*
|
|
12
|
+
* 数据目录:唯一判据在内核(core/lib/argent-datadir.mjs);解析不到时回退 ARGENT_DATA_DIR / ~/.dsh/argent。
|
|
13
|
+
* 引擎目录:ARGENT_ENGINE_DIR → 内核 argent.json#engineDir → <dataDir>/engines/<ver>/engine → ~/.argent-dsh。
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
19
|
+
import { createRequire } from 'node:module'
|
|
20
|
+
|
|
21
|
+
import { resolveKernel } from './kernel.mjs'
|
|
22
|
+
import { startOfficeRuntime } from './office-runtime.js'
|
|
23
|
+
import { registerWhyshuAuth } from './whyshu-auth.mjs'
|
|
24
|
+
import { acquireLease, heartbeatLease, releaseLease, leaseHolder } from './lease.mjs'
|
|
25
|
+
import { resolveAccountToken } from './account-token.mjs'
|
|
26
|
+
import { installMachineSessionReporter } from './machine-session.mjs'
|
|
27
|
+
|
|
28
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
29
|
+
const PKG_VERSION = (() => {
|
|
30
|
+
try { return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version || '' } catch { return '' }
|
|
31
|
+
})()
|
|
32
|
+
|
|
33
|
+
const API_BASE = (process.env.ARGENT_API_BASE || 'https://whyshu.com').replace(/\/+$/, '')
|
|
34
|
+
const TENANT = process.env.ARGENT_TENANT || 'ly-0001'
|
|
35
|
+
const HEARTBEAT_MS = 30_000
|
|
36
|
+
|
|
37
|
+
/** 引擎目录探测(决定 MCP 网关是否挂 engine.* 上游):找不到就只挂 erp.*,网关照常起。 */
|
|
38
|
+
function detectEngineDir(dataDir, preferred = '') {
|
|
39
|
+
if (preferred) return preferred
|
|
40
|
+
if (process.env.ARGENT_ENGINE_DIR) return process.env.ARGENT_ENGINE_DIR
|
|
41
|
+
const cands = []
|
|
42
|
+
try {
|
|
43
|
+
for (const d of readdirSync(join(dataDir, 'engines'))) cands.push(join(dataDir, 'engines', d, 'engine'))
|
|
44
|
+
} catch { /* 没有 engines 目录 */ }
|
|
45
|
+
cands.push(join(homedir(), '.argent-dsh'))
|
|
46
|
+
for (const c of cands.reverse()) {
|
|
47
|
+
try { if (existsSync(join(c, 'core', 'mcp', 'server.mjs'))) return c } catch { /* next */ }
|
|
48
|
+
}
|
|
49
|
+
return ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 宿主模块解析(bare → profile createRequire 回退;软链/复制安装都能用) */
|
|
53
|
+
async function hostImport(name) {
|
|
54
|
+
let first = null
|
|
55
|
+
try { return await import(name) } catch (e) { first = e }
|
|
56
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
57
|
+
for (const p of ['desktop', 'web', 'headless']) {
|
|
58
|
+
try {
|
|
59
|
+
const req = createRequire(pathToFileURL(join(home, 'profiles', p, 'package.json')).href)
|
|
60
|
+
return await import(pathToFileURL(req.resolve(name)).href)
|
|
61
|
+
} catch { /* next */ }
|
|
62
|
+
}
|
|
63
|
+
throw first || new Error(`host module not found: ${name}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 账号面板数据(伞伞积分卡):/me + /tenants/:id/billing/summary;失败不影响其它功能。 */
|
|
67
|
+
async function fetchAccount() {
|
|
68
|
+
const { token, source } = resolveAccountToken()
|
|
69
|
+
if (!token) return { ok: false, code: 'no_token', message: '未登录问述账号' }
|
|
70
|
+
const h = { 'x-api-key': token, accept: 'application/json' }
|
|
71
|
+
try {
|
|
72
|
+
const [me, bill] = await Promise.all([
|
|
73
|
+
fetch(`${API_BASE}/api/argent/v1/me`, { headers: h, signal: AbortSignal.timeout(8000) }).then((r) => r.json()),
|
|
74
|
+
fetch(`${API_BASE}/api/argent/v1/tenants/${encodeURIComponent(TENANT)}/billing/summary`, { headers: h, signal: AbortSignal.timeout(8000) }).then((r) => r.json()),
|
|
75
|
+
])
|
|
76
|
+
const d = (bill && bill.data) || {}
|
|
77
|
+
const bal = d.balance || {}
|
|
78
|
+
const spent = d.spent || {}
|
|
79
|
+
return {
|
|
80
|
+
ok: true, source, tenant: TENANT,
|
|
81
|
+
walletMode: Boolean(d.walletMode),
|
|
82
|
+
user: (me && me.data && me.data.user) || null,
|
|
83
|
+
plan: (d.plan && d.plan.label) || ((me && me.data && me.data.user && me.data.user.plan) || ''),
|
|
84
|
+
points: bal.points ?? null,
|
|
85
|
+
spentPoints: spent.points ?? null,
|
|
86
|
+
spentYuan: spent.yuan ?? null,
|
|
87
|
+
calls: spent.calls ?? null,
|
|
88
|
+
period: d.period || null,
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { ok: false, code: 'network', message: String(e.message || e).slice(0, 120) }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const name = 'argent'
|
|
96
|
+
export const inject = ['webServer']
|
|
97
|
+
|
|
98
|
+
/** 问述代理默认配置(宿主 settings 未覆盖时生效)。 */
|
|
99
|
+
const WHYS_CFG = {
|
|
100
|
+
apiKeyEnv: 'WHYSHU_API_KEY',
|
|
101
|
+
baseURL: 'https://whyshu.com/api/argent/v1',
|
|
102
|
+
models: [
|
|
103
|
+
// dsh 的 catalogModelInfo 默认只给 ["text"] ⇒ 必须显式声明 image,否则 read_image 直接 UNSUPPORTED_CONTENT
|
|
104
|
+
{ id: 'deepseek-flash', name: 'DeepSeek V4.1 Flash(问述代理)', contextWindow: 131072, maxTokens: 32768, inputModalities: ['text', 'image'] },
|
|
105
|
+
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro(问述代理)', contextWindow: 131072, maxTokens: 32768, inputModalities: ['text', 'image'] },
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const json = (res, code, body) => {
|
|
110
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
111
|
+
res.end(JSON.stringify(body))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function apply(ctx) {
|
|
115
|
+
// ⚠️ apply 必须**同步返回**(cordis 不认 async apply ⇒ 会判 entry 未激活 ⇒ 启动恢复可能重置 profile)。
|
|
116
|
+
let dataDir = process.env.ARGENT_DATA_DIR || join(homedir(), '.dsh', 'argent')
|
|
117
|
+
let engineDir = process.env.ARGENT_ENGINE_DIR || ''
|
|
118
|
+
let role = 'ui'
|
|
119
|
+
let holder = null
|
|
120
|
+
|
|
121
|
+
// ① 问述 provider(异步块:宿主没有 dsh-llm* 时只告警,不阻塞 apply)
|
|
122
|
+
void (async () => {
|
|
123
|
+
try {
|
|
124
|
+
const { load } = await import('./llm-whyshu.mjs')
|
|
125
|
+
ctx.plugin(await load(), WHYS_CFG)
|
|
126
|
+
console.log('[argent] 问述代理 provider 已注册(whyshu)')
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error('[argent] 问述 provider 注册失败(不影响其它功能):', e?.message || e)
|
|
129
|
+
}
|
|
130
|
+
})()
|
|
131
|
+
|
|
132
|
+
// ② 一键登录端点(设备码;含 refresh_token 槽位)
|
|
133
|
+
try { registerWhyshuAuth(ctx) } catch (e) {
|
|
134
|
+
console.error('[argent] 登录端点注册失败:', e?.message || e)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ②.5 机器任务会话登记(计费互斥甲):监听 session/title 命中「后台派发:」⇒ 登记 sessionId
|
|
138
|
+
try {
|
|
139
|
+
installMachineSessionReporter(ctx, { dataDir })
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error('[argent] 机器任务会话登记安装失败(不影响其它功能):', e?.message || e)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ③ 账号面板端点(poller/ui 都注册:积分显示与轮询无关)
|
|
145
|
+
try {
|
|
146
|
+
ctx.webServer.register({
|
|
147
|
+
kind: 'prefix',
|
|
148
|
+
path: '/api/argent/account',
|
|
149
|
+
handler: async (req, res) => {
|
|
150
|
+
if ((req.method || 'GET') !== 'GET') return json(res, 405, { ok: false, error: 'method_not_allowed' })
|
|
151
|
+
const u = new URL(req.url || '/', 'http://localhost')
|
|
152
|
+
if (u.pathname !== '/api/argent/account') return json(res, 404, { ok: false, error: 'not_found' })
|
|
153
|
+
return json(res, 200, await fetchAccount())
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error('[argent] 账号端点注册失败:', e?.message || e)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ④ 健康/状态端点(单插件排障入口;poller/ui 都有)
|
|
161
|
+
try {
|
|
162
|
+
ctx.webServer.register({
|
|
163
|
+
kind: 'prefix',
|
|
164
|
+
path: '/api/argent/health',
|
|
165
|
+
handler: async (req, res) => {
|
|
166
|
+
if ((req.method || 'GET') !== 'GET') return json(res, 405, { ok: false, error: 'method_not_allowed' })
|
|
167
|
+
const u = new URL(req.url || '/', 'http://localhost')
|
|
168
|
+
if (u.pathname !== '/api/argent/health') return json(res, 404, { ok: false, error: 'not_found' })
|
|
169
|
+
return json(res, 200, {
|
|
170
|
+
ok: true, plugin: '@whyshu/argent', version: PKG_VERSION,
|
|
171
|
+
role, holder: holder || leaseHolder({ dataDir }),
|
|
172
|
+
})
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error('[argent] 健康端点注册失败:', e?.message || e)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ⑤ 内核解析 → 租约 → MCP 网关 → ERP 连接器(全部在异步块里,串行避免 dataDir 竞态)
|
|
180
|
+
void (async () => {
|
|
181
|
+
try {
|
|
182
|
+
const k = await resolveKernel().catch(() => null)
|
|
183
|
+
if (k?.mod?.resolveDataDir) {
|
|
184
|
+
const resolved = k.mod.resolveDataDir({ env: process.env }).dir
|
|
185
|
+
if (resolved) dataDir = resolved
|
|
186
|
+
}
|
|
187
|
+
if (!engineDir && k && k.dir) engineDir = k.dir
|
|
188
|
+
} catch { /* 内核不可用 ⇒ 用默认目录 */ }
|
|
189
|
+
engineDir = detectEngineDir(dataDir, engineDir)
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const r = acquireLease({ dataDir, host: 'web|desktop' })
|
|
193
|
+
role = r.role
|
|
194
|
+
holder = r.holder
|
|
195
|
+
if (role === 'poller') {
|
|
196
|
+
const t = setInterval(() => { try { heartbeatLease({ dataDir }) } catch { /* noop */ } }, HEARTBEAT_MS)
|
|
197
|
+
t.unref?.()
|
|
198
|
+
ctx.on?.('dispose', () => { clearInterval(t); try { releaseLease({ dataDir }) } catch { /* noop */ } })
|
|
199
|
+
}
|
|
200
|
+
console.log(`[argent] ERP 轮询角色:${role}${role === 'ui' ? `(已由 pid ${holder && holder.pid}@${(holder && holder.host) || '?'} 占用,本端只做 UI)` : ''}`)
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error('[argent] 租约获取失败(降级 UI):', e?.message || e)
|
|
203
|
+
role = 'ui'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// MCP 网关(engine.* / erp.* / ml.*)
|
|
207
|
+
try {
|
|
208
|
+
const mcpClient = await hostImport('@deepseek-ai/dsh-mcp-client')
|
|
209
|
+
await ctx.plugin(mcpClient, {
|
|
210
|
+
transport: 'stdio',
|
|
211
|
+
serverName: 'argent',
|
|
212
|
+
command: process.execPath,
|
|
213
|
+
args: [join(PKG_ROOT, 'mcp', 'gateway.mjs')],
|
|
214
|
+
cwd: PKG_ROOT,
|
|
215
|
+
env: {
|
|
216
|
+
ARGENT_DATA_DIR: dataDir,
|
|
217
|
+
ARGENT_TENANT: TENANT,
|
|
218
|
+
ARGENT_ENGINE_DIR: engineDir,
|
|
219
|
+
ARGENT_GATEWAY_ML: process.env.ARGENT_GATEWAY_ML || '',
|
|
220
|
+
},
|
|
221
|
+
toolCallTimeoutMs: 180000,
|
|
222
|
+
failOnStartupError: false,
|
|
223
|
+
})
|
|
224
|
+
console.log(`[argent] MCP 网关已注册(engineDir=${engineDir || '(无引擎)'})`)
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error('[argent] MCP 网关注册失败(其余功能不受影响):', e?.message || e)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ERP 连接器:只有 poller 才启动(UI 端不抢队列)
|
|
230
|
+
if (role === 'poller') {
|
|
231
|
+
try {
|
|
232
|
+
startOfficeRuntime(ctx, { dataDir })
|
|
233
|
+
console.log(`[argent] ERP 连接器已启动,数据目录: ${dataDir}`)
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error('[argent] ERP 连接器启动失败(不影响 dsh):', e?.message || e)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
})()
|
|
239
|
+
}
|
package/src/kernel.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel.mjs —— 宿主壳定位引擎的**唯一入口**(方案 C · P1)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:`index.js` 会 import 宿主包(`@deepseek-ai/dsh-llm*`),
|
|
5
|
+
* 在测试环境里加载不了;把"找引擎"这段逻辑抽出来,才能被真实单测覆盖。
|
|
6
|
+
*
|
|
7
|
+
* 优先级(⛔ 不要加第 4 条"再猜一个路径"):
|
|
8
|
+
* 1) `ARGENT_ENGINE_DIR` —— probe / 排障显式指定
|
|
9
|
+
* 2) `<profile>/argent.json` —— 启动器 `buildProfile()` 写入的契约文件
|
|
10
|
+
* 3) 兼容期回退:旧候选路径(`console.warn` 点名 legacy;`ARGENT_LEGACY_KERNEL_LOOKUP=0` 可关)
|
|
11
|
+
*
|
|
12
|
+
* 契约存在但不匹配 / 指向的目录不可用 ⇒ 返回 null 并打印可执行修复命令,
|
|
13
|
+
* **不回退 legacy**(否则会掩盖"装错了")。
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { pathToFileURL } from 'node:url'
|
|
19
|
+
|
|
20
|
+
/** 本壳要求的引擎契约版本(与 core/lib/contract.mjs 的 CONTRACT 对应) */
|
|
21
|
+
export const REQUIRED_CONTRACT = 1
|
|
22
|
+
|
|
23
|
+
/** 从 argv 里找 `<...>/profiles/<name>`(桌面 host 把 profile 目录作为参数传给 app) */
|
|
24
|
+
export function findProfileDir({ env = process.env, argv = process.argv } = {}) {
|
|
25
|
+
const cands = []
|
|
26
|
+
if (env.ARGENT_PROFILE_DIR) cands.push(env.ARGENT_PROFILE_DIR)
|
|
27
|
+
for (const a of argv) {
|
|
28
|
+
if (typeof a === 'string' && /[\\/]profiles[\\/][^\\/]+$/.test(a)) cands.push(a)
|
|
29
|
+
}
|
|
30
|
+
for (const d of cands) {
|
|
31
|
+
try { if (statSync(d).isDirectory()) return d } catch { /* 下一个 */ }
|
|
32
|
+
}
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 找 `<profile>/argent.json`;找不到返回 '' */
|
|
37
|
+
export function findContractFile(opts = {}) {
|
|
38
|
+
const dir = findProfileDir(opts)
|
|
39
|
+
if (!dir) return ''
|
|
40
|
+
try { return statSync(join(dir, 'argent.json')).isFile() ? join(dir, 'argent.json') : '' } catch { return '' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const loadDatadir = async (dir) => {
|
|
44
|
+
if (!dir) return null
|
|
45
|
+
try {
|
|
46
|
+
return await import(pathToFileURL(join(dir, 'core', 'lib', 'argent-datadir.mjs')).href)
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {Promise<{mod:any, dir:string, source:'env'|'contract'|'legacy'}|null>}
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveKernel({ env = process.env, argv = process.argv, log = console } = {}) {
|
|
56
|
+
// ① 显式指定
|
|
57
|
+
const explicit = await loadDatadir(env.ARGENT_ENGINE_DIR)
|
|
58
|
+
if (explicit) return { mod: explicit, dir: env.ARGENT_ENGINE_DIR, source: 'env' }
|
|
59
|
+
|
|
60
|
+
// ② 契约文件
|
|
61
|
+
const file = findContractFile({ env, argv })
|
|
62
|
+
if (file) {
|
|
63
|
+
let contract = null
|
|
64
|
+
try { contract = JSON.parse(readFileSync(file, 'utf8')) } catch { /* 坏文件当没有 */ }
|
|
65
|
+
if (contract) {
|
|
66
|
+
if (contract.contract !== REQUIRED_CONTRACT) {
|
|
67
|
+
log.error(`[argent] 引擎契约不符:本插件要求 ${REQUIRED_CONTRACT},`
|
|
68
|
+
+ `${file} 写的是 ${contract.contract} ⇒ ERP 连接器停用(fail-closed)。`)
|
|
69
|
+
log.error('[argent] 修复:在 DSH 插件面板卸载并重装 @whyshu/argent(或联系管理员更新 profile)')
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
const mod = await loadDatadir(contract.engineDir)
|
|
73
|
+
if (mod) return { mod, dir: contract.engineDir, source: 'contract' }
|
|
74
|
+
log.error(`[argent] argent.json 指向的 engineDir 不可用:${contract.engineDir || '(空)'} ⇒ ERP 连接器停用。`)
|
|
75
|
+
log.error('[argent] 修复:在 DSH 插件面板重装 @whyshu/argent(会重写 argent.json),或让管理员运行 dsh plugin 安装')
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ③ 兼容期回退
|
|
81
|
+
if (env.ARGENT_LEGACY_KERNEL_LOOKUP === '0') return null
|
|
82
|
+
const legacy = [new URL('../../../core/lib/argent-datadir.mjs', import.meta.url).href]
|
|
83
|
+
for (const root of [env.ARGENT_DSH_REPO, env.ARGENT_DISH_HOME, join(homedir(), '.argent-dsh'), join(homedir(), 'Code', 'argent-dsh')]) {
|
|
84
|
+
if (root) legacy.push(pathToFileURL(join(root, 'core', 'lib', 'argent-datadir.mjs')).href)
|
|
85
|
+
}
|
|
86
|
+
for (const u of legacy) {
|
|
87
|
+
try {
|
|
88
|
+
const mod = await import(u)
|
|
89
|
+
log.warn(`[argent] 未找到 argent.json,回退 legacy 路径(契约未生效):${u}`)
|
|
90
|
+
return { mod, dir: '', source: 'legacy' }
|
|
91
|
+
} catch { /* 下一个候选 */ }
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 引擎目录(同步版,供仍走 spawn 的执行路径用):
|
|
98
|
+
* `ARGENT_ENGINE_DIR` → `<profile>/argent.json#engineDir`。
|
|
99
|
+
* ⛔ 不再"猜候选路径"——拿不到就返回 '',由调用方明确报错。
|
|
100
|
+
*/
|
|
101
|
+
export function engineDirSync(opts = {}) {
|
|
102
|
+
if (opts.env?.ARGENT_ENGINE_DIR || process.env.ARGENT_ENGINE_DIR) return (opts.env || process.env).ARGENT_ENGINE_DIR
|
|
103
|
+
const file = findContractFile(opts)
|
|
104
|
+
if (!file) return ''
|
|
105
|
+
try {
|
|
106
|
+
const c = JSON.parse(readFileSync(file, 'utf8'))
|
|
107
|
+
return c.contract === REQUIRED_CONTRACT && c.engineDir ? c.engineDir : ''
|
|
108
|
+
} catch {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/lease.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lease.mjs —— ERP 轮询"先到先得"租约(2026-11-05)
|
|
3
|
+
*
|
|
4
|
+
* 问题:桌面端与 web 端是**两个 dsh 宿主**,都能跑 office-runtime(ERP 机器轮询)。
|
|
5
|
+
* 同一台机器同时跑 = 两个 poller 抢同一队列(重复回执;将来写路径会重复扣费)。
|
|
6
|
+
*
|
|
7
|
+
* 规则(用户拍板):**谁先启动谁持有,后启动的自动降级为纯 UI**,不打断在用的一方。
|
|
8
|
+
* · 租约文件 `<dataDir>/office-runtime.lease.json`:{pid, host, startedAt, heartbeatAt}
|
|
9
|
+
* · 持有者每 30s 心跳;超过 TTL(默认 90s)没心跳 = 已死 ⇒ 后来者可接管
|
|
10
|
+
* · 释放(正常退出)立即让位
|
|
11
|
+
* · 判定"同一持有者"看 pid:同 pid 重复 acquire 幂等(热重载/重复调用安全)
|
|
12
|
+
*
|
|
13
|
+
* ⛔ 不做网络/分布式锁:同一台机器用文件 + pid 足够;跨机器本就该不同 machineId。
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, closeSync, chmodSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_TTL_MS = 90_000
|
|
19
|
+
|
|
20
|
+
const fileOf = (dataDir) => join(dataDir, 'office-runtime.lease.json')
|
|
21
|
+
|
|
22
|
+
function readLease(file) {
|
|
23
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeLease(file, lease) {
|
|
27
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
28
|
+
const tmp = `${file}.tmp.${process.pid}`
|
|
29
|
+
writeFileSync(tmp, JSON.stringify(lease) + '\n')
|
|
30
|
+
renameSync(tmp, file) // 原子替换,避免读到半截 JSON
|
|
31
|
+
try { chmodSync(file, 0o644) } catch { /* noop */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pidAlive(pid) {
|
|
35
|
+
if (!pid) return false
|
|
36
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 申请租约。
|
|
41
|
+
* @returns {{role:'poller'|'ui', holder?:{pid:number,host:string,startedAt:string,heartbeatAt:string}}}
|
|
42
|
+
*/
|
|
43
|
+
export function acquireLease({ dataDir, host = 'unknown', pid = process.pid, now = Date.now(), ttlMs = DEFAULT_TTL_MS, alive = pidAlive } = {}) {
|
|
44
|
+
const file = fileOf(dataDir)
|
|
45
|
+
const cur = existsSync(file) ? readLease(file) : null
|
|
46
|
+
|
|
47
|
+
if (cur) {
|
|
48
|
+
const fresh = now - Date.parse(cur.heartbeatAt || 0) < ttlMs
|
|
49
|
+
const mine = Number(cur.pid) === Number(pid)
|
|
50
|
+
const holderAlive = alive(Number(cur.pid))
|
|
51
|
+
if (fresh && holderAlive && !mine) return { role: 'ui', holder: cur } // 别人在跑 ⇒ 降级 UI
|
|
52
|
+
// 同 pid(热重载)或已死/超时 ⇒ 接管
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lease = {
|
|
56
|
+
pid: Number(pid), host,
|
|
57
|
+
startedAt: cur && Number(cur.pid) === Number(pid) ? cur.startedAt : new Date(now).toISOString(),
|
|
58
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
59
|
+
}
|
|
60
|
+
writeLease(file, lease)
|
|
61
|
+
return { role: 'poller', holder: lease }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 心跳(只有持有者需要调;非持有者调用不会抢) */
|
|
65
|
+
export function heartbeatLease({ dataDir, pid = process.pid, now = Date.now() } = {}) {
|
|
66
|
+
const file = fileOf(dataDir)
|
|
67
|
+
const cur = readLease(file)
|
|
68
|
+
if (!cur || Number(cur.pid) !== Number(pid)) return false
|
|
69
|
+
writeLease(file, { ...cur, heartbeatAt: new Date(now).toISOString() })
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 释放(正常退出时调;非持有者调用无副作用) */
|
|
74
|
+
export function releaseLease({ dataDir, pid = process.pid } = {}) {
|
|
75
|
+
const file = fileOf(dataDir)
|
|
76
|
+
const cur = readLease(file)
|
|
77
|
+
if (!cur || Number(cur.pid) !== Number(pid)) return false
|
|
78
|
+
try { unlinkSync(file) } catch { /* noop */ }
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 当前持有者(只读,给状态页/日志用) */
|
|
83
|
+
export function leaseHolder({ dataDir, alive = pidAlive } = {}) {
|
|
84
|
+
const file = fileOf(dataDir)
|
|
85
|
+
if (!existsSync(file)) return null
|
|
86
|
+
const cur = readLease(file)
|
|
87
|
+
if (!cur) return null
|
|
88
|
+
const fresh = Date.now() - Date.parse(cur.heartbeatAt || 0) < DEFAULT_TTL_MS
|
|
89
|
+
return { ...cur, fresh, alive: alive(Number(cur.pid)) }
|
|
90
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argent/llm-whyshu —— **问述代理 provider**(Argent 0.3,2026-09-30)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么放在 argent 里(而不是独立包)
|
|
5
|
+
* 桌宠只保留一个 L2 bundle;provider 作为可选子模块由 host 插件的 apply 里
|
|
6
|
+
* `ctx.plugin(llmWhyshu, config)` 加载 ⇒ 不新增 profile 层。未登录时也能在模型选择器看到
|
|
7
|
+
* 「问述代理」,调用时才要求 credentials 里的 `WHYSHU_API_KEY`;登录后无需重启即可生效。
|
|
8
|
+
*
|
|
9
|
+
* ## 为什么需要它(用户纠正的设计)
|
|
10
|
+
* 此前 `provider-switch.sh` 直接把 `llm-deepseek` 的 `baseURL` 改写成 `https://whyshu.com/api/argent/v1`
|
|
11
|
+
* ⇒ **劫持了官方直连 provider**:「官方直连」不再是真正的官方,用户无法在两者间自由选择。
|
|
12
|
+
* 正确形态:**官方 `deepseek-official` 保持原样**,问述代理作为**独立 provider(id=`whyshu`)**并存,
|
|
13
|
+
* 用户在模型/Provider 选择处自行挑选。
|
|
14
|
+
*
|
|
15
|
+
* ## 实现方式:复用官方代码路径,只换 provider id
|
|
16
|
+
* 官方 `dsh-llm-deepseek-api-key` 把 `PROVIDER` 写死为 `deepseek-official`(双实例会撞 id),
|
|
17
|
+
* 因此本插件照抄它的 apply(同样的鉴权解析、配置 schema、模型目录),仅把 provider 换成 `whyshu`,
|
|
18
|
+
* 显示名换成「问述代理」。**不复制任何协议/请求逻辑** —— 全部复用官方导出。
|
|
19
|
+
*
|
|
20
|
+
* 配置(settings.yaml 顶层,键 = 本实例 id):
|
|
21
|
+
* llm-whyshu:
|
|
22
|
+
* baseURL: https://whyshu.com/api/argent/v1
|
|
23
|
+
* apiKeyEnv: WHYSHU_API_KEY
|
|
24
|
+
* models: [...]
|
|
25
|
+
*/
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { pathToFileURL } from 'node:url'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 宿主模块解析(2026-11-05):
|
|
34
|
+
* 插件被软链到 profile 时,bare import `@deepseek-ai/*` 会从**仓库路径**解析 ⇒ 找不到
|
|
35
|
+
* (实测 ERR_MODULE_NOT_FOUND)。dsh 宿主内可能自带解析,但我们不能赌:
|
|
36
|
+
* 先试 bare,再依次用 profile 的 package.json 作基准 createRequire 解析。
|
|
37
|
+
*/
|
|
38
|
+
const HOST_MODULES = [
|
|
39
|
+
'@deepseek-ai/dsh-llm',
|
|
40
|
+
'@deepseek-ai/dsh-llm-deepseek',
|
|
41
|
+
'@deepseek-ai/dsh-llm-deepseek-api-key',
|
|
42
|
+
'@deepseek-ai/dsh-launch-environment',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
function resolveBases() {
|
|
46
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
47
|
+
return ['web', 'desktop', 'headless'].map((p) => pathToFileURL(join(home, 'profiles', p, 'package.json')).href)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function hostImport(name) {
|
|
51
|
+
let firstErr = null
|
|
52
|
+
try { return await import(name) } catch (e) { firstErr = e }
|
|
53
|
+
for (const base of resolveBases()) {
|
|
54
|
+
try {
|
|
55
|
+
const req = createRequire(base)
|
|
56
|
+
return await import(pathToFileURL(req.resolve(name)).href)
|
|
57
|
+
} catch { /* 下一个基准 */ }
|
|
58
|
+
}
|
|
59
|
+
throw firstErr || new Error(`host module not found: ${name}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const name = 'argent-llm-whyshu'
|
|
63
|
+
const inject = ['llm']
|
|
64
|
+
/** 独立 provider id —— 与官方 `deepseek-official` 并存,用户在模型选择处可见「问述代理」。 */
|
|
65
|
+
const PROVIDER = 'whyshu'
|
|
66
|
+
const DISPLAY_NAME = '问述代理(Argent)'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 机器任务标记(2026-11-06,计费互斥甲):
|
|
70
|
+
* 客户机以「任务执行器」身份跑 dsh 时(profile/env 设 ARGENT_MACHINE_TASK=1),
|
|
71
|
+
* 代理请求带 x-argent-source=machine-task + x-argent-machine-id,服务端据此只记成本审计(0 积分),
|
|
72
|
+
* 不按 token 二次扣费(结果计费已按 SKU 收费)。
|
|
73
|
+
*/
|
|
74
|
+
const MACHINE_ID_CACHE = new Map() // dataDir -> { at, id }
|
|
75
|
+
function readMachineIdFromAgentReport(env = process.env) {
|
|
76
|
+
try {
|
|
77
|
+
const dataDir = env.ARGENT_DATA_DIR || join(homedir(), '.dsh', 'argent')
|
|
78
|
+
const now = Date.now()
|
|
79
|
+
const hit = MACHINE_ID_CACHE.get(dataDir)
|
|
80
|
+
if (hit && now - hit.at < 60_000) return hit.id
|
|
81
|
+
const file = join(dataDir, 'agent-report.json')
|
|
82
|
+
if (!existsSync(file)) return ''
|
|
83
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
84
|
+
const id = String(raw && raw.machineId || '').trim()
|
|
85
|
+
MACHINE_ID_CACHE.set(dataDir, { at: now, id })
|
|
86
|
+
return id
|
|
87
|
+
} catch {
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 任务执行器进程才带标记;人工使用(ARGENT_MACHINE_TASK 未设)不带 ⇒ 正常按 token 扣企业钱包 */
|
|
93
|
+
export function machineTaskHeaders(env = process.env) {
|
|
94
|
+
if (String(env.ARGENT_MACHINE_TASK || '') !== '1') return {}
|
|
95
|
+
const machineId = String(env.ARGENT_MACHINE_ID || '').trim() || readMachineIdFromAgentReport(env)
|
|
96
|
+
if (!machineId) return {}
|
|
97
|
+
return { 'x-argent-source': 'machine-task', 'x-argent-machine-id': machineId }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function makeApply({ LlmError, assertUsableApiKey, catalogModelInfo, registerDeepSeekProvider, plainOptions, resolveAdapterOptions, launchEnvironmentOf }) {
|
|
101
|
+
return function apply(ctx, config) {
|
|
102
|
+
const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
|
|
103
|
+
options()
|
|
104
|
+
|
|
105
|
+
const resolveApiKey = async (connection) => {
|
|
106
|
+
const ref = connection.apiKeyEnv
|
|
107
|
+
const credentials = ctx.get('credentials')
|
|
108
|
+
if (credentials !== undefined) {
|
|
109
|
+
const hit = await credentials.resolve(ref)
|
|
110
|
+
if (hit !== undefined) return assertUsableApiKey(hit.value, 'whyshu', ref)
|
|
111
|
+
} else {
|
|
112
|
+
const ambient = launchEnvironmentOf(ctx).get(ref)
|
|
113
|
+
if (ambient !== undefined && ambient.value.length > 0) return assertUsableApiKey(ambient.value, 'whyshu', ref)
|
|
114
|
+
}
|
|
115
|
+
throw new LlmError(
|
|
116
|
+
`whyshu: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials service`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
ctx.llm.registerConfigurableProviders([{
|
|
121
|
+
provider: PROVIDER,
|
|
122
|
+
displayName: DISPLAY_NAME,
|
|
123
|
+
settingsNs: ctx.fiber.entry?.options.id ?? name,
|
|
124
|
+
settingsPath: [],
|
|
125
|
+
}])
|
|
126
|
+
|
|
127
|
+
registerDeepSeekProvider(ctx, PROVIDER, {
|
|
128
|
+
options,
|
|
129
|
+
providerName: DISPLAY_NAME,
|
|
130
|
+
resolveAuth: async (connection) => ({
|
|
131
|
+
headers: { 'x-api-key': await resolveApiKey(connection), ...machineTaskHeaders() },
|
|
132
|
+
}),
|
|
133
|
+
discoverModels: (provider) => {
|
|
134
|
+
const connection = options()
|
|
135
|
+
return Promise.resolve(connection.models.map((model) => catalogModelInfo(provider, model)))
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 懒加载入口:解析宿主模块后返回可 `ctx.plugin()` 的模块对象。
|
|
143
|
+
* ⛔ 顶层不做任何宿主 import —— 缺依赖时只让 provider 缺席,不影响 dsh 启动。
|
|
144
|
+
*/
|
|
145
|
+
export async function load() {
|
|
146
|
+
const [llm, ds, api, launch] = await Promise.all(HOST_MODULES.map(hostImport))
|
|
147
|
+
const { LlmError, assertUsableApiKey } = llm
|
|
148
|
+
const { catalogModelInfo, registerDeepSeekProvider } = ds
|
|
149
|
+
const { Config, plainOptions, resolveAdapterOptions } = api
|
|
150
|
+
const { launchEnvironmentOf } = launch
|
|
151
|
+
const apply = makeApply({ LlmError, assertUsableApiKey, catalogModelInfo, registerDeepSeekProvider, plainOptions, resolveAdapterOptions, launchEnvironmentOf })
|
|
152
|
+
return { name, inject, apply, Config }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { inject, name }
|