@nightpainters/dsh-ling 1.5.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.
Files changed (51) hide show
  1. package/ACCESS-DESIGN.md +119 -0
  2. package/CHANGELOG.md +627 -0
  3. package/DESIGN.md +461 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +7 -0
  6. package/README.md +236 -0
  7. package/cordis.patch.yml +16 -0
  8. package/lib/audit/access-log.js +533 -0
  9. package/lib/audit/index.js +92 -0
  10. package/lib/audit/patch-spec.js +195 -0
  11. package/lib/client.js +5069 -0
  12. package/lib/host/api.js +2584 -0
  13. package/lib/host/archive.js +194 -0
  14. package/lib/host/autotree.js +131 -0
  15. package/lib/host/backfill.js +267 -0
  16. package/lib/host/classify.js +10 -0
  17. package/lib/host/clock-skill.js +141 -0
  18. package/lib/host/clock.js +132 -0
  19. package/lib/host/corpus-suggest.js +139 -0
  20. package/lib/host/deepsummary.js +338 -0
  21. package/lib/host/dsweb-summary.js +225 -0
  22. package/lib/host/feedback.js +119 -0
  23. package/lib/host/freeze.js +144 -0
  24. package/lib/host/genesis.js +176 -0
  25. package/lib/host/guard.js +184 -0
  26. package/lib/host/habit-gen.js +273 -0
  27. package/lib/host/import-file.js +291 -0
  28. package/lib/host/import-log.js +129 -0
  29. package/lib/host/inject-common.js +25 -0
  30. package/lib/host/inject.js +285 -0
  31. package/lib/host/l1.js +282 -0
  32. package/lib/host/lifecycle.js +367 -0
  33. package/lib/host/memory.js +2389 -0
  34. package/lib/host/mode.js +101 -0
  35. package/lib/host/persona.js +214 -0
  36. package/lib/host/retitle.js +214 -0
  37. package/lib/host/rules.js +195 -0
  38. package/lib/host/scan-dsweb.js +111 -0
  39. package/lib/host/seal.js +17 -0
  40. package/lib/host/settings-file.js +49 -0
  41. package/lib/host/snapshot.js +168 -0
  42. package/lib/host/suggestions.js +47 -0
  43. package/lib/host/summarizer.js +103 -0
  44. package/lib/host/tone-advice.js +112 -0
  45. package/lib/host/tools.js +1067 -0
  46. package/lib/host/tree-ops.js +330 -0
  47. package/lib/host/util.js +165 -0
  48. package/lib/host/zstd.js +63 -0
  49. package/lib/index.js +263 -0
  50. package/package.json +69 -0
  51. package/tools/apply-access-log-patch.mjs +295 -0
package/lib/index.js ADDED
@@ -0,0 +1,263 @@
1
+ // dsh-ling host entry (cordis plugin): name/inject/apply named exports.
2
+ // Skeleton wiring: settings file, sqlite memory, freeze gate, system-prompt
3
+ // injection, lifecycle listeners, loopback HTTP gateway.
4
+ import { join } from 'node:path';
5
+ import { readFileSync, existsSync } from 'node:fs';
6
+ import { svc, pick, ensureDir, defaultDataDir, utcIso, dshHome } from './host/util.js';
7
+ import { SettingsFile } from './host/settings-file.js';
8
+ import { MemoryStore } from './host/memory.js';
9
+ import { FreezeGate } from './host/freeze.js';
10
+ import { registerMemorySection } from './host/inject.js';
11
+ import { wireLifecycle } from './host/lifecycle.js';
12
+ import { registerApi } from './host/api.js';
13
+ import { summarizeDsh } from './host/summarizer.js';
14
+ import { ingestFeedbackEntries, loadSeen, entriesFromFeedbackFile } from './host/feedback.js';
15
+ import { probeLLM, runDeepPass, DEEP_CFG } from './host/deepsummary.js';
16
+ import { registerLingTools } from './host/tools.js';
17
+ import { registerClockSkill } from './host/clock-skill.js';
18
+ import { installAccessLog } from './audit/index.js';
19
+
20
+ export const name = 'dsh-ling';
21
+ export const inject = [];
22
+
23
+ /** 版本号单一来源 = package.json(读不到就退回未知,不影响运行)。 */
24
+ function readVersion() {
25
+ try {
26
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
27
+ } catch {
28
+ return '0.0.0';
29
+ }
30
+ }
31
+
32
+ export function apply(ctx, config = {}) {
33
+ const cfg = { ...config, version: readVersion() };
34
+ const dataDir = cfg.dataDir || config.dataDir || defaultDataDir();
35
+ ensureDir(dataDir);
36
+
37
+ const settings = new SettingsFile(dataDir);
38
+ const memory = new MemoryStore(join(dataDir, 'memory.db'));
39
+ // invalidate 延迟绑定(避免环):onFlush 在事件时点才调用,彼时必已就绪
40
+ let invalidateNow = (sid) => {};
41
+ import('./host/inject.js').then((m) => {
42
+ invalidateNow = (sid) => m.invalidateSession(gate, memory, settings, sid);
43
+ }).catch((e) => console.debug('[dsh-ling] lazy inject import failed', e));
44
+ const gate = new FreezeGate({
45
+ onFlush: (sessionId, state) => {
46
+ // running→idle 边界:先执行已入队 op,再清 stale 快照(若有人格/记忆变更待重建)
47
+ if (state && state.snapStale && !state.running) {
48
+ try {
49
+ invalidateNow(sessionId);
50
+ } catch (e) {
51
+ console.debug('[dsh-ling] stale snapshot rebuild failed', e);
52
+ }
53
+ }
54
+ },
55
+ });
56
+
57
+ const disposers = [];
58
+ disposers.push(registerMemorySection(ctx, gate, memory, settings));
59
+ disposers.push(wireLifecycle(ctx, gate, memory, settings, cfg));
60
+ disposers.push(registerApi(ctx, { gate, memory, settings }, cfg));
61
+ // E4 可取证访问日志:只挂两个全局钩子,core 侧补丁留的调用点见 lib/audit/patch-spec.js。
62
+ // 补丁未打时钩子收不到调用 —— 安装器会在启动日志里明确喊出来,不允许静默不记。
63
+ disposers.push(installAccessLog(ctx, cfg.accessLog || {}));
64
+ // clock skill(精确时间按需查询,2026-09-23 用户定):取服务走与 tools 同款的三级兜底。
65
+ disposers.push(...registerClockSkill(ctx));
66
+ // 会话内工具(规矩直达 / 习惯提议):拿不到 tools 服务就退化为"只能用面板",绝不抛错。
67
+ // 取服务用三级兜底(与 webServer 同一套路):直取 → ctx.inject 子 ctx → 定时重试。
68
+ // 2026-09-16 教训:本插件 apply 可能早于 tools 服务挂载,一次性直取会静默失败 —— 工具从未注册。
69
+ let toolsMounted = false;
70
+ const mountTools = (c) => {
71
+ if (toolsMounted) return { ok: true };
72
+ try {
73
+ const tr = registerLingTools(c || ctx, { gate, memory, settings });
74
+ if (tr && tr.ok) {
75
+ toolsMounted = true;
76
+ if (Array.isArray(tr.disposers)) disposers.push(...tr.disposers);
77
+ console.info('[dsh-ling] tools registered: %d', tr.disposers?.length ?? 0);
78
+ }
79
+ return tr || { ok: false, reason: 'unknown' };
80
+ } catch (e) {
81
+ console.debug('[dsh-ling] tools registration failed', e);
82
+ return { ok: false, reason: 'throw' };
83
+ }
84
+ };
85
+ const firstMount = mountTools(ctx);
86
+ if (!firstMount.ok) {
87
+ try {
88
+ ctx.inject?.(['tools'], (child) => mountTools(child));
89
+ } catch (e) {
90
+ console.debug('[dsh-ling] ctx.inject tools failed', e);
91
+ }
92
+ let toolTries = 0;
93
+ const toolTimer = setInterval(() => {
94
+ toolTries += 1;
95
+ if (toolsMounted || toolTries >= 20) { clearInterval(toolTimer); return; }
96
+ mountTools(ctx);
97
+ }, 1500);
98
+ disposers.push(() => clearInterval(toolTimer));
99
+ console.info('[dsh-ling] tools service not ready at apply (%s); retrying', firstMount.reason);
100
+ }
101
+ // 器灵侧工具 · agent 作用域补注册(2026-09-25 修)。
102
+ //
103
+ // 症状:工具"注册成功"(kv tools.registered=1、disposers 非空)但 agent 的
104
+ // 工具清单里一个都没有 —— 连 09-16 就有的 rule_add 也不在。
105
+ //
106
+ // 根因(平台源码层):dsh-tools 的 `view(scope)` 对**全局层**工具逐个跑
107
+ // `layers.every(layer => layer.admits(name))`;`admits` 判的是该 scope 的
108
+ // `tools.restrict()` 掩码。Web 端按 agent preset 组工具(dsh-base
109
+ // cordis.patch.yml 注释:"The Web app ... composes both tools per agent
110
+ // preset"),preset 用 `restrict({ allow: [...] })` 白名单 ⇒ 我们注册在
111
+ // 全局层的那份**不在白名单里,被过滤掉**。
112
+ //
113
+ // 平台文档给了出路:`register` —— "Register globally or **in the calling
114
+ // agent scope**. Scoped tools shadow globals";`restrict` —— "Restrictions
115
+ // intersect; **scoped registrations remain visible**"。即 scope 内注册
116
+ // 不受 restrict 约束。参照实现:`@linxin666/dsh-tool-describe-image` 的
117
+ // `agent.ctx.tools.restrict(...)`(同样用 agent.ctx)。
118
+ //
119
+ // 故:每个 agent 创建时,在**它自己的** ctx 上再注册一份。两处注册落在
120
+ // 不同层(全局层 vs agent 层),平台允许多层共存、近者遮蔽,不冲突。
121
+ // 全局那份**保留**:没有 restrict 的部署仍走它,且它是 apply 期的早绑。
122
+ // ⚠️ 事件名跨 cohort(与 `lifecycle.js:82-99` **同一套口径**,不另发明):DSH **≥0.1.7**
123
+ // 叫 `agent/created`,**≤0.1.6** 叫 `agent/session-start`,载荷形状不变。此前这里只挂了
124
+ // 新名字 ⇒ 在 0.1.6 上本段永不触发 ⇒ **agent scope 的工具补注册根本不发生**(全局那份仍被
125
+ // preset 的 restrict 掩码挡掉,于是 agent 的工具清单里一个都没有)。两个名字都注册 +
126
+ // 3 秒去重:将来两者同存时同一个 agent 不会被注册两遍。
127
+ const agentToolSeen = new Map();
128
+ const onAgentCreated = (payload) => {
129
+ // 去重键取会话 id(取值路径与 lifecycle.js 的 dedupStart 逐字一致)
130
+ try {
131
+ const sid = pick(
132
+ () => payload?.agent?.session?.id,
133
+ () => payload?.session?.id,
134
+ () => payload?.sessionId,
135
+ () => payload?.agent?.sessionId,
136
+ );
137
+ const key = sid ? String(sid) : '';
138
+ const now = Date.now();
139
+ if (key && agentToolSeen.get(key) && now - agentToolSeen.get(key) < 3000) return; // 双事件同发时只注册一次
140
+ if (key) agentToolSeen.set(key, now);
141
+ } catch { /* 去重失败就照常执行,宁可多注册一次也不漏 */ }
142
+ try {
143
+ const agent = pick(() => payload?.agent, () => payload?.session?.agent, () => payload?.agentCtx);
144
+ const r = registerLingTools(agent?.ctx, { gate, memory, settings });
145
+ try {
146
+ memory.kvSet('tools.agent_registered', r?.ok ? '1' : ('0:' + String(r?.reason || '')));
147
+ } catch { /* ignore */ }
148
+ if (r?.ok) {
149
+ console.info('[dsh-ling] tools registered on agent scope: %d', r.disposers?.length ?? 0);
150
+ }
151
+ } catch (e) {
152
+ console.debug('[dsh-ling] agent-scope tools registration failed', e);
153
+ }
154
+ };
155
+ try {
156
+ disposers.push(ctx.on?.('agent/created', onAgentCreated)); // DSH ≥0.1.7
157
+ disposers.push(ctx.on?.('agent/session-start', onAgentCreated)); // DSH ≤0.1.6(旧名,保留兼容)
158
+ } catch (e) {
159
+ console.debug('[dsh-ling] agent scope tool hooks failed', e);
160
+ }
161
+
162
+ memory.kvSet('boot_at', utcIso());
163
+ memory.kvSet('inject_enabled_config', cfg.injectEnabled === false ? '0' : '1');
164
+
165
+ // DSH 会话概述器:启动后 8s 跑一次,之后每 summarizeIntervalMin 分钟自动跑
166
+ let sumTimer = null;
167
+ const runSummarizer = () => {
168
+ try {
169
+ const st = summarizeDsh(memory);
170
+ if (st.sessions > 0 || st.created + st.updated > 0) {
171
+ memory.kvSet('summarizer.last', JSON.stringify({ at: utcIso(), ...st }));
172
+ }
173
+ } catch (e) {
174
+ console.debug('[dsh-ling] summarizeDsh failed', e);
175
+ }
176
+ };
177
+ if (cfg.summarizeAuto !== false) {
178
+ sumTimer = setTimeout(() => {
179
+ runSummarizer();
180
+ const intervalMin = Math.max(1, Number(cfg.summarizeIntervalMin ?? 15));
181
+ sumTimer = setInterval(runSummarizer, intervalMin * 60 * 1000);
182
+ }, 8000);
183
+ }
184
+
185
+ // 反馈成长回路轮询(平台 messageFeedback.list 需会话身份参数 → 直接读 sidecar 文件)
186
+ let fbTimer = null;
187
+ const fbFile = join(dshHome(), 'storages', 'message_feedback.json');
188
+ const pollFeedback = async () => {
189
+ try {
190
+ if (!existsSync(fbFile)) return;
191
+ const fileObj = JSON.parse(readFileSync(fbFile, 'utf8'));
192
+ const entries = entriesFromFeedbackFile(fileObj);
193
+ if (!entries.length) return;
194
+ const seen = loadSeen(memory);
195
+ const res = ingestFeedbackEntries(memory, entries, { seen });
196
+ if (res.queued + res.boosted + res.seenAdded > 0) {
197
+ memory.kvSet('feedback.last', JSON.stringify({ at: utcIso(), ...res }));
198
+ }
199
+ } catch (e) {
200
+ console.debug('[dsh-ling] feedback poll failed', e);
201
+ }
202
+ };
203
+ if (cfg.feedbackAuto !== false) {
204
+ const pollSec = Math.max(20, Number(cfg.feedbackPollSec ?? 60));
205
+ fbTimer = setTimeout(() => {
206
+ pollFeedback();
207
+ fbTimer = setInterval(pollFeedback, pollSec * 1000);
208
+ }, 20000);
209
+ }
210
+
211
+ // LLM 深摘要:启动 12s 先自检探针;通过才挂自动调度(保守上限,可配)
212
+ let deepTimer = null;
213
+ const bootDeep = async () => {
214
+ try {
215
+ const p = await probeLLM(ctx, memory);
216
+ if (p.ok && cfg.deepSummaryAuto !== false) {
217
+ runDeepPass(ctx, memory, { limit: Number(cfg.deepMaxPerRun ?? DEEP_CFG.perRunLimit) });
218
+ const intervalMin = Math.max(10, Number(cfg.deepIntervalMin ?? DEEP_CFG.autoIntervalMin));
219
+ deepTimer = setInterval(() => {
220
+ runDeepPass(ctx, memory, { limit: Number(cfg.deepMaxPerRun ?? DEEP_CFG.perRunLimit) });
221
+ }, intervalMin * 60 * 1000);
222
+ }
223
+ } catch (e) {
224
+ console.debug('[dsh-ling] deep boot failed', e);
225
+ }
226
+ };
227
+ const deepBootTimer = setTimeout(bootDeep, 12000);
228
+
229
+ const dispose = () => {
230
+ if (sumTimer) {
231
+ clearTimeout(sumTimer);
232
+ clearInterval(sumTimer);
233
+ sumTimer = null;
234
+ }
235
+ if (fbTimer) {
236
+ clearTimeout(fbTimer);
237
+ clearInterval(fbTimer);
238
+ fbTimer = null;
239
+ }
240
+ if (deepTimer) {
241
+ clearInterval(deepTimer);
242
+ deepTimer = null;
243
+ }
244
+ if (deepBootTimer) {
245
+ clearTimeout(deepBootTimer);
246
+ }
247
+ for (const d of disposers) {
248
+ try {
249
+ d();
250
+ } catch {}
251
+ }
252
+ try {
253
+ memory.close();
254
+ } catch {}
255
+ };
256
+ if (typeof ctx.effect === 'function') {
257
+ ctx.effect(() => dispose, 'dsh-ling: lifecycle');
258
+ }
259
+ console.info('[dsh-ling] booted; dataDir=%s injection=%s api=%s',
260
+ dataDir,
261
+ typeof svc(ctx, 'systemPrompt')?.section === 'function' ? 'on' : 'off(unavailable)',
262
+ typeof svc(ctx, 'webServer')?.register === 'function' ? 'on' : 'off(unavailable)');
263
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@nightpainters/dsh-ling",
3
+ "version": "1.5.1",
4
+ "description": "器灵 · 跑在你自己电脑上的记忆与人格助手插件:三层记忆、从历史里长出来的人格档案、定型锁与自由生长",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "tools/apply-access-log-patch.mjs",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "CHANGELOG.md",
18
+ "DESIGN.md",
19
+ "ACCESS-DESIGN.md",
20
+ "LICENSE",
21
+ "NOTICE"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "node tests/unit.mjs && node tests/access-log.test.mjs && node tests/archive.test.mjs && node tests/autotree.test.mjs && node tests/backfill.test.mjs && node tests/branch.test.mjs && node tests/assistant.test.mjs && node tests/capture.test.mjs && node tests/clock.test.mjs && node tests/clock-skill.test.mjs && node tests/deep.test.mjs && node tests/dswebsum.test.mjs && node tests/export.test.mjs && node tests/feedback.test.mjs && node tests/forget-backfill.test.mjs && node tests/genesis.test.mjs && node tests/guard.test.mjs && node tests/habitgen.test.mjs && node tests/idle.test.mjs && node tests/importfile.test.mjs && node tests/importlog.test.mjs && node tests/l1lines.test.mjs && node tests/memories.test.mjs && node tests/mode.test.mjs && node tests/mustache.test.mjs && node tests/retitle.test.mjs && node tests/rules.test.mjs && node tests/suggest.test.mjs && node tests/summarize.test.mjs && node tests/times.test.mjs && node tests/tone.test.mjs && node tests/tools.test.mjs && node tests/tree.test.mjs && node tests/patch-spec.test.mjs",
28
+ "check": "node tools/check-install.mjs",
29
+ "check:publish": "node tools/check-publish.mjs"
30
+ },
31
+ "keywords": [
32
+ "dsh",
33
+ "deepseek",
34
+ "deepseek-harness",
35
+ "plugin",
36
+ "memory",
37
+ "persona",
38
+ "companion",
39
+ "local-first",
40
+ "器灵"
41
+ ],
42
+ "license": "Apache-2.0",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/NightPainters/DSH-Ling.git"
46
+ },
47
+ "homepage": "https://github.com/NightPainters/DSH-Ling#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/NightPainters/DSH-Ling/issues"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.5.0"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ },
58
+ "client": {
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-store",
61
+ "@deepseek-ai/dsh-client-connection",
62
+ "@deepseek-ai/dsh-client-locale",
63
+ "@deepseek-ai/dsh-client-ui-slots",
64
+ "@deepseek-ai/dsh-api-session-controller"
65
+ ],
66
+ "platform": "web"
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // E4 访问日志 · 核心补丁脚本(幂等 / 可回滚 / 可自检)
3
+ //
4
+ // 背景:`/api` 的 403/401 栅栏在 core 里(dsh-client-connection),插件层看不到 ——
5
+ // 而"谁在什么时候被拦下"恰恰是悬案最缺的那条证据(任务书 §3.4 首选落点)。
6
+ // 解决办法不是把逻辑搬进 core,而是让 core 多一行**可选调用**:
7
+ //
8
+ // globalThis.__dshAccessLogObserve?.(req, res); // E4 access log (dsh-ling patch)
9
+ //
10
+ // 实现全在 dsh-ling(lib/audit/),升级 DSH 后重跑本脚本一次即可。
11
+ //
12
+ // 用法:
13
+ // node tools/apply-access-log-patch.mjs # 应用(已是 applied 则跳过)
14
+ // node tools/apply-access-log-patch.mjs --check # **只读**自检(不写状态文件);未打/锚点漂移/复核不过时退出码 2
15
+ // node tools/apply-access-log-patch.mjs --revert # 回滚(删掉插进去的那几行)
16
+ // node tools/apply-access-log-patch.mjs --dsh-root "D:\\path\\to\\@deepseek-ai\\dsh"
17
+ //
18
+ // 退出码:0 = 就绪(applied / 已回滚);2 = **未打或锚点漂移**;1 = 其它问题(歧义、目标文件缺失、异常)。
19
+ //
20
+ // 纪律:锚点必须**唯一命中**才动手 —— 宁可不动,也不能在 core 里插错地方(内部踩坑记录 B3)。
21
+ // 目标可以带**多个候选锚点**(同一处 core 代码在不同 DSH cohort 里写法不同,如 dsh-api-gateway 的
22
+ // `/api/remote.mux` upgrade):逐个试,**恰好一个**候选唯一命中才可用;
23
+ // · 多个候选同时唯一命中 ⇒ **歧义**(同一份代码里两种 cohort 的写法都在)⇒ 拒绝改动并报错,不许猜;
24
+ // · 全都不命中 ⇒ **漂移**(drifted)⇒ 一个字也不改,退出码 2,等人工更新锚点。
25
+ // 判据是 lib/audit/patch-spec.js 的纯函数 `resolveAnchor()`(脚本与单测共用,免得两处判据各自漂移)。
26
+
27
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
28
+ import { createHash } from 'node:crypto';
29
+ import { join, dirname, sep } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+ import { fileURLToPath } from 'node:url';
32
+ import {
33
+ PATCH_TARGETS, PATCH_MARKER, PATCH_STATE_FILE, targetPath, resolveAnchor, verifyPatched, stripHookLines,
34
+ } from '../lib/audit/patch-spec.js';
35
+
36
+ const argv = process.argv.slice(2);
37
+ const has = (flag) => argv.includes(flag);
38
+ const argOf = (flag) => {
39
+ const i = argv.indexOf(flag);
40
+ return i >= 0 ? argv[i + 1] : undefined;
41
+ };
42
+
43
+ const MODE = has('--check') ? 'check' : has('--revert') ? 'revert' : 'apply';
44
+ const ORIG_SUFFIX = '.e4-access-log.orig';
45
+
46
+ function dshHome() {
47
+ return process.env.DSH_HOME || join(homedir(), '.dsh');
48
+ }
49
+
50
+ /** 找 DSH 包根:显式参数 → 环境变量 → 运行中的进程 argv → 全局 npm 目录。 */
51
+ function findDshRoot() {
52
+ const candidates = [];
53
+ const explicit = argOf('--dsh-root');
54
+ if (explicit) candidates.push(explicit);
55
+ if (process.env.DSH_PKG_ROOT) candidates.push(process.env.DSH_PKG_ROOT);
56
+ for (const a of process.argv) {
57
+ const m = String(a).match(/^(.*)[\\/]@deepseek-ai[\\/]dsh[\\/]lib[\\/]/);
58
+ if (m) candidates.push(join(m[1], '@deepseek-ai', 'dsh'));
59
+ }
60
+ if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', '@deepseek-ai', 'dsh'));
61
+ candidates.push(join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'node_modules', '@deepseek-ai', 'dsh'));
62
+ for (const c of candidates) {
63
+ try {
64
+ if (c && existsSync(join(c, 'package.json'))) return c;
65
+ } catch {}
66
+ }
67
+ throw new Error(`找不到 DSH 包根;请用 --dsh-root 指定(找过:${candidates.join(' | ')})`);
68
+ }
69
+
70
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
71
+ /** 逐候选的命中数摘要(人工核对时一眼看出"哪个 cohort 命中几次")。 */
72
+ const hitsDetail = (counts) => (counts || []).map((c) => `${c.cohort}×${c.hits}`).join(' · ');
73
+
74
+ /**
75
+ * 删标记行(回滚)的判据在 `lib/audit/patch-spec.js` 的 `stripHookLines()` 里 ——
76
+ * 与 `--check` 的复核(`isHookLine`)**共用同一个判据**,免得"什么算钩子行"两处各自漂移。
77
+ */
78
+
79
+ /** DSH 版本(F6②/E10:状态文件里记下**打补丁那一刻**的版本,升级后才有的比)。 */
80
+ function dshVersionOf(root) {
81
+ try {
82
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
83
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /** 读上一次的状态记录(只用于"版本变了"这类旁证提示,不影响判据)。 */
90
+ function readStateFile() {
91
+ try {
92
+ return JSON.parse(readFileSync(join(dshHome(), 'logs', PATCH_STATE_FILE), 'utf8'));
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ function processTarget(root, target) {
99
+ const file = targetPath(root, target);
100
+ if (!existsSync(file)) {
101
+ return { id: target.id, file, state: 'missing-file', note: '目标文件不存在(DSH 布局变了?)' };
102
+ }
103
+ const original = readFileSync(file, 'utf8');
104
+ const backup = `${file}${ORIG_SUFFIX}`;
105
+
106
+ // ---- C-09:判据改看**实体**(2026-09-26)--------------------------------------------
107
+ // 旧写法是 `original.includes(PATCH_MARKER)`("文件里出现过标记")⇒ **绿色短路**:标记行还在、
108
+ // 但插入点那几行已被改掉的文件照样报 applied / 退出 0。现在一律走 verifyPatched() 的三合一复核:
109
+ // ① 有钩子行 ② 锚点仍恰好唯一命中 ③ 钩子行正文 = 本站 cohort 期望的那行。
110
+ // 这三条同时被**插件启动提示**(lib/audit/index.js)复用 —— 单一真源,判据不分叉。
111
+ const verdict = verifyPatched(target, original);
112
+ const vdetail = hitsDetail(verdict.counts);
113
+ const hasHook = verdict.state !== 'not-applied';
114
+
115
+ if (hasHook) {
116
+ // 回滚:删钩子行(判据与 --check 共用 stripHookLines)。
117
+ if (MODE === 'revert') {
118
+ const stripped = stripHookLines(original);
119
+ writeFileSync(file, stripped, 'utf8');
120
+ const matchesBackup = existsSync(backup) ? readFileSync(backup, 'utf8') === stripped : undefined;
121
+ return {
122
+ id: target.id,
123
+ file,
124
+ state: 'reverted',
125
+ note: matchesBackup === undefined ? '(无 .orig 备份可比对)' : matchesBackup ? '与备份逐字节一致' : '⚠ 与备份不一致',
126
+ };
127
+ }
128
+ // 复核不过 ⇒ **一个字也不改**,更不许报 applied(那正是 C-09 的谎报)。退出码 2,等人工核对。
129
+ if (verdict.state !== 'applied') {
130
+ return {
131
+ id: target.id,
132
+ file,
133
+ state: verdict.state,
134
+ note:
135
+ `钩子行在,但复核不过(${verdict.state};${vdetail})⇒ 未改动` +
136
+ (verdict.expected ? `;期望钩子行: ${verdict.expected}` : '') +
137
+ (verdict.actual ? `;实际: ${verdict.actual}` : ''),
138
+ };
139
+ }
140
+ return {
141
+ id: target.id,
142
+ file,
143
+ state: 'applied',
144
+ cohort: verdict.cohort,
145
+ sha256: sha256(original),
146
+ note:
147
+ MODE === 'apply'
148
+ ? `已是 applied,跳过(复核通过:cohort ${verdict.cohort};${vdetail})`
149
+ : `复核通过:cohort ${verdict.cohort} 的锚点与钩子行都在原处(${vdetail})`,
150
+ };
151
+ }
152
+
153
+ // 未打补丁的文件没什么可回滚(与从前的语义一致:不回滚也不解析锚点)。
154
+ if (MODE === 'revert') return { id: target.id, file, state: 'not-applied' };
155
+
156
+ // 候选锚点解析:恰好一个唯一命中才可用;多个同时命中 = 歧义;全不命中 = 漂移。
157
+ const res = resolveAnchor(target, original);
158
+ const detail = hitsDetail(res.counts);
159
+
160
+ if (MODE === 'check') {
161
+ if (res.state === 'hit') {
162
+ return { id: target.id, file, state: 'not-applied', note: `可应用(候选 cohort ${res.cohort};${detail})` };
163
+ }
164
+ if (res.state === 'ambiguous') {
165
+ return { id: target.id, file, state: 'ambiguous', note: `歧义:${detail} —— 多个候选同时唯一命中,拒绝改动` };
166
+ }
167
+ return { id: target.id, file, state: 'drifted', note: `锚点未命中:${detail}` };
168
+ }
169
+
170
+ // apply
171
+ if (res.state === 'ambiguous') {
172
+ return { id: target.id, file, state: 'ambiguous', note: `歧义:${detail} —— 多个候选同时唯一命中 → 拒绝改动(不许猜)` };
173
+ }
174
+ if (res.state === 'drifted') {
175
+ return { id: target.id, file, state: 'drifted', note: `锚点全漂:${detail} → 未改动,请人工核对/更新锚点` };
176
+ }
177
+ const candidate = res.candidate;
178
+ const patched = original.replace(candidate.anchor, (...args) => {
179
+ const match = args[0];
180
+ const groups = args.slice(1, -2).map((g) => g ?? '');
181
+ const anchorIndent = groups[groups.length - 1] ?? '';
182
+ const lastLineIndent = (match.split(/\r?\n/).filter((l) => l.trim()).pop() || '').match(/^[ \t]*/)[0];
183
+ const delta = Number.isInteger(candidate.indentDelta) ? candidate.indentDelta : 1;
184
+ const insert = candidate.line((lastLineIndent || anchorIndent) + '\t'.repeat(delta));
185
+ return `${match}${insert}`;
186
+ });
187
+ if (patched === original) {
188
+ return { id: target.id, file, state: 'drifted', note: '替换未产生变化 → 未改动' };
189
+ }
190
+ if (!existsSync(backup)) copyFileSync(file, backup);
191
+ writeFileSync(file, patched, 'utf8');
192
+ return {
193
+ id: target.id,
194
+ file,
195
+ state: 'applied',
196
+ cohort: res.cohort,
197
+ note: `按候选 cohort ${res.cohort} 的锚点插入(${detail})`,
198
+ sha256: sha256(patched),
199
+ sha256Before: sha256(original),
200
+ sha256After: sha256(patched),
201
+ backup,
202
+ };
203
+ }
204
+
205
+ function writeState(root, results) {
206
+ // F6①:`--check` 是**只读自检** —— 不再写状态文件。
207
+ // 旧写法在 check 模式下也写盘:① 把上一次 apply 的记录覆盖成本次的 dshRoot(C-10/C-11),
208
+ // ② 让"在只读沙箱里自检"变成不可能,③ 与"检查"这个词的语义相反。判据侧不受影响:
209
+ // 插件启动提示现在直接读 core 实体(F8),不再依赖状态文件是否存在。
210
+ if (MODE === 'check') return null;
211
+ const dir = join(dshHome(), 'logs');
212
+ try {
213
+ mkdirSync(dir, { recursive: true });
214
+ const state = {
215
+ marker: PATCH_MARKER,
216
+ tool: 'dsh-ling/tools/apply-access-log-patch.mjs',
217
+ mode: MODE,
218
+ at: new Date().toISOString(),
219
+ dshRoot: root,
220
+ // F6②/E10:**打补丁那一刻的 DSH 版本** —— 插件启动时与当前版本比对,升级后才有得提示。
221
+ dshVersion: dshVersionOf(root),
222
+ node: process.version,
223
+ targets: results.map((r) => ({
224
+ id: r.id,
225
+ file: r.file,
226
+ state: r.state,
227
+ ...(r.cohort ? { cohort: r.cohort } : {}),
228
+ // F8③:打补丁后目标文件的 sha256 —— 文件被换掉(升级/重装)即可发现,不必等锚点失配。
229
+ ...(r.sha256 ? { sha256: r.sha256 } : {}),
230
+ })),
231
+ };
232
+ writeFileSync(join(dir, PATCH_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
233
+ return join(dir, PATCH_STATE_FILE);
234
+ } catch (err) {
235
+ return `(状态文件写入失败:${err.message})`;
236
+ }
237
+ }
238
+
239
+ /** 只有人工能处理的状态(锚点要更新 / 钩子行 cohort 错 / 重复打过)——退出码 2,与 --check 同义。 */
240
+ const HUMAN_STATES = new Set(['drifted', 'stale', 'cohort-mismatch', 'duplicated']);
241
+ /** 别的问题(同一份代码里两种 cohort 都在 / 目标文件不在)。 */
242
+ const OTHER_STATES = new Set(['ambiguous', 'missing-file']);
243
+
244
+ function main() {
245
+ const root = findDshRoot();
246
+ const version = dshVersionOf(root);
247
+ const prev = readStateFile();
248
+ const results = PATCH_TARGETS.map((t) => processTarget(root, t));
249
+ const stateFile = writeState(root, results);
250
+
251
+ console.log(`# E4 access log patch · ${MODE}`);
252
+ console.log(` DSH 包根: ${root}${version ? `(DSH ${version})` : ''}`);
253
+ // F6②/E10:版本变了 = 升级过 —— 而升级会覆盖 core(这正是 F8 那条"提示静默失灵"的现场)。
254
+ if (prev && prev.dshVersion && version && prev.dshVersion !== version) {
255
+ console.log(` ⚠ 上次打补丁时是 DSH ${prev.dshVersion},现在是 ${version} —— 升级会覆盖 core,须复核补丁`);
256
+ }
257
+ for (const t of PATCH_TARGETS) {
258
+ const r = results.find((x) => x.id === t.id);
259
+ console.log(` [${r.state}] ${t.label}`);
260
+ console.log(` ${r.file}`);
261
+ if (r.note) console.log(` ${r.note}`);
262
+ }
263
+ console.log(
264
+ stateFile
265
+ ? ` 状态文件: ${stateFile}`
266
+ : ` 状态文件: (--check 只读自检,本次不写盘;上一次的记录见 ${join(dshHome(), 'logs', PATCH_STATE_FILE)})`,
267
+ );
268
+
269
+ const bad = results.filter((r) => HUMAN_STATES.has(r.state) || OTHER_STATES.has(r.state));
270
+ if (MODE === 'check') {
271
+ const pending = results.filter((r) => r.state !== 'applied');
272
+ if (pending.length || bad.length) {
273
+ console.log(` ⚠ 未就绪: ${pending.map((r) => `${r.id}=${r.state}`).join(', ')}`);
274
+ process.exitCode = 2;
275
+ } else {
276
+ console.log(' ✅ 两条补丁均已应用');
277
+ }
278
+ return;
279
+ }
280
+ if (bad.length) {
281
+ // 需要人工处理(漂移/stale/cohort 错/重复)= 退出码 2(与 --check 的语义一致);歧义/缺文件 = 1。
282
+ const human = results.some((r) => HUMAN_STATES.has(r.state));
283
+ console.error(` ✖ 有目标未按预期处理:${bad.map((r) => `${r.id}=${r.state}`).join('、')}(未强行改动任何文件)`);
284
+ process.exitCode = human ? 2 : 1;
285
+ return;
286
+ }
287
+ console.log(` ℹ 改动 core 后需重启 dsh web 才生效(host 侧无热载,见 BACKLOG E1)`);
288
+ }
289
+
290
+ try {
291
+ main();
292
+ } catch (err) {
293
+ console.error(`✖ ${err.message}`);
294
+ process.exitCode = 1;
295
+ }