dsh-hams 0.1.2 → 0.1.4

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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +11335 -0
  3. package/package.json +9 -5
  4. package/src/domain/check.ts +0 -193
  5. package/src/domain/errors.ts +0 -59
  6. package/src/domain/evidence.ts +0 -134
  7. package/src/domain/ids.ts +0 -31
  8. package/src/host/chat-runtime.ts +0 -489
  9. package/src/host/dsh-adapter.ts +0 -867
  10. package/src/host/session-adapter.ts +0 -276
  11. package/src/http/handler.ts +0 -1132
  12. package/src/index.js +0 -53
  13. package/src/service/audit.ts +0 -92
  14. package/src/service/chat.ts +0 -1100
  15. package/src/service/contracts.ts +0 -193
  16. package/src/service/defaults.ts +0 -182
  17. package/src/service/deliverables.ts +0 -240
  18. package/src/service/hams.ts +0 -3103
  19. package/src/service/knowledge.ts +0 -940
  20. package/src/service/member-config.ts +0 -39
  21. package/src/service/members.ts +0 -506
  22. package/src/service/memory.ts +0 -375
  23. package/src/service/permissions.ts +0 -118
  24. package/src/service/project-lifecycle.ts +0 -233
  25. package/src/service/projects.ts +0 -840
  26. package/src/service/receipts.ts +0 -63
  27. package/src/service/templates.ts +0 -140
  28. package/src/service/tick.ts +0 -751
  29. package/src/service/tools.ts +0 -711
  30. package/src/service/types.ts +0 -219
  31. package/src/service/work-item-create.ts +0 -159
  32. package/src/service/workspace-profile.ts +0 -443
  33. package/src/store/backup.ts +0 -239
  34. package/src/store/bootstrap.ts +0 -225
  35. package/src/store/db.ts +0 -179
  36. package/src/store/lock.ts +0 -194
  37. package/src/store/migrations.ts +0 -211
  38. package/src/store/schema/001_p1.sql +0 -846
  39. package/src/store/schema/001_p1.sql.ts +0 -865
  40. package/src/store/schema/002_knowledge.sql +0 -54
  41. package/src/store/schema/002_knowledge.sql.ts +0 -68
  42. package/src/store/schema/003_projects.sql.ts +0 -3
  43. package/src/store/schema/004_team.sql.ts +0 -50
  44. package/src/store/schema/005_target_check.sql.ts +0 -22
  45. package/src/store/schema/006_target_flow.sql.ts +0 -19
  46. package/src/store/schema/007_project_delete.sql.ts +0 -17
  47. package/src/store/schema/008_memory.sql.ts +0 -47
  48. package/src/store/schema/generate-projects.mjs +0 -16
  49. package/src/store/seed.ts +0 -114
@@ -1,867 +0,0 @@
1
- /**
2
- * 宿主适配器:把纯函数 handler 与工具定义接到 Cordis 的 `webServer` / `tools` 上。
3
- *
4
- * 只依赖注入的 ctx 形状(`webServer.register` / `tools.register` / 可选 `effect`),
5
- * 不 import 宿主包——这样既能在测试里用假 ctx 跑,也能在宿主升级时只改这一层。
6
- *
7
- * 与 design 的对应:
8
- * - `webServer.register({kind:'exact', path, handler})`:24 条精确路由(backend §9);
9
- * - `tools.register(tool)`:11 个窄语义工具,**没有审批/决定/关单/SQL**;
10
- * - 启动编排沿用存储层:拿锁 → 开库 → 备份 → 迁移 → 种子 → runtime_epoch +1;
11
- * - 拿不到写锁时降级为只读实例:读路由可用,写命令返回 HOST_CAPABILITY_MISSING 并说明原因。
12
- *
13
- * 安全边界(P1 单机):只接受回环 Origin;主体来自配置或 `hams_user(auth_source='local_host')`。
14
- * 同 OS 账户下的本机管理员仍可直接改库——这条诚实边界写在 README 与状态文档里。
15
- */
16
- import { homedir } from 'node:os'
17
- import path from 'node:path'
18
- import type { DatabaseSync } from 'node:sqlite'
19
- import { bootstrapStore, openReadOnlyStore, type BootstrapReport } from '../store/bootstrap.ts'
20
- import { resolveDataDir, type StoreError } from '../store/db.ts'
21
- import { HamsService } from '../service/hams.ts'
22
- import { createHamsHttpHandler, API_PREFIX, ROUTE_PATHS, type AuthResult, type HttpRequest, type HttpResponse } from '../http/handler.ts'
23
- import { createHamsTools, type ToolDefinition, type ToolExecContext } from '../service/tools.ts'
24
- import { createTicker, readRuntimeEpoch, recover, type TickerHandle } from '../service/tick.ts'
25
- import { ChatService } from '../service/chat.ts'
26
- import { createHostChatRuntime, ensureHamsBotPreset, parseModelChoice, resolveAgentOptions, HAMS_BOT_PRESET } from './chat-runtime.ts'
27
- import { HamsSessionAdapter } from './session-adapter.ts'
28
- import { ensureAllDefaultPlans } from '../service/project-lifecycle.ts'
29
- import { createIdFactory } from '../domain/ids.ts'
30
- import { HamsError } from '../domain/errors.ts'
31
- import { ensureStarterTemplate } from '../service/templates.ts'
32
- import { ensureDefaultBots, type DefaultBotResult } from '../service/defaults.ts'
33
- import { applyWorkspaceProfile, defaultProfilePath, type WorkspaceProfileReport } from '../service/workspace-profile.ts'
34
-
35
-
36
- export interface HostDisposer {
37
- (): void
38
- }
39
-
40
- export interface HostWebServer {
41
- register(spec: {
42
- kind: 'exact'
43
- path: string
44
- handler: (req: HostRequest, res: HostResponse) => unknown
45
- }): HostDisposer
46
- }
47
-
48
- export interface HostRequest {
49
- method?: string
50
- headers?: Record<string, string | string[] | undefined>
51
- body?: unknown
52
- [Symbol.asyncIterator]?: () => AsyncIterator<Buffer | string>
53
- on?: (event: string, listener: (...args: unknown[]) => void) => unknown
54
- }
55
-
56
- export interface HostResponse {
57
- writeHead?: (status: number, headers?: Record<string, string>) => unknown
58
- setHeader?: (name: string, value: string) => unknown
59
- end?: (body?: string | Buffer) => unknown
60
- statusCode?: number
61
- }
62
-
63
- export interface HostTools {
64
- register(tool: unknown): unknown
65
- }
66
-
67
- /**
68
- * 宿主 `ctx.connection` 的最小面(Desktop 0.2.2-rc.3 的 `HostConnectionHandle`)。
69
- *
70
- * 只声明 C2 真正需要的两个方法,并且都按「传 headers 拿判定」的纯函数用:
71
- * - `admit()`:官方给「另一个 Web 路由」复用的认证入口,返回已认证 Peer 或 401/403;
72
- * - `requestRejection()`:更轻的同类入口(只要判定不要 Peer)。
73
- *
74
- * 用结构类型而不是 import 宿主包:宿主升级时最多改这一处,测试用假对象即可。
75
- */
76
- export interface HostConnectionPeer {
77
- readonly principal?: { readonly kind?: string }
78
- }
79
-
80
- export type HostPeerAdmission =
81
- | { readonly peer: HostConnectionPeer }
82
- | { readonly rejection: 401 | 403 }
83
-
84
- export interface HostConnection {
85
- admit?(request: { readonly headers: Record<string, string | undefined> }): HostPeerAdmission
86
- requestRejection?(
87
- request: { readonly headers: Record<string, string | undefined> },
88
- ): 401 | 403 | undefined
89
- }
90
-
91
- export interface HostContext {
92
- webServer: HostWebServer
93
- tools?: HostTools
94
- effect?: (fn: () => HostDisposer | void, label?: string) => unknown
95
- logger?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }
96
- /** Cordis 的服务读取入口。**必须**用它取 connection:直接读 `ctx.connection`
97
- * 在 Cordis 里会抛「cannot get property without inject」。 */
98
- get?: (name: string) => unknown
99
- }
100
-
101
- /**
102
- * 浏览器会话校验策略:
103
- * - `auto`:宿主提供 `connection` 就必须过会话校验;没有则退回「只验 Origin」并告警;
104
- * - `require`:没有 `connection` 就直接拒绝所有界面请求(安全优先的部署用);
105
- * - `off`:不校验会话(只给测试/排障用,不要装到真实环境)。
106
- */
107
- export type HostSessionMode = 'auto' | 'require' | 'off'
108
-
109
- export type HostSessionState = 'host' | 'origin-only' | 'off'
110
-
111
- export interface HamsHostConfig {
112
- /** 数据目录;空 = $DSH_HOME/hams。 */
113
- dataDir?: string
114
- /** tick 周期,默认 5 秒。 */
115
- tickMs?: number
116
- /** 本机操作者 member id;空 = 查 hams_user(auth_source='local_host')。 */
117
- localPrincipalId?: string
118
- /** 允许的 Origin 前缀;默认只放行回环。 */
119
- allowedOrigins?: string[]
120
- /** 首次认领时线程尚未建立,用这个 bot 成员兜底(P1 单 bot);M5 会后由线程取代。 */
121
- defaultBotMemberId?: string
122
- /** 关闭工具注册(排障用)。 */
123
- toolsEnabled?: boolean
124
- /** 关闭 bot 运行时(不建会话、不派发运行);默认开。 */
125
- botRuntime?: boolean
126
- /** 关闭 tick(手工验证用)。 */
127
- tickEnabled?: boolean
128
- /** 升级前备份保留份数。 */
129
- backupKeep?: number
130
- /** 浏览器会话校验策略,默认 `auto`。 */
131
- hostSession?: HostSessionMode
132
- /**
133
- * 知识屏的真实技能目录;默认 `$DSH_HOME/skills`。
134
- * 只读每个子目录里的 `SKILL.md`,不写宿主文件。
135
- */
136
- knowledgeSkillDirs?: string[]
137
- /** 工作区画像文件;默认 `<dataDir>/workspace-profile.json`。 */
138
- workspaceProfileFile?: string
139
- /** 画像里相对路径的根;空 = 读画像自己的 workspaceRoot。 */
140
- workspaceRoot?: string
141
- /**
142
- * 显式注入宿主连接服务(装配与测试用)。不传就按 `ctx.get('connection')` 现取,
143
- * 因为 connection 可能在插件 apply 之后才激活。
144
- */
145
- connection?: HostConnection
146
- }
147
-
148
- export interface RegisteredHams {
149
- mode: 'writer' | 'reader'
150
- service: HamsService
151
- db: DatabaseSync
152
- report: BootstrapReport | null
153
- ticker: TickerHandle | null
154
- routes: string[]
155
- toolNames: string[]
156
- /** 实际生效的浏览器会话校验形态(启动时判定)。 */
157
- sessionState: HostSessionState
158
- /** 本次启动给本机负责人岗位补授的能力条数(老库升级时非零)。 */
159
- operatorCapabilitiesAdded: number
160
- /** 工作区画像的应用结果(没有画像文件时为 null)。 */
161
- workspaceProfile: WorkspaceProfileReport | null
162
- /** 默认 bot(项目管理 / 数据分析)的补齐结果;只读实例为 null。 */
163
- defaultBots: DefaultBotResult | null
164
- dispose(): void
165
- }
166
-
167
- const DEFAULT_ORIGINS = ['http://127.0.0.1:', 'http://localhost:', 'http://[::1]:']
168
-
169
- function headerValue(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
170
- const value = headers[name] ?? headers[name.toLowerCase()]
171
- if (Array.isArray(value)) return value.join(',')
172
- return value
173
- }
174
-
175
- export function toHeaderRecord(raw: Record<string, string | string[] | undefined> | undefined): Record<string, string | undefined> {
176
- const out: Record<string, string | undefined> = {}
177
- for (const [key, value] of Object.entries(raw ?? {})) {
178
- out[key.toLowerCase()] = Array.isArray(value) ? value.join(',') : value
179
- }
180
- return out
181
- }
182
-
183
- /** 读请求体:兼容 Node 流、async iterator 与直接给 string 的假对象。 */
184
- export async function readRequestBody(request: HostRequest, maxBytes = 256 * 1024): Promise<string> {
185
- if (typeof request.body === 'string') return request.body
186
- const chunks: Buffer[] = []
187
- let size = 0
188
- const push = (chunk: Buffer | string): void => {
189
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
190
- size += buffer.length
191
- if (size > maxBytes) throw new Error('请求体过大')
192
- chunks.push(buffer)
193
- }
194
-
195
- if (request[Symbol.asyncIterator]) {
196
- for await (const chunk of request as AsyncIterable<Buffer | string>) push(chunk)
197
- return Buffer.concat(chunks).toString('utf8')
198
- }
199
- if (typeof request.on === 'function') {
200
- await new Promise<void>((resolve, reject) => {
201
- request.on!('data', (chunk) => {
202
- try {
203
- push(chunk as Buffer | string)
204
- } catch (error) {
205
- reject(error)
206
- }
207
- })
208
- request.on!('end', () => resolve())
209
- request.on!('error', (error) => reject(error))
210
- })
211
- return Buffer.concat(chunks).toString('utf8')
212
- }
213
- return ''
214
- }
215
-
216
- function writeResponse(response: HostResponse, result: HttpResponse): void {
217
- if (typeof response.writeHead === 'function') {
218
- response.writeHead(result.status, result.headers)
219
- } else if (typeof response.setHeader === 'function') {
220
- response.statusCode = result.status
221
- for (const [key, value] of Object.entries(result.headers)) response.setHeader(key, value)
222
- } else {
223
- response.statusCode = result.status
224
- }
225
- response.end?.(result.body)
226
- }
227
-
228
- export function isAllowedOrigin(origin: string, allowed: string[] = DEFAULT_ORIGINS): boolean {
229
- return allowed.some((prefix) => origin === prefix.replace(/:$/, '') || origin.startsWith(prefix))
230
- }
231
-
232
- const UNAUTHENTICATED_MESSAGE = '浏览器会话未通过认证(没有宿主会话 cookie,或已过期)'
233
- const UNAUTHENTICATED_NEXT_STEP = '用 dsh 打印的带 ?token= 的地址重新打开这一页(或刷新),再操作面板'
234
- const UNTRUSTED_MESSAGE = '请求来源不被宿主信任(Host/Origin 校验失败)'
235
- const UNTRUSTED_NEXT_STEP = '只从本机 127.0.0.1/localhost 打开面板;不要用外部页面调用'
236
-
237
- function unauthenticated(): { ok: false; reason: 'UNAUTHORIZED'; message: string; nextStep: string } {
238
- return { ok: false, reason: 'UNAUTHORIZED', message: UNAUTHENTICATED_MESSAGE, nextStep: UNAUTHENTICATED_NEXT_STEP }
239
- }
240
-
241
- function untrusted(): { ok: false; reason: 'FORBIDDEN'; message: string; nextStep: string } {
242
- return { ok: false, reason: 'FORBIDDEN', message: UNTRUSTED_MESSAGE, nextStep: UNTRUSTED_NEXT_STEP }
243
- }
244
-
245
- export type SessionVerdict =
246
- | { ok: true }
247
- | { ok: false; reason: 'UNAUTHORIZED' | 'FORBIDDEN'; message: string; nextStep: string }
248
-
249
- /**
250
- * 用宿主自己的浏览器会话判定「这个请求是不是已认证的本机页面」。
251
- *
252
- * 语义对齐宿主(`dsh-client-connection`):认证只认两样东西——进程启动时随 URL
253
- * 下发、换成熟 cookie 的宿主令牌,以及 `/connect` 换来的 API Peer。HAMS 不自己
254
- * 造令牌、不登录、不存口令;cookie 是宿主写的(HttpOnly + SameSite=Strict),
255
- * 我们只读判定结果。
256
- *
257
- * 宿主调用失败(抛异常)一律按 403 拒绝——宁可让面板报错,也不要放进来路不明
258
- * 的写请求。
259
- */
260
- export function checkHostSession(
261
- connection: HostConnection | undefined,
262
- headers: Record<string, string | undefined>,
263
- mode: HostSessionMode = 'auto',
264
- ): SessionVerdict {
265
- if (mode === 'off') return { ok: true }
266
- if (!connection) {
267
- if (mode === 'require') {
268
- return {
269
- ok: false,
270
- reason: 'FORBIDDEN',
271
- message: '宿主没有提供 connection 服务,无法校验浏览器会话',
272
- nextStep: '把插件装进带 Web 载体的宿主,或把配置 hostSession 设为 auto(只验 Origin)',
273
- }
274
- }
275
- return { ok: true }
276
- }
277
- const request = { headers }
278
- try {
279
- if (typeof connection.admit === 'function') {
280
- const admission = connection.admit(request)
281
- if ('rejection' in admission) {
282
- return admission.rejection === 401 ? unauthenticated() : untrusted()
283
- }
284
- const kind = admission.peer?.principal?.kind
285
- // 协议目前只有 owner;将来加了别的 driver(例如 bot peer),界面通道一律不收。
286
- if (kind !== undefined && kind !== 'owner') {
287
- return {
288
- ok: false,
289
- reason: 'FORBIDDEN',
290
- message: `主体类型 ${kind} 不能调用 HAMS 界面接口`,
291
- nextStep: 'bot 只能走工具通道;界面接口只服务已认证的本机操作者',
292
- }
293
- }
294
- return { ok: true }
295
- }
296
- if (typeof connection.requestRejection === 'function') {
297
- const rejection = connection.requestRejection(request)
298
- if (rejection === 401) return unauthenticated()
299
- if (rejection === 403) return untrusted()
300
- return { ok: true }
301
- }
302
- if (mode === 'require') {
303
- return {
304
- ok: false,
305
- reason: 'FORBIDDEN',
306
- message: '宿主 connection 服务没有可用的认证入口',
307
- nextStep: '确认宿主版本提供 admit/requestRejection,或把 hostSession 设为 auto',
308
- }
309
- }
310
- return { ok: true }
311
- } catch (error) {
312
- return {
313
- ok: false,
314
- reason: 'FORBIDDEN',
315
- message: `宿主会话校验失败:${String((error as Error)?.message ?? error)}`,
316
- nextStep: '看宿主日志确认 connection 服务状态后重试',
317
- }
318
- }
319
- }
320
-
321
- /**
322
- * P1 鉴权:Origin 白名单 → 宿主浏览器会话 → 主体。
323
- *
324
- * 主体仍然是「本机负责人」(单机模式没有登录页),但**能不能进来**由宿主会话
325
- * 说了算:没有换过宿主令牌的请求拿不到 cookie,也就到不了业务层。
326
- */
327
- export function createLocalAuthenticator(
328
- db: DatabaseSync,
329
- config: HamsHostConfig,
330
- resolveConnection?: () => HostConnection | undefined,
331
- ): (request: HttpRequest) => AuthResult {
332
- const allowed = config.allowedOrigins ?? DEFAULT_ORIGINS
333
- const mode = config.hostSession ?? 'auto'
334
- return (request) => {
335
- const origin = request.headers.origin
336
- if (origin && !isAllowedOrigin(origin, allowed)) {
337
- return { ok: false, reason: 'FORBIDDEN', message: `非同源请求:${origin}`, nextStep: UNTRUSTED_NEXT_STEP }
338
- }
339
- const verdict = checkHostSession(resolveConnection?.(), request.headers, mode)
340
- if (!verdict.ok) return verdict
341
-
342
- let principalId = config.localPrincipalId ?? ''
343
- if (!principalId) {
344
- const row = db
345
- .prepare("SELECT member_id FROM hams_user WHERE auth_source = 'local_host' AND status = 'active' LIMIT 1")
346
- .get() as { member_id: string } | undefined
347
- principalId = row?.member_id ?? ''
348
- }
349
- if (!principalId) {
350
- return {
351
- ok: false,
352
- reason: 'FORBIDDEN',
353
- message: '还没有绑定本机操作者:请先建立 hams_user(auth_source=local_host) 或配置 localPrincipalId',
354
- nextStep: '重启插件会首次自动创建本机操作者;若仍失败,检查 hams_user 表',
355
- }
356
- }
357
- return { ok: true, principalId, authSubject: origin ?? 'local_host' }
358
- }
359
- }
360
-
361
- /**
362
- * 参数直接保持标准 JSON Schema(`required` 是对象层的数组)。
363
- *
364
- * 实测(2026-11-14,Desktop 0.2.2-rc.3):`ctx.tools.register` 校验的是标准 JSON Schema,
365
- * 属性节点上标 `required: true` 会被拒:JsonSchemaError: ... is not supported on type "boolean"。
366
- */
367
- export function toHostParameters(schema: Record<string, unknown>): Record<string, unknown> {
368
- const properties = (schema.properties ?? {}) as Record<string, unknown>
369
- return {
370
- type: 'object',
371
- properties,
372
- ...(Array.isArray(schema.required) && schema.required.length ? { required: schema.required } : {}),
373
- additionalProperties: schema.additionalProperties === undefined ? false : schema.additionalProperties,
374
- }
375
- }
376
-
377
- export function hostToolOutput(renderKey = 'render'): Record<string, unknown> {
378
- return {
379
- schema: {
380
- type: 'object',
381
- additionalProperties: true,
382
- properties: {
383
- ok: { type: 'boolean' },
384
- [renderKey]: { type: 'string' },
385
- },
386
- required: ['ok', renderKey],
387
- },
388
- render: (_args: unknown, value: Record<string, unknown>) => [
389
- { type: 'text', text: String(value?.[renderKey] ?? JSON.stringify(value)) },
390
- ],
391
- }
392
- }
393
-
394
- /** 把工具结果压成「模型能读的一段文字 + 机器可读的 ok」。 */
395
- export function renderToolResult(name: string, result: { ok: boolean; data?: unknown; error?: { code: string; message: string; nextStep: string } }): {
396
- ok: boolean
397
- render: string
398
- code?: string
399
- } {
400
- if (result.ok) {
401
- return {
402
- ok: true,
403
- render: `${name} 成功:${JSON.stringify(result.data ?? {}, null, 2).slice(0, 4000)}`,
404
- }
405
- }
406
- return {
407
- ok: false,
408
- code: result.error?.code,
409
- render: `${name} 失败(${result.error?.code}):${result.error?.message}\n下一步:${result.error?.nextStep}`,
410
- }
411
- }
412
-
413
- export interface BuildHostToolsOptions {
414
- defineTool?: (definition: Record<string, unknown>) => unknown
415
- }
416
-
417
- export function toHostTools(
418
- definitions: ToolDefinition[],
419
- options: BuildHostToolsOptions = {},
420
- ): unknown[] {
421
- const define = options.defineTool ?? ((definition: Record<string, unknown>) => definition)
422
- return definitions.map((definition) => {
423
- const parameters = toHostParameters(definition.parameters)
424
- const tool = define({
425
- name: definition.name,
426
- description: definition.description,
427
- parameters,
428
- output: hostToolOutput(),
429
- async execute(args: unknown, exec: { agent?: { session?: { id?: string } }; sessionId?: string }): Promise<unknown> {
430
- const sessionId = exec?.agent?.session?.id ?? exec?.sessionId ?? ''
431
- const result = await definition.execute(args, { sessionId } as ToolExecContext)
432
- return renderToolResult(definition.name, result)
433
- },
434
- })
435
- return tool
436
- })
437
- }
438
-
439
- /**
440
- * P1 首次使用:宿主本机操作者还没有档案时,建一个最小可用的人 + 岗位 + 登录身份。
441
- *
442
- * 这是设计里「宿主本机令牌映射本机负责人」的落地:单机模式没有登录页,第一次打开面板
443
- * 就得有一个能审批、能建单的人。只建一次;已有 hams_user(local_host) 就什么都不做。
444
- * 不写任何业务数据(项目、工作项仍要人自己建)。
445
- */
446
- export function ensureLocalOperator(db: DatabaseSync, now = Date.now()): string {
447
- const existing = db
448
- .prepare("SELECT member_id FROM hams_user WHERE auth_source = 'local_host' AND status = 'active' LIMIT 1")
449
- .get() as { member_id: string } | undefined
450
- if (existing) return existing.member_id
451
-
452
- const memberId = 'h-owner'
453
- const positionId = 'pos-owner'
454
- db.exec('BEGIN IMMEDIATE')
455
- try {
456
- const member = db.prepare('SELECT id FROM member WHERE id = ?').get(memberId) as { id: string } | undefined
457
- if (!member) {
458
- db.prepare(
459
- `INSERT INTO member (id, kind, name, role, current_version, created_at, updated_at)
460
- VALUES (?, 'human', '本机操作者', 'coordinator', 0, ?, ?)`,
461
- ).run(memberId, now, now)
462
- }
463
- const position = db.prepare('SELECT id FROM position WHERE id = ?').get(positionId) as { id: string } | undefined
464
- if (!position) {
465
- db.prepare("INSERT INTO position (id, name, domain, description) VALUES (?, '本机负责人', NULL, 'P1 自动创建')").run(positionId)
466
- }
467
- const capability = db.prepare(
468
- 'INSERT OR IGNORE INTO position_permission (position_id, capability, scope) VALUES (?, ?, ?)',
469
- )
470
- for (const name of [
471
- 'work_item.create',
472
- 'work_item.accept',
473
- 'work_item.manage',
474
- 'work_item.request_closure',
475
- 'evidence.append',
476
- 'check.evaluate',
477
- 'proposal.decide',
478
- 'approval.decide',
479
- ]) {
480
- capability.run(positionId, name, 'global')
481
- }
482
- const assigned = db
483
- .prepare('SELECT 1 FROM member_position WHERE member_id = ? AND position_id = ?')
484
- .get(memberId, positionId) as { 1: number } | undefined
485
- if (!assigned) {
486
- db.prepare(
487
- `INSERT INTO member_position (member_id, position_id, starts_at, ends_at, assigned_by)
488
- VALUES (?, ?, 0, NULL, 'system')`,
489
- ).run(memberId, positionId)
490
- }
491
- db.prepare(
492
- `INSERT INTO hams_user (id, member_id, login, display_name, auth_source, external_id, is_admin, status, last_login_at)
493
- VALUES ('user-local', ?, 'local', '本机操作者', 'local_host', 'local', 1, 'active', ?)`,
494
- ).run(memberId, now)
495
- db.exec('COMMIT')
496
- return memberId
497
- } catch (error) {
498
- if (db.isTransaction) db.exec('ROLLBACK')
499
- throw error
500
- }
501
- }
502
-
503
- /**
504
- * 给本机负责人岗位补齐 M6 需要的能力。
505
- *
506
- * 为什么要单独一步:`ensureLocalOperator` 在已有 operator 时直接返回,老库不会
507
- * 重新走一遍授权;而项目 / 成员 / 知识三屏的写命令需要 `project.create`、
508
- * `member.manage` 等新能力。这里只对**自动创建的 `pos-owner` 岗位**补缺失项,
509
- * 不碰用户自建岗位,也不覆盖已有的 scope。
510
- */
511
- export const OPERATOR_CAPABILITIES = [
512
- 'work_item.create',
513
- 'work_item.accept',
514
- 'work_item.manage',
515
- 'work_item.request_closure',
516
- 'evidence.append',
517
- 'check.evaluate',
518
- 'proposal.decide',
519
- 'approval.decide',
520
- 'project.create',
521
- 'project.manage',
522
- 'project.archive',
523
- 'target.publish',
524
- 'review.confirm',
525
- 'metric.record',
526
- 'member.manage',
527
- 'knowledge.write',
528
- 'knowledge.sync',
529
- ] as const
530
-
531
- export function ensureOperatorCapabilities(db: DatabaseSync, memberId: string): number {
532
- const position = db.prepare("SELECT id FROM position WHERE id = 'pos-owner'").get() as
533
- | { id: string }
534
- | undefined
535
- if (!position) return 0
536
- const assigned = db
537
- .prepare('SELECT 1 AS ok FROM member_position WHERE member_id = ? AND position_id = ? AND ends_at IS NULL')
538
- .get(memberId, position.id) as { ok: number } | undefined
539
- if (!assigned) return 0
540
- const insert = db.prepare(
541
- 'INSERT OR IGNORE INTO position_permission (position_id, capability, scope) VALUES (?, ?, ?)',
542
- )
543
- let added = 0
544
- for (const capability of OPERATOR_CAPABILITIES) added += Number(insert.run(position.id, capability, 'global').changes ?? 0)
545
- return added
546
- }
547
-
548
- function resolveDshHome(env: Record<string, string | undefined> = process.env): string {
549
- const configured = env.DSH_HOME && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : ''
550
- return configured ? path.resolve(configured) : path.join(homedir(), '.dsh')
551
- }
552
-
553
- function isLockError(error: unknown): boolean {
554
- const code = (error as StoreError | undefined)?.code
555
- return code === 'LOCK_HELD' || code === 'LOCK_HELD_SELF'
556
- }
557
-
558
- /**
559
- * 在宿主里启动 HAMS:拿写锁;拿不到就降级只读。所有注册都返回 disposer。
560
- */
561
- export function registerHams(ctx: HostContext, config: HamsHostConfig = {}): RegisteredHams {
562
- const startedAt = Date.now()
563
- const dataDir = resolveDataDir(config.dataDir)
564
-
565
- let runtime: ReturnType<typeof bootstrapStore> | null = null
566
- let reader: ReturnType<typeof openReadOnlyStore> | null = null
567
- let mode: 'writer' | 'reader'
568
- try {
569
- runtime = bootstrapStore({ dataDir, backupKeep: config.backupKeep })
570
- mode = 'writer'
571
- } catch (error) {
572
- if (!isLockError(error)) throw error
573
- reader = openReadOnlyStore(dataDir)
574
- mode = 'reader'
575
- }
576
-
577
- const db = runtime?.db ?? reader!.db
578
- const dshHome = resolveDshHome()
579
- const knowledgeSkillDirs =
580
- config.knowledgeSkillDirs && config.knowledgeSkillDirs.length
581
- ? [...config.knowledgeSkillDirs]
582
- : [path.join(dshHome, 'skills')]
583
- let starterTemplateVersionId: string | null = null
584
- let operatorCapabilitiesAdded = 0
585
- let workspaceProfile: WorkspaceProfileReport | null = null
586
- let defaultBots: DefaultBotResult | null = null
587
- if (mode === 'writer') {
588
- const operatorId = ensureLocalOperator(db, startedAt)
589
- operatorCapabilitiesAdded = ensureOperatorCapabilities(db, operatorId)
590
- starterTemplateVersionId = ensureStarterTemplate(db, operatorId, startedAt)
591
- // 默认 bot 先于画像落库:画像里的项目如果没指定推进 bot,也能拿到「项目管理」。
592
- try {
593
- defaultBots = ensureDefaultBots(db, createIdFactory(() => Date.now()), startedAt, operatorId)
594
- } catch (error) {
595
- ctx.logger?.warn?.(`[hams] 默认 bot 补齐失败:${String(error)}`)
596
- }
597
- workspaceProfile = applyWorkspaceProfile({
598
- db,
599
- dataDir,
600
- operatorId,
601
- now: startedAt,
602
- idFactory: createIdFactory(() => Date.now()),
603
- file: config.workspaceProfileFile?.trim() || defaultProfilePath(dataDir),
604
- workspaceRoot: config.workspaceRoot?.trim() || undefined,
605
- skillDirs: knowledgeSkillDirs,
606
- })
607
- }
608
- if (mode === 'writer') {
609
- // 历史项目补齐「默认阶段 + 首个目标」:界面永远有可推进的骨架。
610
- try { ensureAllDefaultPlans(db, createIdFactory(() => Date.now()), Date.now()) } catch (error) { ctx.logger?.warn?.(`[hams] 补默认阶段失败:${String(error)}`) }
611
- }
612
- const service = new HamsService({
613
- db,
614
- runtimeEpoch: runtime?.report.runtimeEpoch ?? readRuntimeEpoch(db),
615
- knowledge: { dataDir, skillDirs: knowledgeSkillDirs },
616
- })
617
- // 对话:只在写者实例接宿主 agent 服务(只读实例没有写权限存会话指针)。
618
- const chatRuntime =
619
- mode === 'writer'
620
- ? createHostChatRuntime({ ctx, dataDir, log: (line) => ctx.logger?.warn?.(line) })
621
- : null
622
- const chat = chatRuntime ? new ChatService({ db, runtime: chatRuntime }) : null
623
- const disposers: HostDisposer[] = []
624
-
625
- // bot 运行时:把工作项线程接到真实 agent 会话;服务缺失时只记日志,不影响面板启动。
626
- const hostService = <T>(name: string): T | undefined => {
627
- if (typeof ctx.get !== 'function') return undefined
628
- try {
629
- const found = ctx.get(name)
630
- return found && typeof found === 'object' ? (found as T) : undefined
631
- } catch {
632
- return undefined
633
- }
634
- }
635
- let sessionAdapter: HamsSessionAdapter | null = null
636
- if (mode === 'writer' && config.botRuntime !== false) {
637
- const agentsProxy = {
638
- async create(options: Record<string, unknown>) {
639
- const agents = hostService<{ create(o: Record<string, unknown>): Promise<unknown> }>('agents')
640
- if (!agents) throw new Error('宿主没有 agents 服务')
641
- return (await agents.create(options)) as never
642
- },
643
- async resume(options: Record<string, unknown>) {
644
- const agents = hostService<{ resume(o: Record<string, unknown>): Promise<unknown> }>('agents')
645
- if (!agents) throw new Error('宿主没有 agents 服务')
646
- return (await agents.resume(options)) as never
647
- },
648
- }
649
- sessionAdapter = new HamsSessionAdapter({
650
- db,
651
- service,
652
- dataDir,
653
- defaultPreset: HAMS_BOT_PRESET,
654
- agents: agentsProxy as never,
655
- createUserMessage: (input: Record<string, unknown>) => ({ id: `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, role: 'user', ...input }),
656
- compose: async (bot) => {
657
- const preset = bot.agentPreset && bot.agentPreset !== HAMS_BOT_PRESET ? bot.agentPreset : await ensureHamsBotPreset(ctx, (line) => ctx.logger?.warn?.(line))
658
- // bot 没配模型时用宿主默认模型兜底(否则会话一开就报 has no provider/model)。
659
- const selection = hostService<{ currentSelection?: () => { provider?: string; model?: string; reasoningEffort?: string } }>('agentDefaultModel')?.currentSelection?.()
660
- const model = await resolveAgentOptions(bot.model, hostService('llm') as never, selection)
661
- if (!model) {
662
- throw new HamsError(
663
- 'HOST_CAPABILITY_MISSING',
664
- `bot ${bot.memberId} 没有配置模型,宿主也没有默认模型`,
665
- { nextStep: '在成员配置里给这个 bot 选一个模型,或在宿主设置里选一个默认模型' },
666
- )
667
- }
668
- const registry = hostService<{ mount(agentCtx: unknown, id: string): Promise<unknown> }>('agentPresets')
669
- const setup = registry && preset ? async (agentCtx: unknown) => { await registry.mount(agentCtx, preset) } : undefined
670
- return { agentOptions: model ? { ...model } : {}, ...(setup ? { setup } : {}), agentPreset: preset }
671
- },
672
- })
673
- }
674
-
675
-
676
- // 宿主 connection 可能在插件 apply 之后才激活:每次请求现取,别在启动时缓存。
677
- // 只走 ctx.get():Cordis 里直接读 ctx.connection 会抛「without inject」。
678
- const resolveConnection = (): HostConnection | undefined => {
679
- if (config.connection) return config.connection
680
- if (typeof ctx.get !== 'function') return undefined
681
- try {
682
- const found = ctx.get('connection')
683
- return found && typeof found === 'object' ? (found as HostConnection) : undefined
684
- } catch {
685
- return undefined
686
- }
687
- }
688
- const sessionMode = config.hostSession ?? 'auto'
689
- const sessionState: HostSessionState =
690
- sessionMode === 'off' ? 'off' : resolveConnection() ? 'host' : 'origin-only'
691
-
692
- let ticker: TickerHandle | null = null
693
- if (mode === 'writer' && config.tickEnabled !== false) {
694
- // 启动对账:上一个进程留下的 queued/running 运行必须收尾,否则会一直占着 uq_wi_active_run,
695
- // 被指派的 bot 永远等不到新一轮。
696
- const recovered = recover({ db, runtimeEpoch: runtime!.report.runtimeEpoch, dataDir })
697
- if (recovered.cancelledQueuedRuns || recovered.orphanedRunningRuns) {
698
- ctx.logger?.warn?.(`[hams] 启动对账:取消排队 ${recovered.cancelledQueuedRuns} 个 / 标记孤儿 ${recovered.orphanedRunningRuns} 个`)
699
- }
700
- ticker = createTicker({
701
- db,
702
- runtimeEpoch: runtime!.report.runtimeEpoch,
703
- intervalMs: config.tickMs ?? 5_000,
704
- dataDir,
705
- })
706
- ticker.start()
707
- }
708
-
709
- // 派发:到期复查只把 run 排队;这里负责真正把 queued 运行与「已指派的任务」投给 bot 会话。
710
- let dispatchTimer: ReturnType<typeof setInterval> | null = null
711
- if (sessionAdapter) {
712
- let dispatching = false
713
- dispatchTimer = setInterval(() => {
714
- if (dispatching) return
715
- dispatching = true
716
- Promise.resolve()
717
- .then(() => sessionAdapter!.dispatchAssignments())
718
- .then((report) => { if (report.errors.length) ctx.logger?.warn?.(`[hams] 指派派发失败:${report.errors.slice(0, 3).join(' | ')}`) })
719
- .then(() => sessionAdapter!.dispatchQueued())
720
- .catch((error) => ctx.logger?.warn?.(`[hams] 派发失败:${String(error)}`))
721
- .finally(() => { dispatching = false })
722
- }, Math.max(3000, config.tickMs ?? 5_000))
723
- if (typeof dispatchTimer === 'object' && dispatchTimer && 'unref' in dispatchTimer) dispatchTimer.unref()
724
- }
725
-
726
- const routePaths = ROUTE_PATHS.map((path) => `${API_PREFIX}${path}`)
727
- let toolNames: string[] = []
728
- try {
729
- if (config.toolsEnabled !== false && ctx.tools) {
730
- const definitions = createHamsTools({
731
- service,
732
- db,
733
- resolveSessionOwner: config.defaultBotMemberId
734
- ? () => ({ memberId: config.defaultBotMemberId as string })
735
- : undefined,
736
- ...(chat
737
- ? {
738
- chat: {
739
- projectOfSession: (sessionId: string) => chat.projectOfSession(sessionId),
740
- groupMembersBySession: (sessionId: string) => chat.groupMembersBySession(sessionId),
741
- groupSpeakBySession: (sessionId: string, input) => chat.groupSpeakBySession(sessionId, input),
742
- groupSpawnBySession: (sessionId: string, input) => chat.groupSpawnBySession(sessionId, input),
743
- noteAssignment: (sessionId: string, input) => chat.groupNoteAssignmentBySession(sessionId, input),
744
- rememberBySession: (sessionId: string, input) => chat.rememberBySession(sessionId, input),
745
- },
746
- }
747
- : {}),
748
- })
749
- const hostTools = toHostTools(definitions)
750
- for (const tool of hostTools) {
751
- const disposer = ctx.tools.register(tool)
752
- if (typeof disposer === 'function') disposers.push(disposer as HostDisposer)
753
- }
754
- toolNames = definitions.map((definition) => definition.name)
755
- }
756
-
757
- const handler = createHamsHttpHandler({
758
- service,
759
- authenticate: createLocalAuthenticator(db, config, resolveConnection),
760
- readOnly: mode === 'reader',
761
- ...(chat ? { chat } : {}),
762
- deliverables: { dataDir, db },
763
- })
764
-
765
- for (const path of routePaths) {
766
- const disposer = ctx.webServer.register({
767
- kind: 'exact',
768
- path,
769
- handler: async (req, res) => {
770
- try {
771
- const body = await readRequestBody(req)
772
- const response = await handler({
773
- method: req.method ?? 'POST',
774
- path,
775
- headers: toHeaderRecord(req.headers),
776
- body,
777
- })
778
- writeResponse(res, response)
779
- } catch (error) {
780
- writeResponse(res, {
781
- status: 400,
782
- headers: { 'content-type': 'application/json; charset=utf-8' },
783
- body: JSON.stringify({ ok: false, error: { code: 'STATE_INVALID', message: String((error as Error)?.message ?? error), nextStep: '检查请求体大小后重试' } }),
784
- })
785
- }
786
- },
787
- })
788
- if (typeof disposer === 'function') disposers.push(disposer)
789
- }
790
-
791
- } catch (error) {
792
- // 注册到一半失败:把已经建好的路由、工具、tick 与连接全部收回,再抛出。
793
- while (disposers.length) {
794
- try {
795
- disposers.pop()?.()
796
- } catch {
797
- // 忽略注销失败,继续清理。
798
- }
799
- }
800
- ticker?.stop()
801
- reader?.close()
802
- runtime?.close()
803
- throw error
804
- }
805
-
806
- ctx.logger?.info?.(
807
- `[hams] ${mode === 'writer' ? '写者' : '只读'} 模式启动:${routePaths.length} 条路由 / ${toolNames.length} 个工具,用时 ${Date.now() - startedAt}ms,数据目录 ${dataDir},会话校验 ${sessionState}`,
808
- )
809
- if (workspaceProfile && workspaceProfile.status !== 'missing') {
810
- const summary = workspaceProfile.created
811
- const line = `[hams] 工作区画像 ${workspaceProfile.status}:成员 ${summary.members} / 项目 ${summary.projects} / 工作项 ${summary.workItems} / 文档 ${summary.docs} / 技能 ${summary.skills} / 表 ${summary.tables} / 清理模板 ${summary.removedTemplates}${workspaceProfile.errors.length ? ` / 错误 ${workspaceProfile.errors.length}` : ''}${workspaceProfile.warnings.length ? ` / 告警 ${workspaceProfile.warnings.length}` : ''}`
812
- if (workspaceProfile.status === 'failed' || workspaceProfile.errors.length) ctx.logger?.warn?.(line)
813
- else ctx.logger?.info?.(line)
814
- for (const error of workspaceProfile.errors.slice(0, 5)) ctx.logger?.warn?.(`[hams] 画像:${error}`)
815
- for (const warning of workspaceProfile.warnings.slice(0, 5)) ctx.logger?.warn?.(`[hams] 画像告警:${warning}`)
816
- }
817
- if (sessionState === 'origin-only') {
818
- ctx.logger?.warn?.(
819
- '[hams] 宿主没有提供 connection 服务:界面接口只验 Origin,不校验宿主浏览器会话。' +
820
- '要强制校验,把配置 hostSession 设为 require(或在带 Web 载体的宿主里运行)。',
821
- )
822
- }
823
-
824
- const registered: RegisteredHams = {
825
- mode,
826
- service,
827
- db,
828
- report: runtime?.report ?? null,
829
- ticker,
830
- routes: routePaths,
831
- toolNames,
832
- sessionState,
833
- operatorCapabilitiesAdded,
834
- workspaceProfile,
835
- defaultBots,
836
- dispose() {
837
- while (disposers.length) {
838
- const dispose = disposers.pop()
839
- try {
840
- dispose?.()
841
- } catch {
842
- // 注销失败不能阻碍释放锁。
843
- }
844
- }
845
- ticker?.stop()
846
- if (dispatchTimer) clearInterval(dispatchTimer)
847
- void sessionAdapter?.dispose()
848
- reader?.close()
849
- runtime?.close()
850
- void chatRuntime?.dispose()
851
- },
852
- }
853
- return registered
854
- }
855
-
856
- /** 插件入口调用:优先用 ctx.effect 托管生命周期,拿不到就同步注册。 */
857
- export function applyHams(ctx: HostContext, config: HamsHostConfig = {}): RegisteredHams | null {
858
- if (typeof ctx.effect === 'function') {
859
- let registered: RegisteredHams | null = null
860
- ctx.effect(() => {
861
- registered = registerHams(ctx, config)
862
- return () => registered?.dispose()
863
- }, 'hams.runtime')
864
- return registered
865
- }
866
- return registerHams(ctx, config)
867
- }