@twinsearth/dsh-master 0.0.0-stage → 3.2.8

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 (95) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +89 -2
  3. package/client.js +263 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +377 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/REVIEW-v3.2.7.md +121 -0
  45. package/docs/ROADMAP.md +106 -0
  46. package/docs/SECURITY.md +153 -0
  47. package/docs/SMOKE-3.2.5.md +77 -0
  48. package/docs/SMOKE-3.2.7.md +73 -0
  49. package/docs/SUPERAGENT.md +141 -0
  50. package/docs/WORKER-AGENT-LOOP.md +139 -0
  51. package/icon.svg +9 -0
  52. package/locale/en.json +6 -0
  53. package/locale/zh.json +6 -0
  54. package/package.json +79 -4
  55. package/src/contract/client.js +86 -0
  56. package/src/contract/constants.js +79 -0
  57. package/src/contract/envelope.js +83 -0
  58. package/src/contract/index.js +19 -0
  59. package/src/contract/negotiate.js +106 -0
  60. package/src/contract/server.js +77 -0
  61. package/src/contract/transports.js +319 -0
  62. package/src/index.ts +265 -0
  63. package/src/kernel.ts +190 -0
  64. package/src/plugins/cost-optimizer.ts +155 -0
  65. package/src/plugins/dual-manager.ts +252 -0
  66. package/src/plugins/superagent-router.ts +465 -0
  67. package/src/plugins/superagent-worker.ts +85 -0
  68. package/src/superagent/bridge-tokens.ts +236 -0
  69. package/src/superagent/dispatch.ts +197 -0
  70. package/src/superagent/feishu.ts +104 -0
  71. package/src/superagent/github.ts +111 -0
  72. package/src/superagent/pr-delivery.ts +308 -0
  73. package/src/superagent/router.ts +201 -0
  74. package/src/superagent/stream-adapter.ts +184 -0
  75. package/src/superagent/stream-tokens.ts +142 -0
  76. package/src/superagent/ticket-store.ts +104 -0
  77. package/src/superagent/trust.ts +139 -0
  78. package/src/superagent/types.ts +135 -0
  79. package/src/superagent/worker.ts +100 -0
  80. package/src/superagent/writeback.ts +154 -0
  81. package/tsconfig.host.build.json +12 -0
  82. package/tsconfig.host.json +21 -0
  83. package/tsconfig.worker.build.json +13 -0
  84. package/tsconfig.worker.json +17 -0
  85. package/types/superagent.d.ts +99 -0
  86. package/worker/agent-loop.ts +524 -0
  87. package/worker/errors.ts +47 -0
  88. package/worker/github-pr.ts +65 -0
  89. package/worker/index.ts +334 -0
  90. package/worker/llm-http.ts +359 -0
  91. package/worker/main.ts +45 -0
  92. package/worker/prompt.ts +39 -0
  93. package/worker/sink-http.ts +159 -0
  94. package/worker/tools.ts +531 -0
  95. package/worker/types.ts +247 -0
@@ -0,0 +1,184 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * 流式传输 adapter 层(v3.2.5)。
7
+ *
8
+ * Router 只负责“产生事件”,**怎么送到浏览器由宿主决定**:
9
+ * * 内置 adapter:Node `http.ServerResponse`(SSE,默认);
10
+ * * 宿主 adapter:宿主注入 `dshMasterStreamAdapter` 服务(WS / 自有 SSE / IPC 皆可),
11
+ * 实现 `{ publish(event), clientCount?() }` 即可,Router 不关心底层协议。
12
+ *
13
+ * 这样 DSH 宿主可以按自己的传输栈接入真实 SSE/WS,而不需要改房主插件。
14
+ */
15
+
16
+ /** 单条事件的传输目标(一条 SSE 连接 / 一个 WS 连接)。 */
17
+ export const STREAM_ADAPTER_SERVICE = 'dshMasterStreamAdapter'
18
+
19
+ /**
20
+ * 把 Node ServerResponse 包装成传输目标。
21
+ * @param {object} res http.ServerResponse 或测试替身(writeHead/write/end/on)
22
+ */
23
+ export function createNodeStreamTransport(res) {
24
+ let closed = false
25
+ const closeHandlers = []
26
+ const markClosed = () => {
27
+ if (closed) return
28
+ closed = true
29
+ for (const handler of closeHandlers) {
30
+ try { handler() } catch { /* ignore */ }
31
+ }
32
+ }
33
+ try { res?.on?.('close', markClosed) } catch { /* fake res */ }
34
+ return {
35
+ kind: 'sse',
36
+ get closed() { return closed },
37
+ write(text) {
38
+ if (closed) return false
39
+ try {
40
+ res?.write?.(text)
41
+ return true
42
+ } catch {
43
+ markClosed()
44
+ return false
45
+ }
46
+ },
47
+ end() {
48
+ if (closed) return
49
+ try { res?.end?.() } catch { /* ignore */ }
50
+ markClosed()
51
+ },
52
+ onClose(handler) {
53
+ if (typeof handler === 'function') closeHandlers.push(handler)
54
+ },
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 把“宿主提供的发送函数”包装成传输目标(WS / 自有 SSE / IPC)。
60
+ * @param {{ send: (text: string) => boolean|void, close?: () => void, kind?: string }} options
61
+ */
62
+ export function createHostStreamTransport(options) {
63
+ let closed = false
64
+ const closeHandlers = []
65
+ return {
66
+ kind: String(options?.kind ?? 'host'),
67
+ get closed() { return closed },
68
+ write(text) {
69
+ if (closed) return false
70
+ try {
71
+ const ok = options?.send?.(text)
72
+ return ok !== false
73
+ } catch {
74
+ return false
75
+ }
76
+ },
77
+ end() {
78
+ if (closed) return
79
+ closed = true
80
+ try { options?.close?.() } catch { /* ignore */ }
81
+ for (const handler of closeHandlers) {
82
+ try { handler() } catch { /* ignore */ }
83
+ }
84
+ },
85
+ onClose(handler) {
86
+ if (typeof handler === 'function') closeHandlers.push(handler)
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 解析宿主 adapter:ctx 里注册了 `dshMasterStreamAdapter` 就用它,否则返回 null。
93
+ * 宿主 adapter 契约:`{ publish(event, text), clientCount?() }`。
94
+ * @param {object} ctx Cordis context
95
+ */
96
+ export function resolveHostStreamAdapter(ctx) {
97
+ try {
98
+ const found = typeof ctx?.get === 'function' ? ctx.get(STREAM_ADAPTER_SERVICE) : ctx?.[STREAM_ADAPTER_SERVICE]
99
+ if (!found || typeof found !== 'object') return null
100
+ if (typeof found.publish !== 'function') return null
101
+ return found
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 事件广播器:管理本进程内 SSE 连接 + 可选宿主 adapter。
109
+ *
110
+ * @param {{ ringSize?: number, formatter?: (event: object) => string }} [options]
111
+ */
112
+ export function createStreamBroadcaster(options = {}) {
113
+ const ringSize = Math.max(1, Number(options.ringSize ?? 300) || 300)
114
+ const formatter = typeof options.formatter === 'function'
115
+ ? options.formatter
116
+ : (event) => `event: worker-event\ndata: ${JSON.stringify(event)}\n\n`
117
+ /** @type {Set<any>} */
118
+ const clients = new Set()
119
+ const ring = []
120
+ let hostAdapter = null
121
+ let hostEvents = 0
122
+
123
+ const publish = (event) => {
124
+ ring.push(event)
125
+ if (ring.length > ringSize) ring.splice(0, ring.length - ringSize)
126
+ const text = formatter(event)
127
+ for (const client of [...clients]) {
128
+ let ok = false
129
+ try { ok = client.write(text) !== false } catch { ok = false }
130
+ if (!ok) clients.delete(client)
131
+ }
132
+ if (hostAdapter) {
133
+ try { hostAdapter.publish(event, text) } catch { /* 宿主 adapter 失败不影响本机 SSE */ }
134
+ hostEvents += 1
135
+ }
136
+ }
137
+
138
+ return {
139
+ publish,
140
+ /** 新连接:先回放环形缓冲,再开始实时接收。 */
141
+ attach(client, { replay = true, retryMs = 3000 } = {}) {
142
+ if (!client) return false
143
+ clients.add(client)
144
+ try { client.write(`retry: ${retryMs}\n\n`) } catch { /* ignore */ }
145
+ if (replay) {
146
+ for (const event of ring) {
147
+ try { client.write(formatter(event)) } catch { /* ignore */ }
148
+ }
149
+ }
150
+ try { client.onClose?.(() => clients.delete(client)) } catch { /* ignore */ }
151
+ return true
152
+ },
153
+ detach(client) { clients.delete(client) },
154
+ useHostAdapter(adapter) {
155
+ hostAdapter = adapter && typeof adapter.publish === 'function' ? adapter : null
156
+ return hostAdapter !== null
157
+ },
158
+ describe() {
159
+ return {
160
+ clients: clients.size,
161
+ ring: ring.length,
162
+ hostAdapter: Boolean(hostAdapter),
163
+ hostAdapterName: hostAdapter?.name ?? null,
164
+ hostEvents,
165
+ }
166
+ },
167
+ /** 心跳:给所有连接写注释行,探测死连接。 */
168
+ heartbeat(text = ': ping\n\n') {
169
+ for (const client of [...clients]) {
170
+ let ok = false
171
+ try { ok = client.write(text) !== false } catch { ok = false }
172
+ if (!ok) clients.delete(client)
173
+ }
174
+ try { hostAdapter?.heartbeat?.(text) } catch { /* ignore */ }
175
+ },
176
+ closeAll() {
177
+ for (const client of clients) {
178
+ try { client.end?.() } catch { /* ignore */ }
179
+ }
180
+ clients.clear()
181
+ try { hostAdapter?.close?.() } catch { /* ignore */ }
182
+ },
183
+ }
184
+ }
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * 流式 token 管理(v3.2.5):签发 / 校验 / 轮换 / 撤销 / TTL / 审计。
7
+ *
8
+ * 两类 token:
9
+ * * worker:Router 派发时注入 Worker(DSH_MASTER_STREAM_TOKEN),用于 worker-events 回传;
10
+ * * ui:浏览器订阅 SSE 时使用(短 TTL、可轮换,避免把 worker token 暴露给页面)。
11
+ *
12
+ * 审计只记录动作与元数据,不记录 token 本身;所有校验都走常量时间比较。
13
+ */
14
+
15
+ import crypto from 'node:crypto'
16
+
17
+ const AUDIT_LIMIT = 500
18
+
19
+ function nowOf(clock) { return typeof clock === 'function' ? clock() : Date.now() }
20
+
21
+ function safeEqual(a, b) {
22
+ const left = Buffer.from(String(a ?? ''), 'utf8')
23
+ const right = Buffer.from(String(b ?? ''), 'utf8')
24
+ if (left.length === 0 || left.length !== right.length) return false
25
+ return crypto.timingSafeEqual(left, right)
26
+ }
27
+
28
+ /**
29
+ * @param {{workerTtlMs?:number, uiTtlMs?:number, auditLimit?:number, clock?:()=>number}} [options]
30
+ */
31
+ export function createStreamTokenManager(options = {}) {
32
+ /**
33
+ * @param {object} [options]
34
+ * @param {(record:object)=>void} [options.auditSink] 审计外送(例如落盘 JSONL);不含明文 token
35
+ */
36
+ const workerTtlMs = Math.max(1_000, Number(options.workerTtlMs ?? 6 * 60 * 60_000))
37
+ const uiTtlMs = Math.max(1_000, Number(options.uiTtlMs ?? 10 * 60_000))
38
+ const auditLimit = Math.max(10, Number(options.auditLimit ?? AUDIT_LIMIT))
39
+ const clock = typeof options.clock === 'function' ? options.clock : () => Date.now()
40
+ const tokens = new Map()
41
+ const auditLog = []
42
+ const counters = { issued: 0, verified: 0, rejected: 0, rotated: 0, revoked: 0, expired: 0 }
43
+
44
+ const auditSink = typeof options.auditSink === 'function' ? options.auditSink : null
45
+ const audit = (action, detail = {}) => {
46
+ const record = Object.freeze({ at: nowOf(clock), scope: 'stream', action, ...detail })
47
+ try { auditSink?.(record) } catch { /* 审计外送失败不影响鉴权 */ }
48
+ auditLog.push(record)
49
+ if (auditLog.length > auditLimit) auditLog.splice(0, auditLog.length - auditLimit)
50
+ }
51
+ const issue = ({ kind = 'worker', ticketId = '', ttlMs, meta = {} } = {}) => {
52
+ const token = crypto.randomBytes(24).toString('hex')
53
+ const ttl = Math.max(1_000, Number(ttlMs ?? (kind === 'ui' ? uiTtlMs : workerTtlMs)))
54
+ const entry = Object.freeze({
55
+ kind: String(kind),
56
+ ticketId: String(ticketId ?? ''),
57
+ issuedAt: nowOf(clock),
58
+ expiresAt: nowOf(clock) + ttl,
59
+ meta: meta && typeof meta === 'object' ? { ...meta } : {},
60
+ })
61
+ tokens.set(token, entry)
62
+ counters.issued += 1
63
+ audit('issue', { kind: entry.kind, ticketId: entry.ticketId, expiresAt: entry.expiresAt })
64
+ return { token, kind: entry.kind, ticketId: entry.ticketId, expiresAt: entry.expiresAt }
65
+ }
66
+ const find = (token) => tokens.get(String(token ?? ''))
67
+ const verify = (token, { kind = null, ticketId = null } = {}) => {
68
+ const value = String(token ?? '')
69
+ if (!value) {
70
+ counters.rejected += 1
71
+ audit('verify-fail', { reason: 'missing', kind, ticketId })
72
+ return { ok: false, reason: 'token-missing' }
73
+ }
74
+ const entry = find(value)
75
+ if (!entry) {
76
+ counters.rejected += 1
77
+ audit('verify-fail', { reason: 'unknown', kind, ticketId })
78
+ return { ok: false, reason: 'token-unknown' }
79
+ }
80
+ if (nowOf(clock) > entry.expiresAt) {
81
+ tokens.delete(value)
82
+ counters.expired += 1
83
+ audit('expire', { kind: entry.kind, ticketId: entry.ticketId })
84
+ return { ok: false, reason: 'token-expired' }
85
+ }
86
+ if (kind && entry.kind !== kind) {
87
+ counters.rejected += 1
88
+ audit('verify-fail', { reason: 'kind-mismatch', kind, ticketId })
89
+ return { ok: false, reason: 'token-kind-mismatch' }
90
+ }
91
+ if (ticketId !== null && entry.kind !== 'ui' && entry.ticketId !== String(ticketId)) {
92
+ counters.rejected += 1
93
+ audit('verify-fail', { reason: 'ticket-mismatch', kind: entry.kind, ticketId })
94
+ return { ok: false, reason: 'token-ticket-mismatch' }
95
+ }
96
+ counters.verified += 1
97
+ audit('verify-ok', { kind: entry.kind, ticketId: entry.ticketId })
98
+ return { ok: true, entry }
99
+ }
100
+ const revoke = (token, reason = 'manual') => {
101
+ const value = String(token ?? '')
102
+ const entry = tokens.get(value)
103
+ if (!entry) return { ok: false, reason: 'token-unknown' }
104
+ tokens.delete(value)
105
+ counters.revoked += 1
106
+ audit('revoke', { kind: entry.kind, ticketId: entry.ticketId, reason: String(reason) })
107
+ return { ok: true, entry }
108
+ }
109
+ const rotate = (oldToken, { kind = 'ui', ticketId = '' } = {}) => {
110
+ const old = String(oldToken ?? '')
111
+ // 轮换不是“凭空签发”:旧 token 必须存在、未过期且类型匹配。
112
+ // 否则任意拿到本机端口的人都能用任意字符串换出一张有效 UI token。
113
+ const verified = verify(old, { kind })
114
+ if (!verified.ok) return { token: '', error: verified.reason, ok: false }
115
+ revoke(old, 'rotate')
116
+ const issued = issue({ kind, ticketId: kind === 'ui' ? '' : ticketId })
117
+ counters.rotated += 1
118
+ audit('rotate', { kind: issued.kind, ticketId: issued.ticketId, expiresAt: issued.expiresAt })
119
+ return { ...issued, ok: true }
120
+ }
121
+ const sweep = () => {
122
+ const now = nowOf(clock)
123
+ let removed = 0
124
+ for (const [token, entry] of tokens.entries()) {
125
+ if (now > entry.expiresAt) {
126
+ tokens.delete(token)
127
+ removed += 1
128
+ counters.expired += 1
129
+ audit('expire', { kind: entry.kind, ticketId: entry.ticketId })
130
+ }
131
+ }
132
+ return removed
133
+ }
134
+ return {
135
+ issue, verify, revoke, rotate, sweep,
136
+ audit(limit = 20) { return auditLog.slice(-Math.max(1, Math.floor(Number(limit) || 20))) },
137
+ counts() { return { ...counters, active: tokens.size } },
138
+ describe() { return { workerTtlMs, uiTtlMs, active: tokens.size, counters: { ...counters } } },
139
+ }
140
+ }
141
+
142
+ export { safeEqual }
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * Ticket 持久化(v3.2.1)。
7
+ *
8
+ * 目标:Router 重启后 Task/Ticket 可追溯(对应瓮城“持久化状态 → 会话仓”的映射)。
9
+ * 实现保持最小:`<dataDir>/superagent-tickets.json`,原子写(tmp + rename)。
10
+ * dataDir 为空时降级为内存存储(测试/开发默认),并在状态里标记 persistence=false。
11
+ */
12
+
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ const FILE_NAME = 'superagent-tickets.json'
17
+ const VERSION = 1
18
+
19
+ function safeLoad(file) {
20
+ try {
21
+ if (!fs.existsSync(file)) return []
22
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
23
+ if (!parsed || parsed.version !== VERSION || !Array.isArray(parsed.records)) return []
24
+ return parsed.records.filter((record) => record && record.ticket && typeof record.ticket.id === 'string')
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {{dataDir?:string, maxTickets?:number, logger?:{warn?:Function}}} [options]
32
+ */
33
+ export function createTicketStore(options = {}) {
34
+ const dataDir = String(options.dataDir ?? '').trim()
35
+ const maxTickets = Math.max(1, Math.floor(Number(options.maxTickets ?? 500)) || 500)
36
+ const logger = options.logger ?? null
37
+ const file = dataDir ? path.join(dataDir, FILE_NAME) : ''
38
+ const records = new Map()
39
+
40
+ for (const record of safeLoad(file)) records.set(record.ticket.id, record)
41
+
42
+ const persist = () => {
43
+ if (!file) return { ok: true, persisted: false }
44
+ try {
45
+ fs.mkdirSync(dataDir, { recursive: true })
46
+ const payload = {
47
+ version: VERSION,
48
+ updatedAt: Date.now(),
49
+ records: [...records.values()],
50
+ }
51
+ const tmp = file + '.tmp'
52
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
53
+ fs.renameSync(tmp, file)
54
+ return { ok: true, persisted: true }
55
+ } catch (error) {
56
+ try { logger?.warn?.(`[superagent] ticket persistence failed: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
57
+ return { ok: false, persisted: false, error: String(error?.message ?? error) }
58
+ }
59
+ }
60
+
61
+ const trim = () => {
62
+ if (records.size <= maxTickets) return
63
+ const overflow = records.size - maxTickets
64
+ const ids = [...records.keys()].slice(0, overflow)
65
+ for (const id of ids) records.delete(id)
66
+ }
67
+
68
+ const store = {
69
+ persistence: () => ({ enabled: Boolean(file), file: file || null }),
70
+ add(ticket, status = 'pending', detail = null) {
71
+ if (!ticket || typeof ticket.id !== 'string') throw new Error('ticket with id is required')
72
+ const record = Object.freeze({ ticket, status: String(status), updatedAt: Date.now(), detail })
73
+ records.set(ticket.id, record)
74
+ trim()
75
+ const write = persist()
76
+ return { ...record, persisted: write.persisted }
77
+ },
78
+ get(id) {
79
+ return records.get(String(id ?? '')) ?? null
80
+ },
81
+ setStatus(id, status, detail = null) {
82
+ const record = records.get(String(id ?? ''))
83
+ if (!record) return null
84
+ const next = Object.freeze({ ...record, status: String(status), updatedAt: Date.now(), detail })
85
+ records.set(record.ticket.id, next)
86
+ persist()
87
+ return next
88
+ },
89
+ list({ status = null, limit = 50 } = {}) {
90
+ const all = [...records.values()].sort((a, b) => b.updatedAt - a.updatedAt)
91
+ const filtered = status ? all.filter((record) => record.status === status) : all
92
+ return filtered.slice(0, Math.max(1, Math.floor(Number(limit)) || 50))
93
+ },
94
+ counts() {
95
+ const counts = {}
96
+ for (const record of records.values()) counts[record.status] = (counts[record.status] ?? 0) + 1
97
+ return counts
98
+ },
99
+ size() { return records.size },
100
+ }
101
+ return store
102
+ }
103
+
104
+ export { FILE_NAME }
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * SuperAgent 渐进式信任与 Ticket 生成(v3.2.1)。
7
+ *
8
+ * 纯函数:不读环境、不做 IO;Router 插件负责配置注入与持久化。
9
+ * 关键语义:
10
+ * * off —— 完全关闭,事件全部忽略;
11
+ * * observe —— 记录“本该执行的工单”(status=observed),不派发、不改代码;
12
+ * * auto —— 通过确定性 Gate 后派发 Worker 分身;缺派发能力时 unavailable。
13
+ * 默认 observe(用户要求:原型先观察,不先自动接单)。
14
+ */
15
+
16
+ import { normalizeTrustMode, validateSourceEvent } from './types.ts'
17
+
18
+ export const DEFAULT_POLICY = Object.freeze({
19
+ trustMode: 'observe',
20
+ allowlistedActors: Object.freeze([]),
21
+ requireMention: true,
22
+ allowedSources: Object.freeze(['github', 'feishu']),
23
+ repoAllowlist: Object.freeze([]),
24
+ policyVersion: 'v1.0',
25
+ })
26
+
27
+ let ticketSeq = 0
28
+
29
+ /** 策略规范化;非法信任模式 fail-closed 到 observe(并记录 requested 值供状态页展示)。 */
30
+ export function normalizePolicy(input = {}) {
31
+ const raw = input && typeof input === 'object' ? input : {}
32
+ const list = (value, fallback) => {
33
+ if (!Array.isArray(value)) return [...fallback]
34
+ const out = value.map((item) => String(item ?? '').trim()).filter(Boolean)
35
+ return out.length > 0 ? out : [...fallback]
36
+ }
37
+ const actors = Array.isArray(raw.allowedActors) ? raw.allowedActors.map((item) => String(item ?? '').trim()).filter(Boolean) : []
38
+ const sources = Array.isArray(raw.allowedSources)
39
+ ? raw.allowedSources.map((item) => String(item ?? '').trim().toLowerCase()).filter(Boolean)
40
+ : ['github', 'feishu']
41
+ const repos = Array.isArray(raw.repoAllowlist)
42
+ ? raw.repoAllowlist.map((item) => String(item ?? '').trim()).filter(Boolean)
43
+ : []
44
+ return Object.freeze({
45
+ trustMode: normalizeTrustMode(raw.trustMode, 'observe'),
46
+ requestedTrustMode: String(raw.trustMode ?? '').trim() || 'observe',
47
+ allowlistedActors: Object.freeze(actors),
48
+ requireMention: raw.requireMention !== false,
49
+ allowedSources: Object.freeze(sources.length > 0 ? sources : ['github', 'feishu']),
50
+ repoAllowlist: Object.freeze(repos),
51
+ policyVersion: String(raw.policyVersion ?? DEFAULT_POLICY.policyVersion).trim() || DEFAULT_POLICY.policyVersion,
52
+ })
53
+ }
54
+
55
+ /** 从 sourceId 提取仓库:github:owner/repo → owner/repo;其他返回 ''。 */
56
+ export function repoFromSourceId(sourceId) {
57
+ const text = String(sourceId ?? '')
58
+ if (!text.startsWith('github:')) return ''
59
+ return text.slice('github:'.length)
60
+ }
61
+
62
+ /** 仓库允许列表:精确、`*`、前缀 `owner/*` 或任意 `prefix*`。空列表表示不限制。 */
63
+ export function repoAllowed(repo, patterns) {
64
+ if (!Array.isArray(patterns) || patterns.length === 0) return true
65
+ const target = String(repo ?? '')
66
+ return patterns.some((pattern) => {
67
+ if (pattern === '*') return true
68
+ if (pattern === target) return true
69
+ if (pattern.endsWith('*')) return target.startsWith(pattern.slice(0, -1))
70
+ return false
71
+ })
72
+ }
73
+
74
+ /**
75
+ * 生成不可变 Ticket(Mandate 快照)。
76
+ * @param {object} event 已校验的 SourceEvent
77
+ * @param {object} policy normalizePolicy 的返回值
78
+ */
79
+ export function createTicket(event, policy) {
80
+ ticketSeq += 1
81
+ const now = Date.now()
82
+ const mandate = Object.freeze({
83
+ scope: String(event.threadId),
84
+ policyVersion: String(policy.policyVersion),
85
+ strength: event.isMention ? 'explicit' : 'inferred',
86
+ allowedSources: Object.freeze([event.sourceId.split(':')[0]]),
87
+ createdAt: now,
88
+ })
89
+ return Object.freeze({
90
+ id: `ticket-${now.toString(36)}-${ticketSeq.toString(36)}`,
91
+ sourceId: String(event.sourceId),
92
+ requester: String(event.actor),
93
+ payload: String(event.content),
94
+ eventType: String(event.eventType),
95
+ createdAt: now,
96
+ mandate,
97
+ })
98
+ }
99
+
100
+ /**
101
+ * 确定性信任评估(不调用模型、不做 IO)。
102
+ * @param {{event:object, policy:object, dispatchAvailable:boolean}} input
103
+ * @returns {{decision:'accept'|'ignore'|'observe'|'unavailable', reasons:string[], ticket?:object, reason?:string}}
104
+ */
105
+ export function evaluateEvent({ event, policy, dispatchAvailable = false } = {}) {
106
+ const validated = validateSourceEvent(event)
107
+ if (!validated.ok) return { decision: 'ignore', reasons: validated.errors, reason: validated.errors[0] }
108
+ const source = validated.event
109
+ const reasons = []
110
+ if (policy.trustMode === 'off') reasons.push('trust-mode-off')
111
+ if (!policy.allowedSources.includes(source.sourceId.split(':')[0])) reasons.push(`source-not-allowed:${source.sourceId.split(':')[0]}`)
112
+ if (source.actorIsBot) reasons.push('actor-is-bot')
113
+ if (policy.allowlistedActors.length === 0) reasons.push('actor-allowlist-empty')
114
+ else if (!policy.allowlistedActors.includes(source.actor)) reasons.push(`actor-not-allowlisted:${source.actor}`)
115
+ if (policy.requireMention && !source.isMention) reasons.push('mention-required')
116
+ if (source.repo && !repoAllowed(source.repo, policy.repoAllowlist)) reasons.push(`repo-not-allowlisted:${source.repo}`)
117
+ if (reasons.length > 0) return { decision: 'ignore', reasons, reason: reasons[0] }
118
+
119
+ const ticket = createTicket(source, policy)
120
+ if (policy.trustMode === 'observe') return { decision: 'observe', reasons: [], ticket }
121
+ if (policy.trustMode === 'auto') {
122
+ if (!dispatchAvailable) return { decision: 'unavailable', reasons: ['dispatch-unavailable'], reason: 'dispatch-unavailable', ticket }
123
+ return { decision: 'accept', reasons: [], ticket }
124
+ }
125
+ return { decision: 'ignore', reasons: [`trust-mode-unknown:${policy.trustMode}`], reason: 'trust-mode-unknown' }
126
+ }
127
+
128
+ /** 状态页展示用:策略摘要(无 secret)。 */
129
+ export function describePolicy(policy) {
130
+ return {
131
+ trustMode: policy.trustMode,
132
+ requestedTrustMode: policy.requestedTrustMode,
133
+ allowlistedActors: [...policy.allowlistedActors],
134
+ requireMention: policy.requireMention,
135
+ allowedSources: [...policy.allowedSources],
136
+ repoAllowlist: [...policy.repoAllowlist],
137
+ policyVersion: policy.policyVersion,
138
+ }
139
+ }