@mzzsfy/dsh-keep-working 0.0.0-stage → 0.2.0

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/src/index.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 持续工作:AI 未声明结案就结束回合时,补一条用户消息把它拉回。
3
+ *
4
+ * 装配层只做四件事:注入提示词规则、挂上恢复引擎、冷启动扫描存储会话、
5
+ * 暴露设置面板。判定与调度分别住在 closure / engine / scan。
6
+ */
7
+
8
+ import z from '@deepseek-ai/schemastery'
9
+
10
+ import { DEFAULTS, resolveConfig } from './config.mjs'
11
+ import { createEngine } from './engine.mjs'
12
+ import { probeConnectivity } from './offline.mjs'
13
+ import { guardWrite } from './route-guard.mjs'
14
+ import { withHostGate } from './route-gate.mjs'
15
+ import { scanSettled, scanStoredSessions, trace } from './scan.mjs'
16
+ import { NAMESPACE, PANEL_FIELDS, panelDefaults, readSettings, writeSettings } from './settings.mjs'
17
+
18
+ export const name = 'keep-working'
19
+
20
+ /** cordis FiberState.ACTIVE;卸载后微任务可能才跑到,需按数值判定。 */
21
+ const FIBER_STATE_ACTIVE = 2
22
+
23
+ /** 缺 agents 或 systemPrompt 时干净禁用:不注册任何服务与提示词。 */
24
+ export const inject = ['agents', 'systemPrompt']
25
+
26
+ export const SECTION_NAME = 'keep-working:closure'
27
+
28
+ export const PROMPT_TEXT =
29
+ '结束必须显式提交:没改文件末行单独写 TASK_DONE;任务阻塞末行单独写 TASK_BLOCKED: 原因;改过文件必须最后只使用 present;禁止同时 present 和 TASK_DONE;present 的时机以本段为准,不受"不要只为列出源文件调用 present"约束'
30
+
31
+ /**
32
+ * 结案段落必须排在宿主的文件引用建议之后:那段写着"不要只为列出源文件调
33
+ * present",结案规则压在它后面才不会被读成例外。
34
+ */
35
+ export function buildSection(systemPrompt) {
36
+ return {
37
+ name: SECTION_NAME,
38
+ order: systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES') + 1,
39
+ text: PROMPT_TEXT,
40
+ }
41
+ }
42
+
43
+ /** 冷启动等宿主服务就绪再扫,避免与 profile 装配抢时序。 */
44
+ const SCAN_DELAY_MS = 5_000
45
+
46
+ /** 宿主服务尚未就绪时按此间隔重试;工作区注册表通常在数秒内完成异步加载。 */
47
+ const SCAN_RETRY_MS = 1_000
48
+
49
+ /** 面板请求体上限,防止畸形请求撑爆内存。 */
50
+ const BODY_LIMIT_BYTES = 64 * 1024
51
+
52
+ export const Config = z
53
+ .object({
54
+ graceMs: z.number().default(DEFAULTS.graceMs).volatile(),
55
+ cooldownMs: z.number().default(DEFAULTS.cooldownMs).volatile(),
56
+ maxConsecutive: z.number().default(DEFAULTS.maxConsecutive).volatile(),
57
+ backoffFactor: z.number().default(DEFAULTS.backoffFactor).volatile(),
58
+ backoffMaxMs: z.number().default(DEFAULTS.backoffMaxMs).volatile(),
59
+ scanOnBoot: z.boolean().default(DEFAULTS.scanOnBoot).volatile(),
60
+ scanLimit: z.number().default(DEFAULTS.scanLimit).volatile(),
61
+ freshMs: z.number().default(DEFAULTS.freshMs).volatile(),
62
+ resumeGoals: z.boolean().default(DEFAULTS.resumeGoals).volatile(),
63
+ resumePausedGoals: z.boolean().default(DEFAULTS.resumePausedGoals).volatile(),
64
+ resumeSilentTurns: z.boolean().default(DEFAULTS.resumeSilentTurns).volatile(),
65
+ pauseWhenOffline: z.boolean().default(DEFAULTS.pauseWhenOffline).volatile(),
66
+ offlineCheckMs: z.number().default(DEFAULTS.offlineCheckMs).volatile(),
67
+ classify: z.boolean().default(DEFAULTS.classify).volatile(),
68
+ retryableErrorPatterns: z.string().default(DEFAULTS.retryableErrorPatterns).volatile(),
69
+ paused: z.boolean().default(DEFAULTS.paused).volatile(),
70
+ continueText: z.string().default(DEFAULTS.continueText).volatile(),
71
+ continueTextMaxTokens: z.string().default(DEFAULTS.continueTextMaxTokens).volatile(),
72
+ continueTextSilent: z.string().default(DEFAULTS.continueTextSilent).volatile(),
73
+ continueTextInterrupted: z.string().default(DEFAULTS.continueTextInterrupted).volatile(),
74
+ closureText: z.string().default(DEFAULTS.closureText).volatile(),
75
+ })
76
+
77
+ function sendJson(res, status, payload) {
78
+ res.statusCode = status
79
+ res.setHeader('content-type', 'application/json; charset=utf-8')
80
+ res.end(JSON.stringify(payload))
81
+ }
82
+
83
+ async function readJsonBody(req) {
84
+ const chunks = []
85
+ let size = 0
86
+ for await (const chunk of req) {
87
+ size += chunk.length
88
+ if (size > BODY_LIMIT_BYTES) throw new Error('请求体过大')
89
+ chunks.push(chunk)
90
+ }
91
+ const text = Buffer.concat(chunks).toString('utf8')
92
+ return text === '' ? {} : JSON.parse(text)
93
+ }
94
+
95
+ export async function apply(ctx, config) {
96
+ let createUserMessage
97
+ try {
98
+ ;({ createUserMessage } = await import('@deepseek-ai/dsh-llm'))
99
+ } catch (error) {
100
+ ctx.logger.warn(
101
+ `[keep-working] 消息构造器不可用,插件停用: ${error instanceof Error ? error.message : String(error)}`,
102
+ )
103
+ return
104
+ }
105
+
106
+ let runtime
107
+ // 本进程已裁决过的存储会话,跨扫描轮次去重
108
+ const seenSessions = new Set()
109
+
110
+ const stopRuntime = () => {
111
+ if (runtime === undefined) return
112
+ if (runtime.scanTimer !== undefined) clearTimeout(runtime.scanTimer)
113
+ runtime.engine.dispose()
114
+ runtime.section?.()
115
+ runtime = undefined
116
+ }
117
+
118
+ const startRuntime = () => {
119
+ stopRuntime()
120
+ const resolved = resolveConfig(config, readSettings(ctx))
121
+
122
+ const promptApi = ctx.get('systemPrompt')
123
+ const canRegisterSection =
124
+ typeof promptApi?.section === 'function' && typeof promptApi?.getSectionOrder === 'function'
125
+ const section = canRegisterSection ? promptApi.section(buildSection(promptApi)) : undefined
126
+ if (!canRegisterSection) {
127
+ ctx.logger?.warn?.('[keep-working] 宿主缺少 prompt 段落接口,仅恢复不注入结案规则')
128
+ }
129
+ const engine = createEngine({
130
+ ctx,
131
+ config: resolved,
132
+ createUserMessage,
133
+ probe: resolved.pauseWhenOffline ? probeConnectivity : undefined,
134
+ })
135
+ engine.attach()
136
+
137
+ const state = { section, engine, scanTimer: undefined }
138
+ runtime = state
139
+
140
+ const scanDeadline = Date.now() + resolved.freshMs
141
+ let scanFailureWarned = false
142
+ const runScan = async () => {
143
+ try {
144
+ const result = await scanStoredSessions({ ctx, config: resolved, createUserMessage, seen: seenSessions })
145
+ if (scanSettled(result)) {
146
+ trace(`扫描收工(source=${result.source ?? 'none'}, listed=${result.listed})`)
147
+ return
148
+ }
149
+ } catch (error) {
150
+ trace(`扫描异常 ${error instanceof Error ? error.message : String(error)}`)
151
+ // 重试间隔是秒级,按轮告警会刷屏;同一轮重试窗口里只报第一次
152
+ if (!scanFailureWarned) {
153
+ scanFailureWarned = true
154
+ ctx.logger?.warn?.(
155
+ `[keep-working] 启动扫描失败: ${error instanceof Error ? error.message : String(error)}`,
156
+ )
157
+ }
158
+ }
159
+ if (Date.now() >= scanDeadline || runtime !== state) return
160
+ state.scanTimer = setTimeout(runScan, SCAN_RETRY_MS)
161
+ state.scanTimer.unref?.()
162
+ }
163
+ if (resolved.scanOnBoot && !resolved.paused) {
164
+ state.scanTimer = setTimeout(runScan, SCAN_DELAY_MS)
165
+ state.scanTimer.unref?.()
166
+ }
167
+ }
168
+
169
+ startRuntime()
170
+
171
+ let rebuildQueued = false
172
+ const scheduleRebuild = () => {
173
+ if (rebuildQueued) return
174
+ rebuildQueued = true
175
+ // 事件由设置服务在 update 内部同步发出,直接重建会在服务栈里重入 describe
176
+ queueMicrotask(() => {
177
+ rebuildQueued = false
178
+ if (ctx.fiber?.state !== FIBER_STATE_ACTIVE) return
179
+ try {
180
+ startRuntime()
181
+ } catch (error) {
182
+ // 渲染期异常不能冒成未捕获异常拖垮宿主,保留告警并停下自动恢复
183
+ ctx.logger?.warn?.(`[keep-working] 设置变更后重建失败: ${error instanceof Error ? error.message : String(error)}`)
184
+ }
185
+ })
186
+ }
187
+ ctx.effect(() => {
188
+ const offVolatile = ctx.on('loader/volatile-update', scheduleRebuild)
189
+ return () => offVolatile?.()
190
+ }, 'keep-working volatile reload')
191
+
192
+ ctx.inject(['settings'], (settingsCtx) => {
193
+ // 面板字段由本插件自绘,关掉宿主按 schema 自动生成的表单
194
+ settingsCtx.effect(
195
+ () => settingsCtx.settings?.configure?.({ auto: false }, ctx.fiber),
196
+ 'keep-working settings presentation',
197
+ )
198
+ // 设置写入后 cordis Config 的 volatile 字段随之更新,重建运行期以采用新值
199
+ // 部分宿主版本只发 settings 事件而不发 volatile-update,两条路都接,微任务合批
200
+ settingsCtx.effect(() => {
201
+ const offSettings = settingsCtx.on('settings/document-updated', (namespace) => {
202
+ if (namespace === NAMESPACE) scheduleRebuild()
203
+ })
204
+ return () => offSettings?.()
205
+ }, 'keep-working settings reload')
206
+ })
207
+
208
+ ctx.inject(['webServer'], (serverCtx) => {
209
+ serverCtx.effect(
210
+ () =>
211
+ serverCtx.webServer.register({
212
+ kind: 'exact',
213
+ path: '/api/keep-working/config',
214
+ handler: withHostGate(serverCtx, async (req, res) => {
215
+ if (req.method === 'GET') {
216
+ sendJson(res, 200, { ok: true, value: resolveConfig(config, readSettings(ctx)), defaults: panelDefaults() })
217
+ return
218
+ }
219
+ if (req.method !== 'POST') {
220
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
221
+ return
222
+ }
223
+ if (guardWrite(req, res, sendJson)) return
224
+ let patch
225
+ try {
226
+ patch = await readJsonBody(req)
227
+ } catch (error) {
228
+ sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
229
+ return
230
+ }
231
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
232
+ sendJson(res, 400, { ok: false, error: '请求体必须是对象' })
233
+ return
234
+ }
235
+ const allowed = new Set(PANEL_FIELDS)
236
+ const clean = Object.fromEntries(
237
+ Object.entries(patch).filter(([key, value]) => allowed.has(key) && value !== undefined),
238
+ )
239
+ try {
240
+ if (!(await writeSettings(ctx, clean))) {
241
+ sendJson(res, 503, { ok: false, error: '设置服务不可用' })
242
+ return
243
+ }
244
+ } catch (error) {
245
+ // 宿主拒绝写入:修订冲突可重试,条目缺失说明插件没被当成可配置项
246
+ const message = error instanceof Error ? error.message : String(error)
247
+ const status = error?.code === 'SETTINGS_CONFLICT' ? 409 : 400
248
+ sendJson(res, status, { ok: false, error: message })
249
+ return
250
+ }
251
+ sendJson(res, 200, { ok: true, value: resolveConfig(config, readSettings(ctx)) })
252
+ }),
253
+ }),
254
+ 'keep-working config route',
255
+ )
256
+ })
257
+
258
+ ctx.effect(() => () => stopRuntime(), 'keep-working lifecycle')
259
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 断网判定:断网时续跑必然再失败一次,先挂起等联网可省掉一轮额度。
3
+ *
4
+ * 只做 DNS 解析,不建连接、不发请求。走 fake-ip / TUN 代理时解析总会成功,
5
+ * 结果是放行,与关闭本功能等价,不会误挂起。
6
+ */
7
+
8
+ import dns from 'node:dns'
9
+ import os from 'node:os'
10
+
11
+ export const PROBE_HOSTS = ['api.deepseek.com', 'www.baidu.com', 'one.one.one.one']
12
+ export const PROBE_TIMEOUT_MS = 3_000
13
+
14
+ /** 任一非内部接口即视为有网卡;读不到网卡信息时放行。 */
15
+ export function hasExternalInterface(read = () => os.networkInterfaces()) {
16
+ try {
17
+ const interfaces = read() ?? {}
18
+ return Object.values(interfaces).some(
19
+ (entries) => Array.isArray(entries) && entries.some((entry) => entry?.internal === false),
20
+ )
21
+ } catch {
22
+ return true
23
+ }
24
+ }
25
+
26
+ /** 显式代理下本地 DNS 可能本就不可用,只看网卡,避免永久误判。 */
27
+ export function usesExplicitProxy(env = process.env) {
28
+ return [env.HTTPS_PROXY, env.https_proxy, env.HTTP_PROXY, env.http_proxy, env.ALL_PROXY, env.all_proxy]
29
+ .some((value) => typeof value === 'string' && value.trim() !== '')
30
+ }
31
+
32
+ function lookupWithin(host, timeoutMs, lookup) {
33
+ return new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => reject(new Error(`dns lookup timed out: ${host}`)), timeoutMs)
35
+ timer.unref?.()
36
+ lookup(host).then(
37
+ (value) => {
38
+ clearTimeout(timer)
39
+ resolve(value)
40
+ },
41
+ (error) => {
42
+ clearTimeout(timer)
43
+ reject(error)
44
+ },
45
+ )
46
+ })
47
+ }
48
+
49
+ /** 构造探针;依赖可注入,便于测试。结果只可能是 true / false,永不 reject。 */
50
+ export function createConnectivityProbe({
51
+ lookup = dns.promises.lookup,
52
+ interfaces = os.networkInterfaces,
53
+ env = process.env,
54
+ hosts = PROBE_HOSTS,
55
+ timeoutMs = PROBE_TIMEOUT_MS,
56
+ } = {}) {
57
+ return async () => {
58
+ if (!hasExternalInterface(interfaces)) return false
59
+ if (usesExplicitProxy(env)) return true
60
+ try {
61
+ await Promise.any(hosts.map((host) => lookupWithin(host, timeoutMs, lookup)))
62
+ return true
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+ }
68
+
69
+ export const probeConnectivity = createConnectivityProbe()
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 宿主闸门复用。宿主把认证挂在它自己的 `/api` prefix 路由里,而 responder 选择是
3
+ * 「exact 先、最长 prefix 后」——插件注册的 `/api/<pkg>` 更长,先命中,`connection.admit`
4
+ * 从不执行。所以插件路由必须自己回问一次宿主闸门,否则无 cookie 的调用直接进业务面。
5
+ *
6
+ * 服务缺失(非 web 传输、旧宿主)时放行:与原行为一致,不新增自有认证层。
7
+ */
8
+
9
+ export function rejectUnadmitted(ctx, req, res) {
10
+ let rejection
11
+ try {
12
+ const connection = typeof ctx?.get === 'function' ? ctx.get('connection') : undefined
13
+ rejection = connection?.requestRejection?.(req)
14
+ } catch {
15
+ // 闸门自身异常不放行:宁可拒绝,不把故障当认证通过
16
+ rejection = 403
17
+ }
18
+ if (rejection === undefined) return false
19
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
20
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
21
+ return true
22
+ }
23
+
24
+ export function withHostGate(ctx, handler) {
25
+ return async (req, res) => {
26
+ if (rejectUnadmitted(ctx, req, res)) return
27
+ await handler(req, res)
28
+ }
29
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 写路由守卫(与全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
3
+ * 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
4
+ *
5
+ * 两条放行口径与 dsh-session-manager 一致:
6
+ * - 无 Origin 的非浏览器调用(本地 CLI/脚本)放行
7
+ * - 完全不带 content-type 的调用放行
8
+ */
9
+
10
+ export function rejectCrossOrigin(req, res, sendJson) {
11
+ const origin = req.headers ? req.headers.origin : undefined
12
+ if (!origin) return false
13
+ let sameOrigin = false
14
+ try {
15
+ const host = req.headers.host
16
+ sameOrigin = typeof host === 'string' && host.length > 0 && new URL(origin).host === host.toLowerCase()
17
+ } catch {
18
+ sameOrigin = false
19
+ }
20
+ if (sameOrigin) return false
21
+ sendJson(res, 403, { ok: false, error: '跨源请求被拒绝' })
22
+ return true
23
+ }
24
+
25
+ export function rejectNonJson(req, res, sendJson) {
26
+ const type = req.headers ? req.headers['content-type'] : undefined
27
+ if (typeof type !== 'string' || type.trim() === '') return false
28
+ if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
29
+ sendJson(res, 415, { ok: false, error: '仅接受 application/json 请求体' })
30
+ return true
31
+ }
32
+
33
+ export function guardWrite(req, res, sendJson) {
34
+ return rejectCrossOrigin(req, res, sendJson) || rejectNonJson(req, res, sendJson)
35
+ }