@zoria-lind/dsh-ecolink-adapter 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zoria Lind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # dsh-ecolink-adapter
2
+
3
+ dsh-ecolink 的 DSH 适配层(v1):直读本地记忆池(`~/.dsh-memory/memory.json`,只读),
4
+ 经 `agent/pre-step` 维持 DSM:memory_write 指令通道(E1),提供 `/ecolink-push` 显式写回(E4)
5
+ 与记忆 skill 按需读取(E2),打通"网页端记住 → DSH 直接用;DSH 产出 → 网页端可用"。
6
+
7
+ > **v0.1.1 变更摘要(E0–E8)**:离线队列并发丢条目修复(E0,extension/core/queue.mjs);
8
+ > 指令/内容通道分离——指令每轮幂等注入(plugin 快照消息,不改写用户消息、不碰前缀缓存),
9
+ > 内容默认不注入、由 skill 按需 read,`contentInjectionEnabled` 为逃生舱(E1);
10
+ > 记忆 skill `ecolink-memory` 运行时注册(E2);服务端快照/hash/diff 三端点(E3);
11
+ > `/ecolink-push` 写回(复用 /memory/sync,source:'dsh',service 唯一写者)(E4);
12
+ > 建议确认队列(sync 进队列、确认才入池;未确认条目不进 DSH 上下文)(E5);
13
+ > 网页端只读面板 + chrome.alarms badge(E6);静音 + 黑名单(E7);服务默认安全(E8)。
14
+ >
15
+ > **v1.1(2026-09-14)**:service 自动拉起——探测到 17520 未监听就本机 spawn 拉起
16
+ > (apply + pre-step 60s 节流;仅本地 URL;`serviceAutoStart` 默认开)。E5 设计纠正:
17
+ > 确认点改到 DSH 侧(待实现),随仓 config.json 设 `autoConfirm: true`,网页收割直入池。
18
+
19
+ ## 安装(link 挂载,与 token-optimizer 同款)
20
+
21
+ ```bash
22
+ dsh plugin --profile web add D:/dsh/dsh-plugins/dsh-ecolink/adapter
23
+ ```
24
+
25
+ ## 机制
26
+
27
+ - **只读池文件**:mtime 缓存(文件变化立即重读,TTL 5s 兜底);唯一写者是 ecolink-service
28
+ - **指令通道(E1,默认开)**:每轮 pre-step 末尾追加一条 `source.kind='plugin'` 的
29
+ `[dsh-ecolink 指令]` 快照消息(照抄内核 dsh-time-context 形态:prepend 注册 + await next +
30
+ createUserMessage;决策里的 messages 只喂本步请求、不写会话事件)。剥旧追加 → 每轮幂等不叠加
31
+ - **内容通道(E1,默认关)**:`contentInjectionEnabled: true` 恢复旧行为——改写用户消息注入
32
+ 记忆块(2-gram 打分 + 时间衰减 + 预算 + 长提示词缩减 + singleInjection 会话去重)
33
+ - **`/ecolink-push`(E4)**:斜杠命令,把指定要点 POST 到 service 的 `/memory/sync`
34
+ (source:'dsh');token 未配置时自动读同仓 `service/config.json`;service 是唯一写者
35
+ - **记忆 skill(E2)**:注册 `ecolink-memory`,指引模型用 read 按需读池原文(引用前必须核对)
36
+ - **service 自动拉起(v1.1)**:apply 时与每次 pre-step(60s 节流)探测 `serviceUrl/memory/status`;
37
+ 任何 HTTP 响应(含 401)= 已活;网络错误 = 未运行 → spawn 同仓 `service/server.mjs`
38
+ (detached + 无窗口)。仅对 127.0.0.1/localhost/::1 生效;service 目录缺失(独立发布包)静默跳过。
39
+ 与登录自启任务兼容:端口已监听就不会重复起
40
+ - **fail-open**:池不存在/读失败/任何异常 → 静默放行,绝不破坏请求
41
+ - 只处理 `source.kind='user'` 的真实用户消息(DSH runtime-context 快照直接放行)
42
+
43
+ ## 配置(cordis.patch.yml)
44
+
45
+ ```yaml
46
+ adapter:
47
+ enabled: true
48
+ poolPath: '~/.dsh-memory/memory.json'
49
+ poolTtlMs: 5000
50
+ injectEnabled: true # 总开关(false = 两个通道全关)
51
+ singleInjection: true # 内容通道专用(指令通道天然每轮幂等)
52
+ maxInjectionChars: 3000 # 内容通道注入预算
53
+ instructionEnabled: true # E1 指令通道
54
+ contentInjectionEnabled: false # E1 内容逃生舱
55
+ skillName: 'ecolink-memory' # E2 skill 注册名
56
+ serviceUrl: 'http://127.0.0.1:17520' # E4 写回目标
57
+ serviceToken: '' # E4;留空自动读同仓 service/config.json(E8 自动生成)
58
+ serviceAutoStart: true # v1.1;探测到服务未运行就自动拉起(仅本地 URL 生效)
59
+ ```
60
+
61
+ ## 测试
62
+
63
+ ```bash
64
+ node adapter/test/smoke.mjs # 66 项,零 API
65
+ ```
66
+
67
+ ## 已知限制
68
+
69
+ - 会话过滤靠 `#记忆名` 显式调用;DSH CLI 的 `--resume <session>` 只接受会话 id,
70
+ "按命名会话自动绑定"待 DSH 提供 `--session` 能力后再做
71
+ - 与 token-optimizer / behavior-enhancer 的联动开关(两插件中 `memory_bridge` 占位节)
72
+ 待适配层稳定后启用
73
+ - `inject.js` 仍是经典脚本,INSTRUCTION_PROMPT/BLOCK_RE 等与 core/*.mjs 镜像维护
74
+ (注释锁死);彻底消除镜像需要构建期注入(P5/0D 结论)
75
+ - adapter npm 发布口径未解决:`src/modules/memoryInject.js` 跨目录 import `extension/core/*`,
76
+ npm `files` 无法引用包根之外路径(E8#5 有据缓办;Junction 挂载不受影响)
@@ -0,0 +1,19 @@
1
+ # dsh-ecolink-adapter bundle layer:挂载适配层。
2
+ - insert:
3
+ - id: ecolink-adapter
4
+ name: 'dsh-ecolink-adapter'
5
+ config:
6
+ adapter:
7
+ enabled: true
8
+ poolPath: '~/.dsh-memory/memory.json'
9
+ poolTtlMs: 5000
10
+ injectEnabled: true
11
+ singleInjection: true
12
+ maxInjectionChars: 3000
13
+ # E1 通道分离:指令每轮幂等注入;内容默认不注入(按需 read,见 skill)
14
+ instructionEnabled: true
15
+ contentInjectionEnabled: false # 逃生舱:true 恢复旧的"改写用户消息注入记忆块"行为
16
+ skillName: 'ecolink-memory' # E2 记忆 skill 注册名
17
+ # E4 写回:/ecolink-push 经 service 的 POST /memory/sync 落盘(唯一写者)
18
+ serviceUrl: 'http://127.0.0.1:17520'
19
+ serviceToken: '' # 留空自动读同仓 service/config.json 的 token(E8 自动生成)
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@zoria-lind/dsh-ecolink-adapter",
3
+ "version": "0.1.1",
4
+ "description": "dsh-ecolink DSH 适配层:直读本地记忆池、pre-step 注入(打分+预算+会话去重)、#记忆名 过滤,打通网页端记忆生态",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "cordis.patch.yml",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node test/smoke.mjs"
19
+ },
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ }
24
+ },
25
+ "keywords": [
26
+ "dsh",
27
+ "memory",
28
+ "ecolink",
29
+ "deepseek",
30
+ "dsh-plugin",
31
+ "deepseek-harness"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Zoria-Lind/dsh-ecolink.git",
36
+ "directory": "adapter"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ }
41
+ }
package/src/config.js ADDED
@@ -0,0 +1,49 @@
1
+ // dsh-ecolink-adapter 配置。与 token-optimizer / behavior-enhancer 同款 resolveSection 模式。
2
+
3
+ export const DEFAULT_CONFIG = {
4
+ adapter: {
5
+ enabled: true,
6
+ poolPath: '~/.dsh-memory/memory.json', // 只读;唯一写者是 ecolink-service
7
+ poolTtlMs: 5000, // 池文件缓存(mtime 变化立即重读,否则 TTL 兜底)
8
+ injectEnabled: true, // 总开关(false = 两个通道全关)
9
+ singleInjection: true, // 内容通道专用:每 DSH 会话只注入一次(指令通道天然每轮幂等,不适用)
10
+ maxInjectionChars: 3000, // 内容通道注入预算(长提示词自动缩减,与网页端同规则)
11
+ // E1 通道分离:指令每轮幂等注入(维持 DSM:memory_write 捕获);内容默认不再注入
12
+ instructionEnabled: true, // 指令通道(pre-step 追加 plugin 快照消息)
13
+ contentInjectionEnabled: false, // 内容通道逃生舱(旧行为:改写用户消息注入记忆块)
14
+ skillName: 'ecolink-memory', // E2 记忆 skill 的注册名(kebab-case)
15
+ // E4 写回:POST /memory/sync 到本机 service(service 是唯一写者,DSH 只发请求)
16
+ serviceUrl: 'http://127.0.0.1:17520',
17
+ serviceToken: '', // 留空则自动读同仓 service/config.json 的 token(E8 自动生成)
18
+ serviceAutoStart: true, // v1.1:探测到 service 未运行就自动拉起(仅本地 URL;见 serviceGuard.js)
19
+ },
20
+ }
21
+
22
+ const NUMERIC_KEYS = new Set(['poolTtlMs', 'maxInjectionChars'])
23
+
24
+ function resolveSection(section, defaults) {
25
+ const out = { ...defaults }
26
+ if (section && typeof section === 'object') {
27
+ for (const [key, value] of Object.entries(section)) {
28
+ if (!(key in defaults)) {
29
+ throw new Error(`dsh-ecolink-adapter config: unknown key "${key}" (allowed: ${Object.keys(defaults).join(', ')})`)
30
+ }
31
+ if (NUMERIC_KEYS.has(key)) {
32
+ if (!Number.isFinite(value) || value <= 0) {
33
+ throw new Error(`dsh-ecolink-adapter config: ${key} (${value}) must be a positive number`)
34
+ }
35
+ } else if (typeof value !== typeof defaults[key]) {
36
+ throw new Error(`dsh-ecolink-adapter config: "${key}" must be ${typeof defaults[key]}`)
37
+ }
38
+ out[key] = value
39
+ }
40
+ }
41
+ return Object.freeze(out)
42
+ }
43
+
44
+ export function resolveConfig(config = {}) {
45
+ if (typeof config !== 'object' || config === null) config = {}
46
+ return Object.freeze({
47
+ adapter: resolveSection(config.adapter, DEFAULT_CONFIG.adapter),
48
+ })
49
+ }
package/src/index.js ADDED
@@ -0,0 +1,77 @@
1
+ // dsh-ecolink-adapter(E1/E2):dsh-ecolink 的 DSH 适配层。
2
+ // 职责(PLAN §6/§8):直读 memory.json(只读,唯一写者是 ecolink-service),
3
+ // pre-step 指令通道(每轮幂等追加 plugin 快照消息,E1)+ 可选内容注入逃生舱 +
4
+ // 记忆 skill 运行时注册(E2:内容按需 read),与网页端记忆生态打通。
5
+ // 与 token-optimizer / behavior-enhancer 的联动开关(memory_bridge 配置节)已
6
+ // 在两者中占位,等适配层稳定后在各自 README 说明如何开启。
7
+
8
+ import { DEFAULT_CONFIG, resolveConfig } from './config.js'
9
+ import { createMemoryInjectModule } from './modules/memoryInject.js'
10
+ import { createMemoryWriteModule } from './modules/memoryWrite.js'
11
+ import { createServiceGuardModule } from './modules/serviceGuard.js'
12
+ import { createMemoryDiffModule } from './modules/memoryDiff.js'
13
+
14
+ // E2 记忆 skill 正文:指引模型按需 read 记忆池(内容不再自动注入,这里是读取入口)。
15
+ // 引用细节前必须 read 原文核对(沿用 token-optimizer 的堵漏原则)。
16
+ const SKILL_CONTENT = `# dsh-ecolink 跨端记忆池
17
+
18
+ 本机记忆池文件:\`~/.dsh-memory/memory.json\`(归档:\`~/.dsh-memory/archive.json\`)。
19
+ 网页端(chat.deepseek.com)与 DSH 共用这一份结构化池;网页端记过的事实在这里能查到。
20
+
21
+ ## 文件结构
22
+ - 顶层:{ version, shared_pool: [...], session_pools: { <网页会话id>: { identity, memories: [...] } } }
23
+ - 条目字段:id / key / content / timestamp(UTC ISO) / time_precision / source(web|dsm-import|dsh) / importance(always|called) / pinned / last_accessed
24
+ - shared_pool 是跨会话共享记忆;session_pools 按网页会话分组,identity 是该会话的人类可读名
25
+
26
+ ## 何时读
27
+ - 用户提到"我在网页端记过 / 之前说过 / 我的偏好"等跨端记忆问题时
28
+ - 需要回忆历史偏好、项目约定、个人事实,而当前上下文没有答案时
29
+
30
+ ## 使用规则
31
+ - 先运行 /ecolink-diff 命令获取记忆池自上次查看以来的新增/变化(本会话或重启后首次调用会返回全量)——多数问题只靠这个结果就能答
32
+ - 禁止在未查询记忆池的情况下回答"不知道/不记得/没有记录":记忆池与本对话上下文是两回事,池里有而上下文没有是常态
33
+ - 需要核对细节或完整历史时,用 read 工具读取 memory.json 原文;引用任何记忆细节前必须先读到原文,禁止凭印象回答
34
+ - timestamp 是 UTC 存储值(例:2026-09-14T10:24Z = 本地 18:24,本机时区 UTC+8)。展示给用户时**必须先转本地时间**,再按龄降精度:2 小时内→分钟,48 小时内→小时,5 天内→日期,更早不显示时间;禁止直接把 UTC 原值当本地时间展示
35
+ - 记忆可能过时:时间戳较旧或与当前对话矛盾时,先向用户复述该记忆并确认,不要当作当前事实直接使用
36
+ `
37
+
38
+ export function apply(ctx, config = {}) {
39
+ const resolved = resolveConfig(config)
40
+ const stats = { bump() {}, addSample() {}, dispose() {}, snapshot() { return {} } }
41
+ const modules = []
42
+ modules.push(createMemoryInjectModule(ctx, resolved.adapter, stats))
43
+ // E4:DSH 记忆写回(/ecolink-push 显式触发;service 是唯一写者)
44
+ modules.push(createMemoryWriteModule(ctx, resolved.adapter, stats))
45
+ // E9:记忆 diff 查看(/ecolink-diff;调用即读取事件,游标随调用推进,skill 优先走这条)
46
+ modules.push(createMemoryDiffModule(ctx, resolved.adapter, stats))
47
+ // v1.1:service 未运行时自动拉起(先探测后 spawn,已在跑就不会重复起)
48
+ modules.push(createServiceGuardModule(ctx, resolved.adapter, stats))
49
+
50
+ // E2:记忆 skill 运行时注册(register 返回 Cordis disposer,非 Promise;
51
+ // 同名重复注册是 first-wins + no-op disposer,重复挂载不会叠加)。
52
+ // inject 依赖缺失时本回调永不执行(插件其余部分不受影响)。
53
+ const skillDisposers = []
54
+ ctx.inject?.(['skills'], (skillCtx) => {
55
+ try {
56
+ skillDisposers.push(skillCtx.skills.register({
57
+ name: resolved.adapter.skillName,
58
+ description: '按需读取 dsh-ecolink 跨端记忆池(~/.dsh-memory/memory.json)',
59
+ whenToUse: '需要回忆网页端记过的偏好/事实/决策,或用户提到之前记过的内容时',
60
+ content: SKILL_CONTENT,
61
+ source: 'runtime',
62
+ }))
63
+ } catch (err) {
64
+ console.warn(`[dsh-ecolink-adapter] skill 注册失败(${err?.message ?? err})`)
65
+ }
66
+ })
67
+
68
+ return () => {
69
+ for (const cleanup of modules) cleanup()
70
+ for (const dispose of skillDisposers) {
71
+ try { dispose?.() } catch { /* noop */ }
72
+ }
73
+ stats.dispose()
74
+ }
75
+ }
76
+
77
+ export { DEFAULT_CONFIG, resolveConfig }
package/src/kernel.js ADDED
@@ -0,0 +1,25 @@
1
+ // 内核模块解析(不声明为 inject 依赖,运行时经 createRequire 借道)。
2
+ // 照抄仓库既有先例 dsh-token-optimizer/src/modules/toolTrim.js:42-58 的多锚点:
3
+ // 插件自身位置(link 挂载时走不到 profile 依赖桥)→ DSH_ROOT →
4
+ // DSH_HOME/.dsh 的 profiles/node_modules(CLI 安装器生成的符号链接桥)→ 全局 npm 目录。
5
+ // 全部失败返回 undefined,调用方降级禁用对应通道,绝不抛错。
6
+ import { createRequire } from 'node:module'
7
+ import { join } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+
10
+ export function resolveKernelModule(name) {
11
+ const anchors = []
12
+ anchors.push(import.meta.url) // 插件自身位置
13
+ if (process.env.DSH_ROOT) anchors.push(join(process.env.DSH_ROOT, 'package.json'))
14
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
15
+ anchors.push(join(dshHome, 'profiles', 'node_modules', 'noop.js'))
16
+ if (process.env.APPDATA) anchors.push(join(process.env.APPDATA, 'npm', 'node_modules', 'noop.js'))
17
+ for (const anchor of anchors) {
18
+ try {
19
+ const req = createRequire(anchor)
20
+ req.resolve(name)
21
+ return req(name)
22
+ } catch { /* 下一锚点 */ }
23
+ }
24
+ return undefined
25
+ }
@@ -0,0 +1,120 @@
1
+ // dsh-ecolink-adapter(E9):记忆 diff 查看模块(/ecolink-diff)。
2
+ // 动机:skill 读取路径的 token 成本——池变大后每次 read 全量都烧上下文。
3
+ // 语义:"自上次 /ecolink-diff 调用以来"的新增/变化条目——命令调用本身就是读取事件,
4
+ // 游标随调用推进,无需读检测;首次调用(或 DSH 重启后)返回全量。纯模块内状态,不落盘。
5
+ // 稳定性铁律:任何异常 → kind:'error' 文案,绝不抛出打断命令管线。
6
+
7
+ import { readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ const COMMAND_NAME = 'ecolink-diff'
12
+
13
+ function expandHome(dir) {
14
+ const d = String(dir ?? '')
15
+ if (d === '~' || d.startsWith('~/') || d.startsWith('~\\')) return join(homedir(), d.slice(2))
16
+ return d
17
+ }
18
+
19
+ function contentFp(text) { const s = String(text ?? ''); return s.length + ':' + s.slice(0, 40) + ':' + s.slice(-40) }
20
+ function entryKey(e) { return e?.id ?? 'c:' + contentFp(e?.content) }
21
+
22
+ export function createMemoryDiffModule(ctx, config, stats, deps = {}) {
23
+ if (!config?.enabled) return () => {}
24
+ if (!ctx || typeof ctx.inject !== 'function') return () => {}
25
+
26
+ const readFile = typeof deps.readFile === 'function' ? deps.readFile : ((p) => readFileSync(p, 'utf8'))
27
+ // 响应体按内容通道预算裁剪,避免超大 diff 直接灌满上下文
28
+ const cap = Number.isFinite(config.maxInjectionChars) ? Math.max(200, config.maxInjectionChars) : 3000
29
+
30
+ let lastSnapshot = null // Map(entryKey → content);null = 尚未建立基线(首次调用)
31
+
32
+ function flatten(pool) {
33
+ const out = []
34
+ for (const m of pool?.shared_pool ?? []) out.push(m)
35
+ for (const [sid, sp] of Object.entries(pool?.session_pools ?? {})) {
36
+ for (const m of sp?.memories ?? []) out.push({ ...m, _identity: sp?.identity })
37
+ }
38
+ // 最新在前:模型读列表时注意力集中在头部,新记忆垫底会被漏掉(2026-09-14 实测:
39
+ // "今晚要干嘛"的答案就在列表最后一条,模型复述时把它丢了)
40
+ out.sort((a, b) => String(b?.timestamp ?? '').localeCompare(String(a?.timestamp ?? '')))
41
+ return out
42
+ }
43
+
44
+ // 渲染降精度(与网页端 renderMemoryTimestamp 同规则):2h→分钟,48h→小时,5天→日期,更早不渲染
45
+ function renderTs(isoTs) {
46
+ const d = new Date(isoTs)
47
+ if (!Number.isFinite(d.getTime())) return null
48
+ const age = Math.max(0, Date.now() - d.getTime())
49
+ if (age > 5 * 86400e3) return null
50
+ const pad = (n) => String(n).padStart(2, '0')
51
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
52
+ if (age > 48 * 3600e3) return date
53
+ if (age > 2 * 3600e3) return `${date} ${pad(d.getHours())}:00`
54
+ return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
55
+ }
56
+
57
+ function computeDiff(entries) {
58
+ if (!lastSnapshot) {
59
+ lastSnapshot = new Map(entries.map((e) => [entryKey(e), e.content]))
60
+ return { first: true, items: entries }
61
+ }
62
+ const items = entries.filter((e) => !lastSnapshot.has(entryKey(e)) || lastSnapshot.get(entryKey(e)) !== e.content)
63
+ lastSnapshot = new Map(entries.map((e) => [entryKey(e), e.content]))
64
+ return { first: false, items }
65
+ }
66
+
67
+ function formatItems(items) {
68
+ const lines = []
69
+ let used = 0
70
+ for (const it of items) {
71
+ const content = String(it?.content ?? '')
72
+ const ts = it?.timestamp ? renderTs(it.timestamp) : null
73
+ const line = (ts ? `(${ts}) ` : '') + (it?._identity ? `[${it._identity}] ` : '') + (it?.key ? `(${it.key}) ` : '') + (content.length > 300 ? content.slice(0, 300) + '…' : content)
74
+ if (used + line.length + 2 > cap && lines.length > 0) {
75
+ lines.push(`…(共 ${items.length} 条,超出预算已截断;其余请 read 原文)`)
76
+ break
77
+ }
78
+ lines.push('- ' + line)
79
+ used += line.length + 2
80
+ }
81
+ return lines.join('\n')
82
+ }
83
+
84
+ const disposers = []
85
+ ctx.inject(['commands'], (cmdCtx) => {
86
+ try {
87
+ disposers.push(cmdCtx.commands.register({
88
+ name: COMMAND_NAME,
89
+ description: '查看 dsh-ecolink 记忆池自上次查看以来的新增/变化条目(首次调用返回全量)',
90
+ input: { hint: '无需输入,直接回车查看' },
91
+ handler: async (invocation) => {
92
+ try {
93
+ const pool = JSON.parse(readFile(expandHome(config.poolPath)))
94
+ const entries = flatten(pool)
95
+ const { first, items } = computeDiff(entries)
96
+ stats?.bump?.('memoryDiff.called', 1)
97
+ if (first) {
98
+ const body = formatItems(items)
99
+ return { kind: 'success', text: `记忆池共 ${entries.length} 条(首次调用,返回全量):\n${body || '(空池)'}` }
100
+ }
101
+ if (items.length === 0) {
102
+ return { kind: 'success', text: `自上次查看以来无变化(池共 ${entries.length} 条)。需要完整上下文请用 read 工具读 memory.json。` }
103
+ }
104
+ return { kind: 'success', text: `自上次查看以来新增/变化 ${items.length} 条:\n${formatItems(items)}` }
105
+ } catch (err) {
106
+ return { kind: 'error', text: `读取记忆池失败:${err?.message ?? err}(检查 poolPath 下是否存在 memory.json)` }
107
+ }
108
+ },
109
+ }))
110
+ } catch (err) {
111
+ console.warn(`[dsh-ecolink-adapter] /${COMMAND_NAME} 注册失败(${err?.message ?? err})`)
112
+ }
113
+ })
114
+
115
+ return () => {
116
+ for (const dispose of disposers) {
117
+ try { dispose?.() } catch { /* noop */ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,189 @@
1
+ // dsh-ecolink-adapter v1(E1 通道分离):记忆注入模块。
2
+ // 与网页端扩展同源的核心逻辑(复用 ../extension/core 的选择器/提示词纯函数):
3
+ // - 直读 memory.json(只读,mtime 缓存——唯一写者是 ecolink-service)
4
+ // - 【指令通道,默认开】agent/pre-step 每轮幂等追加一条 source.kind='plugin' 的
5
+ // 快照消息承载 [dsh-ecolink 指令] 块(照抄内核 dsh-time-context 的成熟做法:
6
+ // prepend 注册 + await next() + 末尾 append createUserMessage;决策里的 messages
7
+ // 只喂本步请求、不写入会话事件)。不改写用户消息 → 不碰前缀缓存。
8
+ // - 【内容通道,默认关(contentInjectionEnabled 逃生舱)】旧行为:改写用户消息注入
9
+ // 记忆块(打分 + 预算 + #记忆名 + singleInjection 会话去重)。
10
+ // - 块标记 [dsh-ecolink 记忆] / [dsh-ecolink 指令]:幂等剥离(DSH 每 step 重发
11
+ // inbox 同一消息,与网页端同款防残留逻辑)
12
+ // 稳定性铁律:任何异常 → 静默放行,不注入。
13
+ // 内核事实(09 §A 已核实):agent/* 事件载荷由 agentEvents() 自动 {...payload, agent} 融合
14
+ // → pre-step 载荷直接有 payload.agent(v0 的 agent/status 跟踪 hack 据此移除)。
15
+
16
+ import { existsSync, readFileSync, statSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { selectMemories, effectiveBudget, parseMemoryCommand } from '../../../extension/core/selector.mjs'
20
+ import { buildInjectedBlock, buildInstructionBlock, stripAllBlocks } from '../../../extension/core/prompt.mjs'
21
+ import { resolveKernelModule } from '../kernel.js'
22
+
23
+ const INSTRUCTION_SUMMARY = 'ecolink-instruction'
24
+
25
+ function expandHome(dir) {
26
+ if (typeof dir !== 'string' || dir.length === 0) return dir
27
+ if (dir === '~' || dir.startsWith('~/') || dir.startsWith('~\\')) return join(homedir(), dir.slice(2))
28
+ return dir
29
+ }
30
+
31
+ export function createMemoryInjectModule(ctx, config, stats, deps = {}) {
32
+ if (!config?.enabled) return () => {}
33
+ if (!ctx || typeof ctx.on !== 'function') return () => {}
34
+
35
+ const poolPath = expandHome(deps.poolPath ?? config.poolPath)
36
+
37
+ // createUserMessage:测试经 deps 注入假实现(显式 null = 强制禁用,模拟解析失败);
38
+ // 生产从 @deepseek-ai/dsh-llm 解析(失败 → 指令通道静默禁用,警告一次)
39
+ let createUserMessage = null
40
+ if (deps.createUserMessage === null) {
41
+ // 强制禁用路径(测试用)
42
+ } else if (typeof deps.createUserMessage === 'function') {
43
+ createUserMessage = deps.createUserMessage
44
+ } else {
45
+ const llm = resolveKernelModule('@deepseek-ai/dsh-llm')
46
+ if (llm && typeof llm.createUserMessage === 'function') {
47
+ createUserMessage = llm.createUserMessage
48
+ } else {
49
+ console.warn('[dsh-ecolink-adapter] 未能解析 @deepseek-ai/dsh-llm,指令通道禁用(记忆注入不受影响)')
50
+ }
51
+ }
52
+
53
+ // 池读取:mtime 缓存(TTL 兜底 + 文件变化立即重读);失败返回 null → 不注入
54
+ let poolCache = { mtimeMs: 0, at: 0, data: null }
55
+ function readPool() {
56
+ try {
57
+ if (!existsSync(poolPath)) return null
58
+ const st = statSync(poolPath)
59
+ const now = Date.now()
60
+ if (poolCache.data && st.mtimeMs === poolCache.mtimeMs && now - poolCache.at < config.poolTtlMs) {
61
+ return poolCache.data
62
+ }
63
+ const data = JSON.parse(readFileSync(poolPath, 'utf8'))
64
+ poolCache = { mtimeMs: st.mtimeMs, at: now, data }
65
+ return data
66
+ } catch {
67
+ return poolCache.data // 读失败:用旧缓存或 null
68
+ }
69
+ }
70
+
71
+ // 指令消息识别(幂等剥旧用:summary 唯一属于本插件的指令快照)
72
+ const isOwnInstruction = (m) => m?.source?.kind === 'plugin'
73
+ && m?.source?.plugin === 'ecolink-adapter'
74
+ && m?.source?.summary === INSTRUCTION_SUMMARY
75
+
76
+ // 指令快照消息(与内核 dsh-time-context 同款形状:form='snapshot' + sections)
77
+ // E9.1:指令块内追加"读路径"指引——旧版只教写不教读,模型不知道池可查,
78
+ // "你还记得吗"类问题零工具调用凭印象回答(2026-09-14 实测)。
79
+ // E9.2:从"建议"升级为"硬规则"——查池是回答记忆类问题的前提,查之前禁止
80
+ // 答"不知道/不记得"(模型常因"当前上下文没有"而诚实地说不知道,但池是另一回事)
81
+ const READ_HINT = '跨端记忆查询(强制规则):当用户询问或提及"之前记过什么/你还记得吗/我的偏好/日程安排/考试安排/习惯/计划"等任何可能与历史记忆有关的问题时,第一步必须先运行 /ecolink-diff 命令查询记忆池(本会话首次调用返回全量),需要细节时再用 read 工具读 ~/.dsh-memory/memory.json 核对。禁止在未查询记忆池的情况下回答"不知道/不记得/没有记录"——记忆池与本对话上下文是两回事,池里有而上下文没有是常态。'
82
+ function buildInstructionMessage() {
83
+ const text = buildInstructionBlock().replace('[/dsh-ecolink 指令]', READ_HINT + '\n[/dsh-ecolink 指令]')
84
+ return createUserMessage({
85
+ content: [{ type: 'text', text }],
86
+ source: { kind: 'plugin', plugin: 'ecolink-adapter', form: 'snapshot', summary: INSTRUCTION_SUMMARY, sections: [{ name: 'ecolink-instruction', text }] },
87
+ })
88
+ }
89
+
90
+ // 内容通道(E1 逃生舱):旧行为的按消息改写注入
91
+ function injectContentIntoMessages(messages) {
92
+ const sk = String(currentAgent?.session?.id ?? currentAgent?.id ?? 'default')
93
+ if (config.singleInjection && injectedSessions.has(sk)) return { messages, changed: false }
94
+
95
+ const pool = readPool()
96
+ if (!pool) return { messages, changed: false }
97
+ const shared = pool.shared_pool ?? []
98
+ const sessionPools = pool.session_pools ?? {}
99
+
100
+ const out = []
101
+ let changed = false
102
+ for (const message of messages) {
103
+ const srcKind = message?.source?.kind
104
+ if (srcKind !== undefined && srcKind !== 'user') { out.push(message); continue }
105
+ const content = message?.content
106
+ const text = typeof content === 'string' ? content
107
+ : Array.isArray(content) ? content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')
108
+ : ''
109
+ if (!text || text.trim().length === 0) { out.push(message); continue }
110
+
111
+ // #记忆名 显式调用 → 只注入该会话记忆;否则共享池 + 全部会话池
112
+ const cmd = parseMemoryCommand(text, sessionPools)
113
+ let items
114
+ let sessionName = null
115
+ if (cmd) {
116
+ const sp = sessionPools[cmd]
117
+ items = (sp?.memories ?? []).map((m) => ({ ...m, identity: sp?.identity }))
118
+ sessionName = sp?.identity
119
+ } else {
120
+ items = [
121
+ ...shared,
122
+ ...Object.entries(sessionPools).flatMap(([, sp]) => (sp?.memories ?? []).map((m) => ({ ...m, identity: sp?.identity }))),
123
+ ]
124
+ }
125
+ const budget = effectiveBudget(text, config.maxInjectionChars)
126
+ const { memories } = selectMemories(items, text, budget)
127
+ if (memories.length === 0) { out.push(message); continue }
128
+
129
+ const clean = stripAllBlocks(text)
130
+ const composed = buildInjectedBlock(memories, sessionName) + '\n\n' + clean
131
+ if (composed === text) { out.push(message); continue }
132
+ out.push({ ...message, content: typeof content === 'string' ? composed : [{ type: 'text', text: composed }] })
133
+ changed = true
134
+ stats?.bump('ecolinkAdapter.injected', 1)
135
+ }
136
+ if (changed) injectedSessions.add(sk)
137
+ return { messages: out, changed }
138
+ }
139
+
140
+ // 当前 agent(pre-step 载荷自带 agent,内核 agentEvents() 融合;保底用上次 running 状态)
141
+ let currentAgent
142
+ const onStatus = (payload) => {
143
+ if (payload?.status === 'running' && payload?.agent) currentAgent = payload.agent
144
+ }
145
+ ctx.on?.('agent/status', onStatus)
146
+
147
+ const injectedSessions = new Set()
148
+
149
+ const handler = async (payload, next) => {
150
+ const decision = await next()
151
+ try {
152
+ if (!decision || decision.kind !== 'enter') return decision
153
+ if (payload?.signal?.aborted) return decision
154
+ if (!config.injectEnabled) return decision
155
+ const messages = Array.isArray(decision.messages) ? decision.messages : []
156
+ if (messages.length === 0) return decision
157
+
158
+ currentAgent = payload?.agent ?? currentAgent
159
+ let out = messages
160
+ let changed = false
161
+
162
+ // ① 指令通道(默认开):剥旧指令快照 → 末尾追加新指令(每轮幂等,不改写用户消息)
163
+ if (config.instructionEnabled && createUserMessage) {
164
+ const kept = out.filter((m) => !isOwnInstruction(m))
165
+ out = [...kept, buildInstructionMessage()]
166
+ changed = true
167
+ stats?.bump('ecolinkAdapter.instructed', 1)
168
+ }
169
+
170
+ // ② 内容通道(默认关):改写用户消息注入记忆块
171
+ if (config.contentInjectionEnabled) {
172
+ const r = injectContentIntoMessages(out)
173
+ out = r.messages
174
+ changed = changed || r.changed
175
+ }
176
+
177
+ return changed ? { ...decision, messages: out } : decision
178
+ } catch (err) {
179
+ console.warn(`[dsh-ecolink-adapter] 注入失败,静默放行(${err?.message ?? err})`)
180
+ return decision
181
+ }
182
+ }
183
+
184
+ ctx.on('agent/pre-step', handler, { prepend: true })
185
+ return () => {
186
+ ctx.off('agent/pre-step', handler)
187
+ ctx.off?.('agent/status', onStatus)
188
+ }
189
+ }
@@ -0,0 +1,91 @@
1
+ // dsh-ecolink-adapter v1(E4):DSH 记忆写回模块。
2
+ // 原则:DSH 侧只发 HTTP 请求,写盘由 ecolink-service 完成(P7 唯一写者)。
3
+ // 端点复用既有 POST /memory/sync(07 F3 推荐①),条目带 source:'dsh',不新增端点。
4
+ // 触发方式:斜杠命令 /ecolink-push(P6 形状)——默认不自动推送,必须显式调用。
5
+ // token 来源:config.serviceToken 显式配置优先;为空时读取同仓 service/config.json
6
+ // (E8 默认安全:服务启动自动生成 token 并写回该文件;浏览器读不到本地文件,不破坏鉴权边界)。
7
+ // 稳定性铁律:任何异常 → 返回 kind:'error' 文案,绝不抛出打断命令管线。
8
+
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const COMMAND_NAME = 'ecolink-push'
14
+ // 同仓服务配置(仅当 serviceToken 未显式配置时读 token 字段;每次推送热读,容忍失败)
15
+ const SERVICE_CONFIG = join(dirname(fileURLToPath(import.meta.url)), '../../../service/config.json')
16
+
17
+ function resolveServiceToken(config) {
18
+ if (config.serviceToken) return config.serviceToken
19
+ try {
20
+ if (existsSync(SERVICE_CONFIG)) {
21
+ return String(JSON.parse(readFileSync(SERVICE_CONFIG, 'utf8'))?.token ?? '')
22
+ }
23
+ } catch { /* 读不到就不带头,由服务端 401 提示 */ }
24
+ return ''
25
+ }
26
+
27
+ export function createMemoryWriteModule(ctx, config, stats, deps = {}) {
28
+ if (!config?.enabled) return () => {}
29
+ if (!ctx || typeof ctx.inject !== 'function') return () => {}
30
+
31
+ const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : ((...a) => fetch(...a))
32
+
33
+ // POST /memory/sync:响应形状 { ok, added, replaced, archived } 是现存硬依赖(07 F1)
34
+ async function pushMemories(contents) {
35
+ const base = String(config.serviceUrl ?? 'http://127.0.0.1:17520').replace(/\/$/, '')
36
+ const headers = { 'Content-Type': 'application/json' }
37
+ const token = resolveServiceToken(config)
38
+ if (token) headers['X-Ecolink-Token'] = token
39
+ const resp = await fetchImpl(`${base}/memory/sync`, {
40
+ method: 'POST',
41
+ headers,
42
+ body: JSON.stringify({ memories: contents.map((content) => ({ content, importance: 'called', source: 'dsh' })) }),
43
+ })
44
+ if (!resp.ok) {
45
+ const detail = await resp.text().catch(() => '')
46
+ throw new Error(`ecolink-service HTTP ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`)
47
+ }
48
+ return resp.json()
49
+ }
50
+
51
+ const disposers = []
52
+ ctx.inject(['commands'], (cmdCtx) => {
53
+ try {
54
+ disposers.push(cmdCtx.commands.register({
55
+ name: COMMAND_NAME,
56
+ description: '把指定要点推送到 dsh-ecolink 记忆池(网页端与 DSH 共用)',
57
+ input: { hint: '要记住的要点(一行一条,可多条)' },
58
+ handler: async (invocation) => {
59
+ try {
60
+ const raw = String(invocation?.rawInput ?? '').trim()
61
+ if (!raw) {
62
+ return { kind: 'error', text: '用法:/ecolink-push <要点>。要推送的内容不能为空,一行一条可写多条。' }
63
+ }
64
+ const contents = raw.split('\n').map((s) => s.trim()).filter(Boolean)
65
+ const result = await pushMemories(contents)
66
+ stats?.bump?.('memoryWrite.pushed', contents.length)
67
+ if (result?.queued) {
68
+ return { kind: 'success', text: `已提交 ${result.queued} 条到建议确认队列,请在网页端 popup 确认后入池。` }
69
+ }
70
+ const added = result?.added ?? 0
71
+ const replaced = result?.replaced ?? 0
72
+ if (added + replaced === 0) {
73
+ return { kind: 'success', text: '服务端判定无新增(与现有记忆重复),未写入。' }
74
+ }
75
+ return { kind: 'success', text: `已写入记忆池:新增 ${added} 条,覆盖 ${replaced} 条(来源标记 dsh)。` }
76
+ } catch (err) {
77
+ return { kind: 'error', text: `推送失败:${err?.message ?? err}(service 未运行会由 adapter 下一轮自动拉起;仍失败请检查 token 是否一致)` }
78
+ }
79
+ },
80
+ }))
81
+ } catch (err) {
82
+ console.warn(`[dsh-ecolink-adapter] /${COMMAND_NAME} 注册失败(${err?.message ?? err})`)
83
+ }
84
+ })
85
+
86
+ return () => {
87
+ for (const dispose of disposers) {
88
+ try { dispose?.() } catch { /* noop */ }
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,117 @@
1
+ // dsh-ecolink-adapter v1.1(2026-09-14):ecolink-service 自动拉起模块。
2
+ // 痛点:service 是网页扩展与 DSH 共享记忆池的唯一写者,但它是独立 node 进程,
3
+ // 用户必须手动 `node service/server.mjs`。本模块让 DSH 启动时自动探测并拉起:
4
+ // - apply 时 + 每次 agent/pre-step(60s 节流)探测 `${serviceUrl}/memory/status`;
5
+ // - 任何 HTTP 响应(含 401)= 服务已活,绝不重复起;网络错误/超时 = 未运行 → 本机 spawn 拉起;
6
+ // - 仅对 127.0.0.1 / localhost / ::1 生效(远程 URL 绝不 spawn);
7
+ // - serviceAutoStart:false 或 server.mjs 不存在(独立发布包无 service 目录)时静默跳过;
8
+ // - 与登录自启任务(scripts/install-autostart.ps1)兼容:端口已监听 → 探测即活,不会双起。
9
+ // 稳定性铁律:全部 try/catch,任何异常最多一条 warn,绝不打断 DSH 生命周期。
10
+ // 测试:deps 注入 fetchImpl/spawnImpl/serverPath/now,见 test/smoke.mjs「服务自动拉起」节。
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import { existsSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ // 同仓 service 目录(与 memoryWrite 的 SERVICE_CONFIG 同源;独立发布包无此目录 → 跳过拉起)。
18
+ // 两种布局都找:开发挂载(adapter 上一级的 service/)+ 独立发布包(adapter 内 service/,通常不存在)
19
+ const HERE = dirname(fileURLToPath(import.meta.url))
20
+ export const SERVICE_SERVER_CANDIDATES = [
21
+ join(HERE, '../../../service/server.mjs'),
22
+ join(HERE, '../../service/server.mjs'),
23
+ ]
24
+
25
+ export function isLocalServiceUrl(url) {
26
+ try {
27
+ return ['127.0.0.1', 'localhost', '::1'].includes(new URL(String(url)).hostname)
28
+ } catch { return false }
29
+ }
30
+
31
+ export function shouldAutoStart(config) {
32
+ return Boolean(config?.enabled && config.serviceAutoStart && isLocalServiceUrl(config.serviceUrl))
33
+ }
34
+
35
+ export function createServiceGuardModule(ctx, config, stats, deps = {}) {
36
+ if (!shouldAutoStart(config)) return () => {}
37
+ if (!ctx || typeof ctx.on !== 'function') return () => {}
38
+
39
+ const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : ((...a) => fetch(...a))
40
+ const spawnImpl = typeof deps.spawnImpl === 'function' ? deps.spawnImpl : ((...a) => spawn(...a))
41
+ const serverPath = typeof deps.serverPath === 'string' ? deps.serverPath : (SERVICE_SERVER_CANDIDATES.find((p) => existsSync(p)) ?? SERVICE_SERVER_CANDIDATES[0])
42
+ const nowFn = typeof deps.now === 'function' ? deps.now : (() => Date.now())
43
+ const base = String(config.serviceUrl ?? 'http://127.0.0.1:17520').replace(/\/$/, '')
44
+
45
+ const THROTTLE_MS = 60_000
46
+ const READY_POLL_MS = 3_000
47
+ let inFlight = null
48
+ let lastCheck = 0
49
+ let warnedMissing = false
50
+
51
+ async function isAlive() {
52
+ try {
53
+ await fetchImpl(`${base}/memory/status`, { signal: AbortSignal.timeout(2000) })
54
+ return true // 任何 HTTP 响应(含 401)= 服务已活
55
+ } catch { return false } // 网络错误/超时 = 未运行
56
+ }
57
+
58
+ async function ensureService(reason) {
59
+ if (inFlight) return inFlight
60
+ inFlight = (async () => {
61
+ try {
62
+ if (await isAlive()) return 'already-running'
63
+ if (!existsSync(serverPath)) {
64
+ if (!warnedMissing) {
65
+ warnedMissing = true
66
+ console.warn(`[dsh-ecolink-adapter] 自动拉起跳过:${serverPath} 不存在(独立发布包无 service 目录,请手动启动服务)`)
67
+ }
68
+ return 'skipped'
69
+ }
70
+ const child = spawnImpl(process.execPath, [serverPath], {
71
+ detached: true,
72
+ stdio: 'ignore',
73
+ windowsHide: true,
74
+ })
75
+ child?.unref?.()
76
+ stats?.bump?.('serviceGuard.started')
77
+ // 有界就绪等待(不阻塞 DSH;未就绪也不重试,由网页端离线队列兜底)
78
+ const deadline = Date.now() + READY_POLL_MS
79
+ while (Date.now() < deadline) {
80
+ if (await isAlive()) {
81
+ console.log(`[dsh-ecolink-adapter] ecolink-service 未运行,已自动启动 (pid ${child?.pid ?? '?'}) [${reason}]`)
82
+ return 'started'
83
+ }
84
+ await new Promise((r) => setTimeout(r, 100))
85
+ }
86
+ console.warn(`[dsh-ecolink-adapter] ecolink-service 已启动但 ${READY_POLL_MS / 1000}s 内未就绪 (pid ${child?.pid ?? '?'})`)
87
+ return 'started-slow'
88
+ } catch (err) {
89
+ console.warn(`[dsh-ecolink-adapter] 自动拉起失败:${err?.message ?? err}`)
90
+ return 'failed'
91
+ } finally {
92
+ inFlight = null
93
+ }
94
+ })()
95
+ return inFlight
96
+ }
97
+
98
+ // apply 时探测一次(异步 fire-and-forget,不阻塞插件装配)
99
+ ensureService('apply').catch(() => {})
100
+
101
+ // pre-step 节流探测:服务中途被手动停止 → 下一轮请求前自动补起。
102
+ // ⚠ agent/pre-step 是 cordis waterfall:不调 next() 会截断整条链,决策变 undefined,
103
+ // 内核读 decision.kind 直接 TypeError(2026-09-14 全天 DSH 崩溃根因)。
104
+ // 本监听器必须原样转发 next() 结果,探测本身 fire-and-forget。
105
+ ctx.on('agent/pre-step', (_payload, next) => {
106
+ const now = nowFn()
107
+ if (now - lastCheck < THROTTLE_MS) return next()
108
+ lastCheck = now
109
+ ensureService('pre-step').catch(() => {})
110
+ return next()
111
+ })
112
+
113
+ // 返回 disposer 供 apply 统一清理;测试经 disposer.ensureService 显式等待
114
+ const dispose = () => {}
115
+ dispose.ensureService = ensureService
116
+ return dispose
117
+ }