@akasha-book/sleep 1.7.2 → 1.10.22

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/lib/index.js CHANGED
@@ -1,519 +1,1064 @@
1
- // 阿卡夏之书睡眠器 v1.5.1 —— 调度与注入(蒸馏逻辑归核心库 sleep.mjs)。
2
- // · 触发:agent/status → idle(去抖 minIntervalHours)+ 定时兜底 ctx.interval(默认 1h)
3
- // · 蒸馏/复核/待办/报告/水位线:核心库 sleepRun —— 手动 `node akasha.mjs sleep` 与之共用同一水位线
4
- // · 条子一:systemPrompt.context(akasha:sleep)——每回合带最近一次睡眠摘要
5
- // · 条子二(唤醒条):agent/created → agent.inject 起床包摘要(不唤醒、不打断)
6
- // · 条子三(库脉搏 + 回查,v1.5):systemPrompt.context(akasha:pulse)——六库计数 + 最近写入;
7
- // 窗口内(默认 30 分钟)有失败时追一行「回查提示」(错误钩子的注入级动作,不拦截)
8
- // · 降级:核心库不可用 → sleep-error 观测线 + 条子提示文案,不炸、不失活。
9
- import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
10
- import { createRequire } from 'node:module';
11
- import { homedir } from 'node:os';
12
- import { dirname, join } from 'node:path';
13
-
14
- export const inject = ['systemPrompt', 'timer'];
15
-
16
- // —— 唤醒条消息构造(模块级导出,便于契约测试与复用)——
17
- // 语义等价于宿主 @deepseek-ai/dsh-llm 的 createUserMessage:
18
- // createMessage(input) = deepFreeze(structuredClone({ ...input, id: brandString(randomUUID()) }))
19
- // 其中 brandString 是纯编译期类型标记(运行时恒等,见 @deepseek-ai/dsh-brand)。
20
- export const newMessageId = () => (globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `akasha-${Date.now()}-${Math.random().toString(16).slice(2)}`);
21
- export const deepFreeze = (value) => {
22
- if (value && typeof value === 'object' && !Object.isFrozen(value)) {
23
- for (const item of Object.values(value)) deepFreeze(item);
24
- Object.freeze(value);
25
- }
26
- return value;
27
- };
28
- export const createUserMessage = (input) => deepFreeze({ ...structuredClone(input), role: 'user', id: newMessageId() });
29
-
30
- // 默认值:家目录下的 .akasha(可用 config 逐项覆盖)。
31
- // 默认值:`AKASHA_DIR` 环境变量 → 家目录下的 .akasha(2026-10-10:一处配置贯通全部路径,
32
- // 对外安装体验——此前用户必须逐项写 log/stateFile/inboxFile/akashaDir,漏一项就落到别处)。
33
- const ENV_AKASHA = typeof process.env.AKASHA_DIR === 'string' && process.env.AKASHA_DIR.trim() !== '' ? process.env.AKASHA_DIR.trim() : null;
34
- const DEFAULT_AKASHA = ENV_AKASHA ? (ENV_AKASHA === '~' ? homedir() : (ENV_AKASHA.startsWith('~/') || ENV_AKASHA.startsWith('~\\') ? join(homedir(), ENV_AKASHA.slice(2)) : ENV_AKASHA)) : join(homedir(), '.akasha');
35
- const DEFAULTS = {
36
- log: join(DEFAULT_AKASHA, 'logs', 'hooks.jsonl'),
37
- akashaDir: DEFAULT_AKASHA,
38
- stateFile: join(DEFAULT_AKASHA, 'logs', 'sleep-state.json'),
39
- inboxFile: join(DEFAULT_AKASHA, 'logs', 'inbox.jsonl'),
40
- // 唤醒条:消息由插件**本地构造**(语义同宿主 createUserMessage;不动态加载宿主包——DSH-Store 静态扫描口径)。
41
- wakeNote: true,
42
- minIntervalHours: 6,
43
- timerCheckMs: 3600000,
44
- contextOrder: 130,
45
- pulseOrder: 132,
46
- addressOrder: 133,
47
- preflightOrder: 135, // C1|易错点前置
48
- statefulOrder: 134 // C3|断言前把关(order 134 < 135 ⇒ 先渲染,命中时 C1 让位)
49
- };
50
-
51
- /** 唤醒条是否值得推(有内容才说话):无睡眠记录 / 有待办 / 审计有警告。 */
52
- export function isWakeNoteworthy(k) {
53
- return !k.sleep || (k.inbox?.totalItems ?? 0) > 0 || (k.review?.findings ?? []).some((f) => f.level === 'warn');
54
- }
55
-
56
- /** 组装「唤醒条」文本(纯函数)。 */
57
- export function buildWakeNote(k) {
58
- const lines = ['【阿卡夏·起床包】'];
59
- lines.push(`库况:canon ${k.library.canon} / mirror ${k.library.mirror} / orphan ${k.library.orphan} / pricing ${k.library.pricing} / lexicon ${k.library.lexicon} / frontier ${k.library.frontier}`);
60
- lines.push(k.sleep ? `睡眠:${k.sleep.lastRunAt}(${k.sleep.trigger})待办 ${k.sleep.todo}` : '睡眠:无记录(等待首个空闲)');
61
- if ((k.inbox?.totalItems ?? 0) > 0) lines.push(`待办 inbox:${k.inbox.totalItems} 条(akasha_promote 可转正)`);
62
- const warns = (k.review?.findings ?? []).filter((f) => f.level === 'warn');
63
- if (warns.length) lines.push(`审计:${warns.map((f) => f.code + '×' + f.count).join(',')}`);
64
- lines.push('详情:akasha_kit');
65
- return lines.join('\n');
66
- }
67
-
68
- export function apply(ctx, config = {}) {
69
-
70
- // A/B 实验开关(2026-10-10 用户解冻,见 docs/ab-experiment-design.md §6):
71
- // abOff=true ⇒ **关闭臂**——本插件不注册任何守卫/注入/工具,只记一行观测;库文件与日志照旧(便于事后复盘)。
72
- if (config.abOff === true) {
73
- try { (ctx.logger?.info ?? (() => {}))('sleep: abOff=true(A/B 关闭臂)——本插件不注册任何面'); } catch { /* 静默 */ }
74
- return;
75
- }
1
+ // 阿卡夏之书睡眠器 v1.5.1 —— 调度与注入(蒸馏逻辑归核心库 sleep.mjs)。
2
+ // · 触发:agent/status → idle(去抖 minIntervalHours)+ 定时兜底 ctx.interval(默认 1h)
3
+ // · 蒸馏/复核/待办/报告/水位线:核心库 sleepRun —— 手动 `node akasha.mjs sleep` 与之共用同一水位线
4
+ // · 条子一:systemPrompt.context(akasha:sleep)——每回合带最近一次睡眠摘要
5
+ // · 条子二(唤醒条):agent/created → agent.inject 起床包摘要(不唤醒、不打断)
6
+ // · 条子三(库脉搏 + 回查,v1.5):systemPrompt.context(akasha:pulse)——六库计数 + 最近写入;
7
+ // 窗口内(默认 30 分钟)有失败时追一行「回查提示」(错误钩子的注入级动作,不拦截)
8
+ // · 条子四(行为层,v1.10;2026-10-13 **按裁定重挂·默认关**):形态 B(无正文 + 无工具)检测 → 观测线 `idle-shape`;
9
+ // `idleResume:true` 时才自动续行(强信号=思维链尾部「好。」;冷却 + 限次;文案禁清单/打勾/句号收尾)
10
+ // · 降级:核心库不可用 → sleep-error 观测线 + 条子提示文案,不炸、不失活。
11
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createRequire } from 'node:module';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ // 注入安全(2026-10-11 立):坑前提示注入的是**库正文**(canon 的 act 字段)⇒ 属"不可信输入",
18
+ // 须先转义花括号(裸 `{{` 会让宿主插值抛错、该会话永久不可用),并加「库原文·仅参考」标注。
19
+ import { safeInject, safeInjectInline } from './prompt-safety.js';
20
+
21
+ export const inject = ['systemPrompt', 'timer'];
22
+
23
+ // —— 唤醒条消息构造(模块级导出,便于契约测试与复用)——
24
+ // 语义等价于宿主 @deepseek-ai/dsh-llm 的 createUserMessage:
25
+ // createMessage(input) = deepFreeze(structuredClone({ ...input, id: brandString(randomUUID()) }))
26
+ // 其中 brandString 是纯编译期类型标记(运行时恒等,见 @deepseek-ai/dsh-brand)。
27
+ export const newMessageId = () => (globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `akasha-${Date.now()}-${Math.random().toString(16).slice(2)}`);
28
+ export const deepFreeze = (value) => {
29
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
30
+ for (const item of Object.values(value)) deepFreeze(item);
31
+ Object.freeze(value);
32
+ }
33
+ return value;
34
+ };
35
+ export const createUserMessage = (input) => deepFreeze({ ...structuredClone(input), role: 'user', id: newMessageId() });
36
+
37
+ // 默认值:家目录下的 .akasha(可用 config 逐项覆盖)。
38
+ // 默认值:`AKASHA_DIR` 环境变量 → 家目录下的 .akasha(2026-10-10:一处配置贯通全部路径,
39
+ // 对外安装体验——此前用户必须逐项写 log/stateFile/inboxFile/akashaDir,漏一项就落到别处)。
40
+ const ENV_AKASHA = typeof process.env.AKASHA_DIR === 'string' && process.env.AKASHA_DIR.trim() !== '' ? process.env.AKASHA_DIR.trim() : null;
41
+ const DEFAULT_AKASHA = ENV_AKASHA ? (ENV_AKASHA === '~' ? homedir() : (ENV_AKASHA.startsWith('~/') || ENV_AKASHA.startsWith('~\\') ? join(homedir(), ENV_AKASHA.slice(2)) : ENV_AKASHA)) : join(homedir(), '.akasha');
42
+ const DEFAULTS = {
43
+ log: join(DEFAULT_AKASHA, 'logs', 'hooks.jsonl'),
44
+ akashaDir: DEFAULT_AKASHA,
45
+ stateFile: join(DEFAULT_AKASHA, 'logs', 'sleep-state.json'),
46
+ inboxFile: join(DEFAULT_AKASHA, 'logs', 'inbox.jsonl'),
47
+ // 唤醒条:消息由插件**本地构造**(语义同宿主 createUserMessage;不动态加载宿主包——DSH-Store 静态扫描口径)。
48
+ wakeNote: true,
49
+ minIntervalHours: 6,
50
+ timerCheckMs: 3600000,
51
+ contextOrder: 130,
52
+ pulseOrder: 132,
53
+ addressOrder: 133,
54
+ preflightOrder: 135, // C1|易错点前置
55
+ statefulOrder: 134, // C3|断言前把关(order 134 < 135 ⇒ 先渲染,命中时 C1 让位)
56
+ gateOrder: 136, // 门档|重要工作自动挂(2026-10-15;排在 C3/C1 之后,不扰动既有的让位次序)
57
+ // I-12b 行为层(2026-10-13 重挂;**默认关**——先观测):判据=形态 B;冷却 / 限次防扰动循环。
58
+ idleResume: false,
59
+ idleResumeOnWeak: false,
60
+ idleResumeCooldownMs: 300000,
61
+ idleResumeMax: 3,
62
+ idleTailChars: 32,
63
+ // F8(2026-10-16 batch8;VERIFY-S3 §5-F8):审核面滞后检查的**超时值改为 cfg 可注入**(默认 5000 ms)。
64
+ // 动机:测试里的超时驱动要实等整个超时 ⇒ route 套件从 0.65 s 抬到 6.4 s,每次回归都背。
65
+ // 语义不变(同一条 kill 路径),只是不再把 5 s 写死在调用点。
66
+ reviewLagTimeoutMs: 5000
67
+ };
68
+
69
+ /** 唤醒条是否值得推(有内容才说话):无睡眠记录 / 有待办 / 审计有警告。 */
70
+ export function isWakeNoteworthy(k) {
71
+ return !k.sleep || (k.inbox?.totalItems ?? 0) > 0 || (k.review?.findings ?? []).some((f) => f.level === 'warn');
72
+ }
73
+
74
+ /** 组装「唤醒条」文本(纯函数)。 */
75
+ export function buildWakeNote(k) {
76
+ const lines = ['【阿卡夏·起床包】'];
77
+ lines.push(`库况:canon ${k.library.canon} / mirror ${k.library.mirror} / orphan ${k.library.orphan} / pricing ${k.library.pricing} / lexicon ${k.library.lexicon} / frontier ${k.library.frontier}`);
78
+ // 2026-10-15(GPT 复核 lead L1):此数是**本次睡眠生成**的待办(历史时点值),不是"当前待办总数"——
79
+ // 标签写明,免得与下一行的当前 inbox 数并排时被读成矛盾(纯展示口径修正,无数据丢失)。
80
+ lines.push(k.sleep ? `睡眠:${k.sleep.lastRunAt}(${k.sleep.trigger})本次生成待办 ${k.sleep.todo}` : '睡眠:无记录(等待首个空闲)');
81
+ if ((k.inbox?.totalItems ?? 0) > 0) lines.push(`待办 inbox:${k.inbox.totalItems} 条(akasha_promote 可转正)`);
82
+ const warns = (k.review?.findings ?? []).filter((f) => f.level === 'warn');
83
+ if (warns.length) lines.push(`审计:${warns.map((f) => f.code + '×' + f.count).join(',')}`);
84
+ lines.push('详情:akasha_kit');
85
+ return lines.join('\n');
86
+ }
87
+
88
+ // —— I-12b 行为层:形态 B 判定(纯函数;2026-10-13 **按裁定重挂**)——
89
+ // 前史:旧钩子 1.8.x(判据=「流无正文」)因 **形态 A 误报 + 扰动循环(抢险注入被写进思考链)**
90
+ // 被 Master 裁定删除(`canon-idle-hook-removed-20261013`)。本版按机制报告 §⑥ 收窄:
91
+ // **形态 B = 有思考、无正文、无工具调用**(真卡壳);**形态 A(无正文 + 有工具)=正常中间步骤,绝不报**。
92
+ // 先兆信号:思维链尾部「好。」(强,11× 危险比)/「。」(弱);干预只在开闸(默认关)且强信号时发生。
93
+ export const IDLE_RESUME_TEXT = '【自动续行】请直接继续执行下一步:禁止列清单、禁止打勾、禁止以「。」收尾(想一条做一条)。若确实已无事可做、或需要用户输入,请直接输出正文说明。';
94
+ /** 一回合的流信号 → 判定:shape `normal` | `A`(无正文+有工具)| `B`(无正文+无工具);signal `hao` | `o` | null。 */
95
+ export function judgeIdleTurn({ hasText, toolCalls, tail } = {}) {
96
+ const shape = hasText === true ? 'normal' : ((Number(toolCalls) || 0) > 0 ? 'A' : 'B');
97
+ const t = String(tail ?? '');
98
+ const signal = t.endsWith('好。') ? 'hao' : (t.endsWith('。') ? 'o' : null);
99
+ return { shape, signal, stall: shape === 'B' };
100
+ }
101
+
102
+ /** S4(2026-10-13):尾部窗口读 + 取「最近一条非注入 intent 的 gist」——两个纯函数。
103
+ * 做成**顶层导出**(而不是埋在 apply 里)的唯一理由:自测要拿它们做等价对照;
104
+ * 生产路径(apply 内的 akasha:stateful 状态条)调用的就是这两个导出,不存在「测试一份、生产另一份」。 */
105
+ export const TAIL_WINDOW_BYTES = 256 * 1024;
106
+ export function tailReadText(file, maxBytes = TAIL_WINDOW_BYTES) {
107
+ const st = statSync(file);
108
+ const start = Math.max(0, st.size - maxBytes);
109
+ const len = st.size - start;
110
+ if (len <= 0) return { text: '', bytes: 0, size: st.size, truncated: false };
111
+ const fd = openSync(file, 'r');
112
+ try {
113
+ const buf = Buffer.allocUnsafe(len);
114
+ const n = readSync(fd, buf, 0, len, start);
115
+ let text = buf.subarray(0, n).toString('utf8');
116
+ // 2026-10-16 审修:**bytes = 实际读入字节数**(截断时恒等于窗口),**size = 文件真大小**——
117
+ // 调用方(gate-window-miss 观测线)此前把 bytes 当 fileBytes 记,日志无法反映"差多远"。
118
+ if (start > 0) { const nl = text.indexOf('\n'); if (nl === -1) return { text: '', bytes: n, size: st.size, truncated: true }; text = text.slice(nl + 1); }
119
+ return { text, bytes: n, size: st.size, truncated: start > 0 };
120
+ } finally { closeSync(fd); }
121
+ }
122
+ // 会话身份是完整宿主 ID;`session-1`、`1`、`session-session-1` 是三个不同身份。
123
+ // 生产者已经保留原始 ID。旧短 ID 缺少可核对映射时不得猜别名,需核对原档后重索引。
124
+ // 2026-10-15 V02 补注(GPT N1/N2 收尾):**存储侧**入口存裸 uuid(核心 normalizeSid);**读侧**=精确优先 + 归一兜底
125
+ // (见下方 latestIntentGist 与 gate.test 的跨 ID 段)——不猜"旧短 ID"别名,但 `session-` 前缀这一种等价形态必须互认。
126
+
127
+ export function latestIntentGist(text, session) {
128
+ // V02:当前会话尚无 intent 也不能回退到另一个合法 ID;无标记的完整 ID 历史记录仍可精确读取。
129
+ // 省略参数仅保留给原有离线尾读诊断;运行时两个调用方均明确传入完整 ID 或 null。
130
+ if (session === null || session === '' || session === '(unknown)') return null;
131
+ const want = session === undefined ? undefined : String(session);
132
+ const lines = String(text ?? '').split('\n');
133
+ const pick = (pred) => {
134
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
135
+ if (!lines[i].includes('"kind":"intent"')) continue;
136
+ let r; try { r = JSON.parse(lines[i]); } catch { continue; }
137
+ if (pred && !pred(r)) continue;
138
+ const g = String(r.gist || '');
139
+ if (!g) continue;
140
+ // 规则化跳过注入段(2026-10-10 生产核对:除 runtime context 外还有 openviking / hindsight 等注入块被记成 intent)
141
+ if (g.startsWith('<') || g.length > 800
142
+ || /Current runtime context|Relevant memory from|Hindsight|openviking-context|阿卡夏·/.test(g)) continue;
143
+ return g;
144
+ }
145
+ return null;
146
+ };
147
+ if (want === undefined) return pick(null);
148
+ // 2026-10-15(GPT 复核"跨组件 ID 约定冲突",板上第 7 条,实证):核心侧**入口一律存裸 uuid**(`normalizeSid`),
149
+ // 而宿主给插件的是**完整 `session-<uuid>`** ⇒ 原第 137 行的严格相等在带前缀会话上恒不匹配 ⇒ gate/stateful **同时静默**。
150
+ // 修法:**精确优先**(保留 Q06 的"不许跨会话误配"),再以**归一兜底**(两边都去掉前缀后比较)。
151
+ const exact = pick((r) => String(r.session ?? '') === want);
152
+ if (exact) return exact;
153
+ const norm = (v) => String(v ?? '').replace(/^session-/, '');
154
+ const nw = norm(want);
155
+ return nw ? pick((r) => norm(r.session) === nw) : null;
156
+ }
157
+
158
+ /** 门档触发判据(2026-10-15 落地;**顶层导出**,供自测做等价对照——与 tailReadText/latestIntentGist 同规矩)。
159
+ * 依据:canon-thinkloop-gate-20261015(何时该开三趟+独立审核)+ canon-gate-v2-20261015(v2:p0 机检 97s / 升级项才开慢层)。
160
+ * 三类「重要工作」:① 代码(写/改/修/实现/重构)② 审计(审/复核/证伪/根因)③ 数据完整性/不可逆(迁移/重建/紧凑化/回滚/发布/删除)。
161
+ * **负例优先**:泛词不算——「查代码/看代码/代码在哪」这类检索动作**不得**触发(否则每回合都响,退化成噪音)。 */
162
+ export const GATE_TRIGGERS = [
163
+ // 2026-10-15 修(门档自审发现):原词表**漏掉高频口语表达** ⇒ 7 条实测里 6 条不触发(含「全量检查」「排查」「体检」「审一下」)——
164
+ // 最该挂门档的场景反而挂不上(当天那次"对阿卡夏做一次全量检查"就是靠用户明说才走的)。
165
+ { cat: '审计', re: /审计|审查|审核|复核|复检|复验|核对|排查|检查|体检|证伪|挑错|挑毛病|找问题|对抗性|根因|事故|审一下|查一下有没有问题/ },
166
+ { cat: '迁移/不可逆', re: /迁移|重建|紧凑化|回滚|清库|发布|上线|不可逆|删掉|删除/ },
167
+ { cat: '代码', re: /(写|改|修|加|补|实现|重构|落)(一下|下|个|一个|一段|点|这个|那个|的)*\s*(代码|函数|脚本|补丁|模块)|修\s*(个|一个)?\s*bug|fix\s*bug|重构|落码|实现一下/ },
168
+ ];
169
+ export function gateTriggerOf(gist) {
170
+ const t = String(gist ?? '').trim();
171
+ if (!t) return null;
172
+ if (/查代码|看代码|搜代码|读代码|代码在哪|找代码|代码风格|代码库|代码量/.test(t)) return null; // 负例优先
173
+ for (const g of GATE_TRIGGERS) if (g.re.test(t)) return { cat: g.cat };
174
+ return null;
175
+ }
176
+
177
+ // 审核面滞后检查的**模块级单例**(2026-10-16 batch6 补丁 D;口径按 batch6 复核 VERIFY-S2 §3 收窄):
178
+ // 原定义在 apply() 闭包里 ⇒ 严格说是「插件实例级」(当前 cordis.yml 只有一条 root 插入、实例数=1,故与
179
+ // 现状等价)。提到模块级 ⇒ **同一模块实例**内重复 `apply()`(同一进程、同一求值结果)共用同一份节流与
180
+ // 结果,不会出现 N 份节流与 N 次 exec。
181
+ // ⚠ 边界(实测,别过度推广):**重新求值 ≠ 同一实例**——两个独立模块实例(不同 cache-buster,≈ HMR
182
+ // 重新求值而旧实例未被复用)会**各持一份节流、各跑一次 exec**。所以「不会 N 份」的口径只对
183
+ // 「同一模块实例内的重复 apply」成立,不对「进程全局」成立。
184
+ // 导出仅供测试:route.test.mjs 用 `reviewLagState.at = 0` 复位节流(同进程多段用例各跑各的)。
185
+ export const reviewLagState = { at: 0, value: null, logged: null, checkedAt: null };
186
+ // O1(2026-10-17 batch15 · 显示面):检查时点只用于**显示**,不参与节流/判定(`at` 仍是节流唯一依据)。
187
+ // 背景(VERIFY-A4 §5-O1):戳重生成后、下一次 turn/end 检查到来前,显示的是上一结果 ⇒ 06:35–06:46 出现
188
+ // 「脉搏报滞后」与「实跑 exit 0」的**时间差分歧**。附时点后「这个结论是什么时候的」一眼可见;判定不动。
189
+ // O1-b(2026-10-17 batch16 · 显示面,VERIFY-S7 §7):只有 `HH:MM` 时,进程长期静默(无 turn/end、无写入)
190
+ // ⇒ 旧结果跨日会被读成「今天 06:50」(今天 06:50 与明天 06:50 逐字相同)。修法:**超过 6h 的时点补日期**
191
+ // (`MM-DD HH:MM`),6h 内仍只给 `HH:MM`(短、不白占行宽)。
192
+ // 阈值取 6h 的判据:与本插件 `minIntervalHours` 同一量级;「今天 HH:MM」与「昨天同 HH:MM」要混淆须错位
193
+ // ≥18h(同刻度落在 6h 窗外才可能同形)⇒ 6h 外的时点带日期即无歧义。判定/节流仍**不读** checkedAt。
194
+ const stampOf = (ms) => {
195
+ const d = new Date(ms), p = (x) => String(x).padStart(2, '0');
196
+ const t = `${p(d.getHours())}:${p(d.getMinutes())}`;
197
+ return Date.now() - ms < 6 * 3600_000 ? t : `${p(d.getMonth() + 1)}-${p(d.getDate())} ${t}`;
198
+ };
199
+
200
+ // D1/F6(2026-10-16 batch8 · VERIFY-S3 §5-F6):**raw 分类器**——从 execFileSync 抛出的 error 取「真因」。
201
+ // 优先级:真实退出码(number)> **`error.code` 字符串** > 信号(`'SIG:' + signal`)> `'-'` 兜底。
202
+ // ⚠ 顺序是实测定的(batch8 landing/tools/probe-timeout-shape.mjs):**超时被杀同时带**
203
+ // `signal:'SIGTERM'` 与 `code:'ETIMEDOUT'`(status 为 null)⇒ 若信号优先,raw 会从 `'ETIMEDOUT'`
204
+ // 退化成 `'SIG:SIGTERM'`(信息变差;H-3 用例当场红)。信号格只在 **code 缺失** 时兜底
205
+ // (POSIX 信号杀:status 为 null、code 为 undefined、signal='SIGKILL')。
206
+ // 提到顶层导出的理由:`'-'` / `'SIG:x'` / `'ENOENT'`(字符串格)在 **Windows 上不可达**,
207
+ // 只能直测本函数(route.test.mjs H+++ 段);生产路径(runReviewLag 的 catch)调用的就是这一份
208
+ // ——不存在「测试一份、生产另一份」。
209
+ export const rawOfError = (e) => (typeof e?.status === 'number'
210
+ ? e.status
211
+ : (typeof e?.code === 'string' ? e.code
212
+ : (typeof e?.signal === 'string' ? 'SIG:' + e.signal : '-')));
213
+
214
+ export function apply(ctx, config = {}) {
215
+
216
+ // A/B 实验开关(2026-10-10 用户解冻,见 docs/ab-experiment-design.md §6):
217
+ // abOff=true ⇒ **关闭臂**——本插件不注册任何守卫/注入/工具,只记一行观测;库文件与日志照旧(便于事后复盘)。
218
+ if (config.abOff === true) {
219
+ try { (ctx.logger?.info ?? (() => {}))('sleep: abOff=true(A/B 关闭臂)——本插件不注册任何面'); } catch { /* 静默 */ }
220
+ return;
221
+ }
76
222
  const cfg = { ...DEFAULTS, ...config };
77
- const require_ = createRequire(import.meta.url);
78
- const reportDir = join(cfg.akashaDir, 'logs');
79
- const stateFile = cfg.stateFile || join(reportDir, 'sleep-state.json');
80
- const inboxFile = cfg.inboxFile || join(reportDir, 'inbox.jsonl');
81
-
82
- const log = (record) => {
83
- try {
84
- mkdirSync(dirname(cfg.log), { recursive: true });
85
- appendFileSync(cfg.log, JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n', 'utf8');
86
- } catch { /* 静默:观测线写不了不拖垮宿主 */ }
87
- };
88
- const readState = () => { try { return JSON.parse(readFileSync(stateFile, 'utf8')); } catch { return {}; } };
89
-
90
- // 核心库(蒸馏逻辑)动态加载:插件只做调度与注入;缺文件时降级留痕(不失活)。
91
- const core = () => require_(join(cfg.akashaDir, 'sleep.mjs'));
92
-
93
- const runSleep = (trigger) => {
94
- try {
95
- const r = core().sleepRun({ trigger, log: cfg.log, stateFile, inboxFile, reportDir });
96
- if (!r.ok) { log({ kind: 'sleep-error', trigger, message: String(r.error).slice(0, 300) }); return false; }
97
- log({ kind: 'sleep-done', trigger, report: r.reportFile, todo: r.todo, counters: r.state?.lastCounters, audit: r.state?.lastAudit });
98
- return true;
99
- } catch (error) {
100
- log({ kind: 'sleep-error', trigger, message: String(error?.message ?? error).slice(0, 300) });
101
- return false;
102
- }
103
- };
104
-
105
- const maybeSleep = (trigger) => {
106
- try {
107
- const verdict = core().shouldSleep(readState(), Date.now(), cfg.minIntervalHours);
108
- if (!verdict.due) { log({ kind: 'sleep-skip', trigger, sinceHours: verdict.hoursSince }); return; }
109
- runSleep(trigger);
110
- } catch (error) {
111
- log({ kind: 'sleep-error', trigger, message: String(error?.message ?? error).slice(0, 300) });
112
- }
113
- };
114
-
115
- // 唤醒条(M6b):新会话(agent/created)自动把「起床包」摘要递进 next-step inbox(不唤醒、不打断)。
116
- // 仅当有内容(无睡眠记录 / 有待办 / 审计警告)才说话;子代理与 compact 不打扰;一切异常只留痕。
117
- // 消息构造用模块级 createUserMessage(见文件头;本地实现、不动态加载宿主包)。
118
- const llmSkipNote = cfg.wakeNote === false ? 'wakeNote=false:唤醒条注入跳过(其余功能不受影响)' : null;
119
-
120
- const injectWakeNote = (agent) => {
121
- try {
122
- if (!agent || typeof agent.inject !== 'function' || !createUserMessage) return;
123
- const k = require_(join(cfg.akashaDir, 'lib.mjs')).kit();
124
- if (!isWakeNoteworthy(k)) return;
125
- const message = createUserMessage({
126
- content: [{ type: 'text', text: buildWakeNote(k) }],
127
- source: { kind: 'akasha-wake' }
128
- });
129
- agent.inject(message);
130
- log({ kind: 'wake-note', session: String(agent.id ?? ''), todo: k.inbox?.totalItems ?? 0, auditOk: k.review?.ok ?? null });
131
- } catch (error) {
132
- log({ kind: 'wake-note-error', message: String(error?.message ?? error).slice(0, 200) });
133
- }
134
- };
135
-
136
- ctx.on('agent/created', (payload) => {
137
- try {
138
- // 2026-10-07 复查:官方 agent/created 的 source 只有 startup/resume;子代理守卫沿用 header.origin / delegationDepth。
139
- const agent = payload?.agent;
140
- const header = agent?.session?.header;
141
- if (header?.origin === 'subagent' || (header?.delegationDepth ?? 0) > 0) return;
142
- if (cfg.wakeNote === false) return;
143
- injectWakeNote(agent);
144
- } catch (error) {
145
- log({ kind: 'wake-note-error', message: String(error?.message ?? error).slice(0, 200) });
146
- }
147
- });
148
-
149
- // 条子:每个新回合的 runtime context 自动带上最近一次睡眠摘要(渲染自核心库,失败安全)。
150
- ctx.systemPrompt.context({
151
- name: 'akasha:sleep',
152
- order: cfg.contextOrder,
153
- text: () => {
154
- try { return core().renderContextLine(readState()); }
155
- catch { return '阿卡夏·睡眠:核心库不可用(akasha\\sleep.mjs 缺失或损坏)。'; }
156
- }
157
- });
158
-
159
- // 回查提示(v1.5,错误钩子的注入级动作):进程内记录近期失败(工具 / agent),窗口内幂等渲染。
160
- const recentFailures = [];
161
- const usedTools = new Set(); // 本会话用过哪些工具(首用预告的判据)
162
- const announcedTraps = new Set(); // 已预告过的陷阱工具(每工具一次)
163
- const noteFailure = (tool) => {
164
- try {
165
- recentFailures.push({ ts: Date.now(), tool: String(tool ?? '?') });
166
- if (recentFailures.length > 20) recentFailures.splice(0, recentFailures.length - 20);
167
- } catch { /* 静默 */ }
168
- };
169
- // 极限观测(2026-10-11 用户令):把「该查库了」变成**可见信号**,不靠记性——针对"200 轮后忘记查库"的观察。
170
- const obs = { turn: 0, steps: 0, compactions: 0, usedToolsN: 0, lastRecallTurn: null, lastCompactionTurn: null };
171
- const clip = (s) => (s.length <= 80 ? s : s.slice(0, 79) + '…'); // 行宽硬约束的统一裁剪(模块级,供多行共用)
172
- const isRecallTool = (name) => /^mcp__akasha__/.test(String(name || ''));
173
- ctx.on('tools/result', (exec, result) => {
174
- const tool = String(exec?.name ?? '?');
175
- usedTools.add(tool); // 首用预告用(2026-10-11)
176
- obs.usedToolsN = usedTools.size;
177
- if (isRecallTool(tool)) obs.lastRecallTurn = obs.turn; // MCP 召回 ⇒ 刷新"距上次查库"
178
- else {
179
- // CLI 查库也算召回(2026-10-11 实测:本会话大量查库走 `node akasha.mjs …`,只认 MCP 会显示"尚未查库"=自欺)
180
- try {
181
- const blob = JSON.stringify(exec?.args ?? exec?.input ?? exec?.params ?? '');
182
- if (/akasha\.mjs|session\.mjs|mcp\.mjs/.test(blob)) obs.lastRecallTurn = obs.turn;
183
- } catch { /* 忽略 */ }
184
- }
185
- if (result?.isError === true) noteFailure(exec?.name);
186
- });
187
- ctx.on('agent/error', () => noteFailure('agent'));
188
- // 轮次与压缩真值:能拿到就用真值,拿不到就保持 0(宁缺勿假)
189
- try { ctx.on('agent/turn-stopping', (p) => { if (p && typeof p.turn === 'number') obs.turn = p.turn; }); } catch { /* 事件不存在 */ }
190
- try { ctx.on('agent/turn-end', (p) => { if (p && typeof p.turn === 'number') obs.turn = Math.max(obs.turn, p.turn); }); } catch { /* 事件不存在 */ }
191
- try {
192
- ctx.on('session/event', (p) => {
193
- const t = String(p?.type ?? p?.name ?? p?.eventType ?? '');
194
- if (t.startsWith('compaction/end') || (t === 'compaction' && p?.phase === 'end')) {
195
- obs.compactions++;
196
- obs.lastCompactionTurn = obs.turn; // 脆弱窗判据(压缩补偿行用)
197
- }
198
- });
199
- } catch { /* 事件不存在 */ }
200
-
201
- // 库脉搏(v1.3;v1.5 起附加回查行):写→可见通道——并行会话 / 脚本写入库后,下一回合即可见并命名新内容。
202
- ctx.systemPrompt.context({
203
- name: 'akasha:pulse',
204
- order: cfg.pulseOrder,
205
- text: () => {
206
- try {
207
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
208
- const lines = [core().renderPulseLine(lib.pulse())];
209
- const recall = core().renderRecallLine(recentFailures, Date.now());
210
- if (recall) lines.push(recall);
211
- return lines.filter(Boolean).join('\n') || null;
212
- } catch { return null; }
213
- }
214
- });
215
-
216
- // 称呼行(2026-10-10 用户复盘「长会话中期称呼易漂移」):
217
- // 规则躺在会话开头那坨注入(akasha:self)里,**执行时看不到** ⇒ 必然漂移。
218
- // 修法=接进每回合必然经过的执行点:这条 runtime context 行每回合都出现,内容渲染自
219
- // canon 的称呼体系当前版本(降级为内置短句),并写明执行点要求(交付/验收/复盘的开口句)。
220
- ctx.systemPrompt.context({
221
- name: 'akasha:address',
222
- order: cfg.addressOrder,
223
- text: () => {
224
- try {
225
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
226
- const recs = lib.currentRecords(lib.loadStore('canon').records);
227
- const hit = recs.filter((r) => String(r.id).startsWith('canon-address-layers')).pop();
228
- if (hit && typeof hit.claim === 'string') return core().renderAddressLine(hit.claim);
229
- } catch { /* 读库失败 → 兜底短句 */ }
230
- return core().renderAddressLine('');
231
- }
232
- });
233
-
234
- // C3|断言前把关(stateful-fact gate,2026-10-10;评审 v2 定稿=触发改到提问侧)
235
- // 为什么改触发:context 行在回合开始渲染、回复尚未产生;且本插件看不到用户消息事件 ⇒
236
- // 取**会话层最近一条 intent 段**(跳过注入样式的段)做 6 类关键词判定。
237
- // 只提醒不阻断;statefulGate:false ⇒ 提示与事件双停。
238
- const gateState = { c3Cat: null, c3At: 0, c3Fired: false };
239
- if (cfg.statefulGate !== false) {
240
- const STATEFUL = [
241
- { cat: "发布", re: /已发布|未发布|上架|没过审|已过审|待审/ },
242
- { cat: "安装", re: /已安装|未安装|装好了|装上了/ },
243
- { cat: "版本", re: /版本|version/i },
244
- { cat: "开关/运行", re: /开关|启用|禁用|默认开|默认关|开着|关着|在跑|跑着|还在吗|开着吗|装着吗|有没有开/ },
245
- { cat: "存在", re: /是否存在|有没有|在不在|还在吗/ },
246
- ];
247
- ctx.systemPrompt.context({
248
- name: "akasha:stateful",
249
- order: cfg.statefulOrder,
250
- text: () => {
251
- try {
252
- const path = join(cfg.akashaDir, "data", "session.jsonl");
253
- const lines = readFileSync(path, "utf8").split("\n").slice(-200);
254
- let gist = null;
255
- for (let i = lines.length - 1; i >= 0; i--) {
256
- if (!lines[i].includes("\"kind\":\"intent\"")) continue;
257
- let r; try { r = JSON.parse(lines[i]); } catch { continue; }
258
- const g = String(r.gist || "");
259
- if (!g) continue;
260
- // 规则化跳过注入段(2026-10-10 生产核对发现:除 runtime context 外还有 openviking / hindsight 等注入块被记成 intent)
261
- const injected = g.startsWith("<") || g.length > 800
262
- || /Current runtime context|Relevant memory from|Hindsight|openviking-context|阿卡夏·/.test(g);
263
- if (injected) continue;
264
- gist = g; break;
265
- }
266
- if (!gist) return null;
267
- const hit = STATEFUL.find((k) => k.re.test(gist));
268
- if (!hit) return null;
269
- const now = Date.now();
270
- if (gateState.c3Cat === hit.cat && now - gateState.c3At < 30 * 60 * 1000) return null; // 同类 30 分钟去重
271
- gateState.c3Cat = hit.cat; gateState.c3At = now; gateState.c3Fired = true;
272
- try {
273
- appendFileSync(join(cfg.akashaDir, "logs", "hooks.jsonl"),
274
- JSON.stringify({ ts: new Date().toISOString(), kind: "stateful-reminder", cat: hit.cat }) + "\n", "utf8");
275
- } catch { /* 事件失败不影响提示 */ }
276
- return "[断言提醒] 状态性事实——先查再答(例:versions + dist-tags 两处核对)";
277
- } catch { return null; }
278
- }
279
- });
280
- }
281
-
282
- // C1|易错点前置(pre-flight hint,2026-10-10 解冻后实现;评审 v2 定稿)
283
- // 依据:可预防漏召率 91.2%(回合级)、会话级 100% ⇒ 缺的不是知识,是"动作前把库拉进来"。
284
- // 形态:一行 ≤80 字,只提示不阻断;关(preflightHints:false)则**提示与事件双停**(不写假触发)。
285
- // 近似说明(如实):context 行在**回合开始**渲染,此刻还不知道本回合会调用什么工具,
286
- // 故用"最近失败的工具"作为**预提示**(近 30 分钟窗口 + 同工具去重);"本回合尚未查库"这一前置
287
- // 在渲染时无法判定,改为"上一个回合没有查库动作时更倾向提示"的弱化版(v1 取"总是可提示",由去重与上限约束)。
288
- const preflightState = { tool: null, at: 0 };
289
- if (cfg.preflightHints !== false) {
290
- ctx.systemPrompt.context({
291
- name: 'akasha:preflight',
292
- order: cfg.preflightOrder,
293
- text: () => {
294
- try {
295
- if (gateState.c3Fired) return null; // C3 优先(同回合只出一条)
296
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
297
- const now = Date.now();
298
- const WINDOW = 30 * 60 * 1000;
299
- const recent = (recentFailures || []).filter((f) => now - Number(f.ts ?? f.at) <= WINDOW); // 字段是 ts(见 noteFailure)
300
- const map = lib.RECALL_TRAP_MAP || {};
301
- // 2026-10-11 修复(用户裁定:反射要能随工程知识纠错与优化):
302
- // **反射文本随条目走** —— 不再只认写死的表。
303
- // ① 链感知解析(id 或 id-rN 取链尾);② 条目缺失/已退役 ⇒ **不打**(fail closed,不执行过期反射);
304
- // ③ 优先用条目自带 `hint`(≤80 字反射文本:**改条目=改反射**);④ `hint_until` 过期 ⇒ 跳过;
305
- // ⑤ 内置表降级为**过渡兜底**;⑥ 都取不到 ⇒ 不打(宁缺勿滥)。
306
- // 该表仅作过渡:条目补上 hint 后即成为唯一来源。
307
- const FALLBACK_ACTIONS = {
308
- 'canon-trap-edit-context-mismatch': '先 read 原文再改、改完复核',
309
- 'canon-trap-read-offset-range': '先用 grep 拿真实行号再读',
310
- 'canon-trap-grep-exit2-path': '换浅范围/排除 node_modules 再 grep',
311
- 'canon-trap-unknown-tool-name': '只从本会话可见工具面选名字',
312
- 'canon-trap-provider-method-list': '先列方法清单再调用',
313
- };
314
- let canonRecs = [];
315
- try { canonRecs = lib.currentRecords(lib.loadStore('canon').records); } catch { /* 读不到 ⇒ 只走兜底 */ }
316
- const resolveEntry = (cid) => canonRecs.filter((r) => String(r.id) === cid || String(r.id).startsWith(cid + '-r')).pop() || null;
317
- const isRetired = (rec) => !!rec && (/已退役|retired/i.test(String(rec.claim ?? '')) || String(rec.id).endsWith('-retired'));
318
- const today = new Date().toISOString().slice(0, 10);
319
- /** 某工具的首选反射:条目 `hint` 优先 → 兜底表;退役/过期/缺失一律跳过;取不到返回 null(fail closed)。
320
- * ⚠ 口径:**库可读时,条目是唯一来源**——该条目没有 hint 也**不**回落兜底(否则"退役/过期"会被兜底表救活,
321
- * 等于过期反射照打)。兜底表只在**库读不到**时生效。 */
322
- const hintForTool = (tool) => {
323
- const ids = map[tool];
324
- if (!ids || !ids.length) return null;
325
- for (const cand of ids) {
326
- if (canonRecs.length) {
327
- const rec = resolveEntry(cand);
328
- if (!rec) continue; // 链上已无此条 ⇒ 跳过
329
- if (isRetired(rec)) continue; // 已退役 ⇒ 不执行过期反射
330
- if (rec.hint_until && String(rec.hint_until) < today) continue; // 反射有效期已过 ⇒ 跳过
331
- if (typeof rec.hint === 'string' && rec.hint.trim()) return { id: cand, act: rec.hint.trim(), hintSource: 'entry' };
332
- continue; // 有条目但无 hint ⇒ 该候选作罢,不回落
333
- }
334
- if (FALLBACK_ACTIONS[cand]) return { id: cand, act: FALLBACK_ACTIONS[cand], hintSource: 'fallback' };
335
- }
336
- return null;
337
- };
338
- const emitPreflight = (payload) => {
339
- try { // 事件:关闭时走不到这里(双停)
340
- appendFileSync(join(cfg.akashaDir, 'logs', 'hooks.jsonl'),
341
- JSON.stringify({ ts: new Date().toISOString(), kind: 'preflight', ...payload }) + '\n', 'utf8');
342
- } catch { /* 事件失败不影响提示 */ }
343
- };
344
- const trim80 = (s) => (s.length <= 80 ? s : s.slice(0, 79) + '…');
345
-
346
- // ① 失败驱动(原逻辑):近 30 分钟内有过失败 ⇒ 给"那件工具"的反射
347
- if (recent.length) {
348
- const tool = String(recent[recent.length - 1].tool || '');
349
- const fresh = !(preflightState.tool === tool && now - preflightState.at < WINDOW); // 同工具 30 分钟去重
350
- if (fresh && map[tool]) {
351
- const h = hintForTool(tool);
352
- if (h) {
353
- preflightState.tool = tool; preflightState.at = now;
354
- emitPreflight({ tool, entry: h.id, hintSource: h.hintSource, mode: 'failure' });
355
- return trim80('[坑前提示] ' + tool + ' 前:' + h.act + ' —— 条目 ' + h.id);
356
- }
357
- }
358
- }
359
-
360
- // ② 首用预告(2026-10-11 新增;补"漏召"的结构缺口):
361
- // 渲染时**不知道本回合会用什么工具**,但知道**本会话还没用过哪些** ⇒ 逐个把"未用过的陷阱工具"预告一遍,
362
- // 每个工具只预告一次(用过即止)= 把"事后回查"提前成"动手前预检",不依赖历史失败。
363
- if (cfg.preflightFirstUse !== false) {
364
- const nextTool = Object.keys(map).find((t) => !usedTools.has(t) && !announcedTraps.has(t));
365
- if (nextTool) {
366
- const h = hintForTool(nextTool);
367
- if (h) {
368
- announcedTraps.add(nextTool);
369
- emitPreflight({ tool: nextTool, entry: h.id, hintSource: h.hintSource, mode: 'first-use' });
370
- return trim80('[坑前提示·首用] ' + nextTool + ' 还没用过:' + h.act + ' —— 条目 ' + h.id);
371
- }
372
- }
373
- }
374
- return null;
375
- } catch { return null; }
376
- }
377
- });
378
- }
379
-
380
- // 极限观测行(2026-10-11,用户令):轮次维度的机械钩子——把「该查库了」变成可见信号,不靠记性。
381
- // 只在知道轮次时渲染(拿不到真值就保持静默,不伪造)。硬约束同 C1:**一行、≤80 字**。
382
- if (cfg.extremeObs !== false) {
383
- ctx.systemPrompt.context({
384
- name: 'akasha:extreme',
385
- order: (cfg.pulseOrder ?? 132) + 2,
386
- text: () => {
387
- try {
388
- if (!obs.turn) return null;
389
- const gap = obs.lastRecallTurn === null ? '(本进程尚未查库)' : '距今 ' + Math.max(0, obs.turn - obs.lastRecallTurn) + ' 轮';
390
- // 口径标注(2026-10-11):轮次/压缩/查库均为**本进程**统计——重启即归零;不标会被读成"整场会话"
391
- const line = '[极限观测·本进程] 轮=' + obs.turn + ' 压缩=' + obs.compactions + ' 上次查库=' + gap;
392
- return line.length <= 80 ? line : line.slice(0, 79) + '…';
393
- } catch { return null; }
394
- }
395
- });
396
- }
397
-
398
- // 压缩补偿行(2026-10-11 用户方向:"针对压缩轮次增加,逐步强行增加使用库查记忆进行补全")。
399
- // 依据(当晚实测):① 压缩摘要只保话题、**丢约定**——四条核心约定存活率 0–21%,而**每回合注入的称呼层 100% 存活**;
400
- // ② 「丢记忆」实例(turn 220)落在**压缩后 15 轮内**(压缩在 205)。
401
- // ⇒ 分级补偿:**脆弱窗**(距上次压缩 ≤15 轮)要求"重要结论先落库";**压缩 ≥3** 且本回合尚未查库 ⇒ 要求先查;
402
- // **压缩 ≥5** ⇒ 升级为强制;**查过即不再催**(闭环,避免噪音)。
403
- const RECALL_WINDOW = 15;
404
- if (cfg.compensate !== false) {
405
- ctx.systemPrompt.context({
406
- name: 'akasha:compensate',
407
- order: (cfg.pulseOrder ?? 132) + 3,
408
- text: () => {
409
- try {
410
- if (!obs.turn || !obs.compactions) return null; // 没压缩过 ⇒ 不打扰
411
- const sinceComp = obs.lastCompactionTurn === null ? null : Math.max(0, obs.turn - obs.lastCompactionTurn);
412
- const recalledRecently = obs.lastRecallTurn !== null && (obs.turn - obs.lastRecallTurn) <= 2;
413
- const inWindow = sinceComp !== null && sinceComp <= RECALL_WINDOW;
414
- if (obs.compactions >= 5 && !recalledRecently) {
415
- return clip('[压缩补偿·强制] 本会话已压缩 ' + obs.compactions + ' 次:关键动作前先 akasha_lookup/brief 补全,再动手');
416
- }
417
- if (obs.compactions >= 3 && !recalledRecently) {
418
- return clip('[压缩补偿] 已压缩 ' + obs.compactions + ' 次' + (inWindow ? '(脆弱窗 距上次 ' + sinceComp + ' 轮)' : '') + ':本回合先查库再答');
419
- }
420
- if (inWindow) {
421
- return clip('[压缩补偿·脆弱窗] 距上次压缩 ' + sinceComp + ' 轮:重要结论先落库,动手前先查');
422
- }
423
- return null;
424
- } catch { return null; }
425
- }
426
- });
427
- }
428
-
429
- // 核心约定常驻行(2026-10-11 用户裁定):**防目标漂移**——压缩丢的是"我们怎么做事"(实测五条约定存活 0–21%),
430
- // 而每回合注入的称呼层 100% 存活 ⇒ 把核心约定也做成**常驻**。**库驱动**:渲染时读 canon 的
431
- // `canon-core-conventions` 当前版本的 `line` 字段 ⇒ **改约定=改条目**(不碰代码);读不到 ⇒ 内置兜底(永不为空)。
432
- // 行宽**破例放宽到 160 字**(其余行均为 80):它是漂移锚,值得多占一行。
433
- if (cfg.conventions !== false) {
434
- const CONV_ID = 'canon-core-conventions';
435
- const FALLBACK_CONVENTIONS = '库无界·装载有界|来源态四标签|修订链:追加不改原文|零编造(红线)|称呼:Master/大肥鱼';
436
- const clipLong = (s) => (s.length <= 160 ? s : s.slice(0, 159) + '…');
437
- ctx.systemPrompt.context({
438
- name: 'akasha:conventions',
439
- order: (cfg.pulseOrder ?? 132) + 4,
440
- text: () => {
441
- try {
442
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
443
- const recs = lib.currentRecords(lib.loadStore('canon').records);
444
- const rec = recs.filter((r) => String(r.id) === CONV_ID || String(r.id).startsWith(CONV_ID + '-r')).pop();
445
- const text = (rec && typeof rec.line === 'string' && rec.line.trim()) ? rec.line.trim() : FALLBACK_CONVENTIONS;
446
- return clipLong('[约定] ' + text);
447
- } catch { return clipLong('[约定] ' + FALLBACK_CONVENTIONS); }
448
- }
449
- });
450
- }
451
-
452
- // ① 语义钩子 + 显式钩子(2026-10-11 用户令「开搞」;补设计稿 §2.4 五类钩子中缺的两类)
453
- // · **显式钩子**("上次那个"):读**本会话最近 intent 段**(会话层=用户消息的摘要)⇒ 检测指代词 ⇒ 检索六库 ⇒ **一行**指路。
454
- // ⚠ 已知限制(如实):数据源是**摘要**而非原话,摘要可能丢掉指代词 ⇒ **宁可漏触发不误触发**;
455
- // 若要原话级,需让 hooks 侧记录 user-message 行(待办)。
456
- // · **语义钩子**("当前处境作为查询"):本会话近 30 分钟内有工具失败 ⇒ 用工具名检索六库 ⇒ **一行**指路。
457
- // 两钩子**每回合最多出一行**,优先级 显式 > 语义;都无触发 ⇒ **静默**(不制造噪音);可关 `recallHook:false`。
458
- if (cfg.recallHook !== false) {
459
- const PRONOUN = /(上次|之前|上回|那天|那个|说过|记得|当时|前面说|前面提)/;
460
- const recentIntentGist = () => {
461
- try {
462
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
463
- const rows = lib.loadStore('session').records.filter((r) => r.kind === 'intent');
464
- const last = rows[rows.length - 1];
465
- return last ? String(last.gist || '') : '';
466
- } catch { return ''; }
467
- };
468
- const clip120 = (s) => (s.length <= 120 ? s : s.slice(0, 119) + '…');
469
- ctx.systemPrompt.context({
470
- name: 'akasha:recall',
471
- order: (cfg.pulseOrder ?? 132) + 5,
472
- text: () => {
473
- try {
474
- const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
475
- // 显式钩子(指代)
476
- const gist = recentIntentGist();
477
- if (gist && PRONOUN.test(gist)) {
478
- // 去掉**所有**指代词(2026-10-11 修:replace 无 g 标志时只替一次,残留「那个」会污染查询)
479
- const q = gist.replace(new RegExp(PRONOUN.source, 'g'), ' ').replace(/\s+/g, ' ').trim().slice(0, 60) || gist.slice(0, 60);
480
- const r = lib.lookupDetailed(q, { limit: 2, contextBudget: false });
481
- const ids = (r.hits || []).map((h) => String(h.id)).slice(0, 2).join(' · ');
482
- if (ids) return clip120('[指代检索] 像是问旧事 → 先看:' + ids);
483
- }
484
- // 语义钩子(近失败)
485
- const f = recentFailures[recentFailures.length - 1];
486
- if (f && obs.turn && Date.now() - f.ts < 30 * 60 * 1000) {
487
- const r = lib.lookupDetailed(String(f.tool || ''), { limit: 2, contextBudget: false });
488
- const top = (r.hits || [])[0];
489
- if (top) return clip120('[处境检索] ' + f.tool + ' 近 30 分钟内失败过 → 相关条目 ' + top.id + ':' + String(top.snippet || '').slice(0, 42));
490
- }
491
- return null;
492
- } catch { return null; }
493
- }
494
- });
495
- }
496
-
497
- // 空闲触发(事件失败开放)。
498
- ctx.on('agent/status', (payload) => {
499
- try { if (payload?.status === 'idle') maybeSleep('idle'); } catch { /* 静默 */ }
500
- });
501
-
502
- // 定时兜底:宿主长期不空闲也能按节奏睡。
503
- // ⚠️ timer 是「混入服务」:访问 ctx.interval 需要 inject:['timer'],否则属性访问本身抛出
504
- // "cannot get property \"timer\" without inject"——v1.1.0 事故:整个 apply 失败、插件静默失活。
505
- // ctx.interval 返回的停止函数再挂到本插件自己的 effect 上,卸载即停。
506
- if (typeof ctx.interval === 'function') {
507
- try {
508
- const stop = ctx.interval(() => { try { maybeSleep('timer'); } catch { /* 静默 */ } }, cfg.timerCheckMs);
509
- ctx.effect(() => () => { try { stop(); } catch { /* 静默 */ } });
510
- } catch { /* 静默 */ }
511
- }
512
-
513
- // 首次激活即跑一次,此后按去抖节奏。
514
- const boot = readState();
515
- if (!boot.lastRunAt) runSleep('activate');
516
-
517
- log({ kind: 'sleep-armed', pid: process.pid, minIntervalHours: cfg.minIntervalHours, timerCheckMs: cfg.timerCheckMs });
518
- if (llmSkipNote) log({ kind: 'wake-note-llm-skip', message: llmSkipNote });
519
- }
223
+ // Empty optional prompts must use a string at the Harness API boundary.
224
+ const registerContext = ({ text, ...entry }) => ctx.systemPrompt.context({
225
+ ...entry,
226
+ text: typeof text === 'function'
227
+ ? (context) => text(context) ?? ''
228
+ : text ?? '',
229
+ });
230
+ const require_ = createRequire(import.meta.url);
231
+ const reportDir = join(cfg.akashaDir, 'logs');
232
+ const stateFile = cfg.stateFile || join(reportDir, 'sleep-state.json');
233
+ const inboxFile = cfg.inboxFile || join(reportDir, 'inbox.jsonl');
234
+
235
+ const log = (record) => {
236
+ try {
237
+ mkdirSync(dirname(cfg.log), { recursive: true });
238
+ appendFileSync(cfg.log, JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n', 'utf8');
239
+ } catch { /* 静默:观测线写不了不拖垮宿主 */ }
240
+ };
241
+ const readState = () => { try { return JSON.parse(readFileSync(stateFile, 'utf8')); } catch { return {}; } };
242
+
243
+ // 核心库(蒸馏逻辑)动态加载:插件只做调度与注入;缺文件时降级留痕(不失活)。
244
+ const core = () => require_(join(cfg.akashaDir, 'sleep.mjs'));
245
+
246
+ const runSleep = (trigger) => {
247
+ try {
248
+ const r = core().sleepRun({ trigger, log: cfg.log, stateFile, inboxFile, reportDir });
249
+ if (!r.ok) { log({ kind: 'sleep-error', trigger, message: String(r.error).slice(0, 300) }); return false; }
250
+ log({ kind: 'sleep-done', trigger, report: r.reportFile, todo: r.todo, counters: r.state?.lastCounters, audit: r.state?.lastAudit });
251
+ return true;
252
+ } catch (error) {
253
+ log({ kind: 'sleep-error', trigger, message: String(error?.message ?? error).slice(0, 300) });
254
+ return false;
255
+ }
256
+ };
257
+
258
+ const maybeSleep = (trigger) => {
259
+ try {
260
+ const verdict = core().shouldSleep(readState(), Date.now(), cfg.minIntervalHours);
261
+ if (!verdict.due) { log({ kind: 'sleep-skip', trigger, sinceHours: verdict.hoursSince }); return; }
262
+ runSleep(trigger);
263
+ } catch (error) {
264
+ log({ kind: 'sleep-error', trigger, message: String(error?.message ?? error).slice(0, 300) });
265
+ }
266
+ };
267
+
268
+ // 唤醒条(M6b):新会话(agent/created)自动把「起床包」摘要递进 next-step inbox(不唤醒、不打断)。
269
+ // 仅当有内容(无睡眠记录 / 有待办 / 审计警告)才说话;子代理与 compact 不打扰;一切异常只留痕。
270
+ // 消息构造用模块级 createUserMessage(见文件头;本地实现、不动态加载宿主包)。
271
+ const llmSkipNote = cfg.wakeNote === false ? 'wakeNote=false:唤醒条注入跳过(其余功能不受影响)' : null;
272
+
273
+ const injectWakeNote = (agent) => {
274
+ try {
275
+ if (!agent || typeof agent.inject !== 'function' || !createUserMessage) return;
276
+ const k = require_(join(cfg.akashaDir, 'lib.mjs')).kit();
277
+ if (!isWakeNoteworthy(k)) return;
278
+ const message = createUserMessage({
279
+ content: [{ type: 'text', text: safeInject(buildWakeNote(k)) }], // #14 保守包裹:唤醒条内含 kit(库内容:待办/审计/脉搏);agent.inject 是否也走插值**未验证** ⇒ 包上无害 ✓(2026-10-11 手检)
280
+ source: { kind: 'akasha-wake' }
281
+ });
282
+ agent.inject(message);
283
+ log({ kind: 'wake-note', session: String(agent.id ?? ''), todo: k.inbox?.totalItems ?? 0, auditOk: k.review?.ok ?? null });
284
+ } catch (error) {
285
+ log({ kind: 'wake-note-error', message: String(error?.message ?? error).slice(0, 200) });
286
+ }
287
+ };
288
+
289
+ ctx.on('agent/created', (payload) => {
290
+ try {
291
+ // 2026-10-07 复查:官方 agent/created 的 source 只有 startup/resume;子代理守卫沿用 header.origin / delegationDepth。
292
+ const agent = payload?.agent;
293
+ const header = agent?.session?.header;
294
+ if (header?.origin === 'subagent' || (header?.delegationDepth ?? 0) > 0) return;
295
+ if (cfg.wakeNote === false) return;
296
+ injectWakeNote(agent);
297
+ } catch (error) {
298
+ log({ kind: 'wake-note-error', message: String(error?.message ?? error).slice(0, 200) });
299
+ }
300
+ });
301
+
302
+ // —— I-12b 行为层(2026-10-13 深夜 · **按 Master 裁定「重挂(默认关)」重挂**)——
303
+ // 前史:同位置旧钩子(判据=「流无正文」)因 **形态 A 误报 + 扰动循环** 被裁定删除(canon-idle-hook-removed-20261013)。
304
+ // **物证(2026-10-13 复查)**:旧实现包 `local-akasha-sleep-1.8.1-h1.tgz` 仍在——其链路=「**流 end 帧**判定 →
305
+ // `agent.inject(createUserMessage{source.kind:'akasha-resume'})`」,三级降级全部实测拉回成功(canon-idle-resume-verified-20261013)。
306
+ // ⇒ 本版**沿用该有效链路**(判定点=attempt `end` 帧、注入 API = agent.inject),**只换判据与护栏**:
307
+ // ① 判据收窄至**形态 B**(该 attempt:无正文 + 无工具调用;A 形态=正常中间步骤,绝不报,只落观测线)
308
+ // + 强信号=思维链尾部「好。」;
309
+ // ② 干预**默认关**(`idleResume:true` 才开;观测线 `idle-shape` 无论开关都落——先观测);
310
+ // ③ 防扰动循环:冷却 idleResumeCooldownMs + 单会话限次 idleResumeMax + 文案禁清单/打勾/句号收尾(机制报告 §⑥)。
311
+ // 证据链:canon-idle-mechanism-20261013-r1 · research/lora-cot-degradation-investigation-20261013.md
312
+ const idleAttempts = new Map(); // attemptId -> { sid, text, tools, tail }(按 attempt 收,end 帧判完即删)
313
+ const idleGate = new Map(); // sid -> { count, lastAt }
314
+ const idleTailChars = Number.isInteger(cfg.idleTailChars) && cfg.idleTailChars > 0 ? cfg.idleTailChars : 32;
315
+ const isSubagentAgent = (agent) => {
316
+ const h = agent?.session?.header;
317
+ return h?.origin === 'subagent' || (h?.delegationDepth ?? 0) > 0;
318
+ };
319
+ const idleEvaluate = (agent, a, meta = {}) => {
320
+ const sid = String(agent?.session?.id ?? agent?.id ?? '');
321
+ if (!sid || isSubagentAgent(agent)) return;
322
+ const j = judgeIdleTurn({ hasText: a.text, toolCalls: a.tools, tail: a.tail });
323
+ if (j.shape !== 'normal') log({ kind: 'idle-shape', session: sid, shape: j.shape, signal: j.signal, tools: a.tools, turn: meta.turn ?? null, step: meta.step ?? null });
324
+ const strong = j.signal === 'hao' || (cfg.idleResumeOnWeak === true && j.signal === 'o');
325
+ const gate = idleGate.get(sid) ?? { count: 0, lastAt: 0 };
326
+ const now = Date.now();
327
+ const eligible = j.stall && strong
328
+ && gate.count < (Number.isInteger(cfg.idleResumeMax) ? cfg.idleResumeMax : 3)
329
+ && (now - gate.lastAt) >= (Number.isFinite(cfg.idleResumeCooldownMs) ? cfg.idleResumeCooldownMs : 0);
330
+ if (cfg.idleResume === true && eligible && typeof agent?.inject === 'function') {
331
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: IDLE_RESUME_TEXT }], source: { kind: 'akasha-idle-resume' } }));
332
+ idleGate.set(sid, { count: gate.count + 1, lastAt: now });
333
+ log({ kind: 'idle-resume', session: sid, turn: meta.turn ?? null, step: meta.step ?? null, count: gate.count + 1, signal: j.signal });
334
+ }
335
+ };
336
+ ctx.on('agent/assistant-stream', (payload) => {
337
+ try {
338
+ const agent = payload?.agent;
339
+ const frame = payload?.frame;
340
+ const frameType = String(frame?.type ?? '');
341
+ const attemptId = String(frame?.attemptId ?? '');
342
+ if (!frameType) return;
343
+ if (frameType === 'start') {
344
+ idleAttempts.set(attemptId || String(idleAttempts.size), { sid: String(agent?.session?.id ?? agent?.id ?? ''), text: false, tools: 0, tail: '' });
345
+ if (idleAttempts.size > 30) idleAttempts.delete(idleAttempts.keys().next().value); // 保新:只留最近 30 条流
346
+ return;
347
+ }
348
+ if (frameType === 'end') {
349
+ const key = attemptId || '';
350
+ const a = idleAttempts.get(key);
351
+ idleAttempts.delete(key);
352
+ if (!a) return;
353
+ idleEvaluate(agent, a, { turn: frame?.turn ?? null, step: frame?.step ?? null });
354
+ return;
355
+ }
356
+ if (frameType !== 'chunk') return;
357
+ const c = frame?.chunk;
358
+ if (!c || typeof c.type !== 'string') return;
359
+ const key = attemptId || 'anon';
360
+ let a = idleAttempts.get(key);
361
+ if (!a) { a = { sid: String(agent?.session?.id ?? agent?.id ?? ''), text: false, tools: 0, tail: '' }; idleAttempts.set(key, a); }
362
+ if (c.type === 'text-delta') a.text = true;
363
+ else if (c.type === 'tool-call-delta') a.tools += 1;
364
+ else if (c.type === 'reasoning-delta') a.tail = (a.tail + String(c.text ?? '')).slice(-idleTailChars);
365
+ } catch { /* 观测不拖垮宿主 */ }
366
+ });
367
+
368
+ // 条子:每个新回合的 runtime context 自动带上最近一次睡眠摘要(渲染自核心库,失败安全)。
369
+ registerContext({
370
+ name: 'akasha:sleep',
371
+ order: cfg.contextOrder,
372
+ text: (context) => {
373
+ retarget(renderSidOf(context));
374
+ try { return core().renderContextLine(readState()); }
375
+ catch { return '阿卡夏·睡眠:核心库不可用(akasha\\sleep.mjs 缺失或损坏)。'; }
376
+ }
377
+ });
378
+
379
+ // 回查提示(v1.5,错误钩子的注入级动作):进程内记录近期失败(工具 / agent),窗口内幂等渲染。
380
+
381
+ // 触发词路由(2026-10-11 解冻后新增):**最近的用户消息文本** + 已触发过的条目(去重)。
382
+ // ⚠ 只存内存、只在进程内、不落盘;超 TTL 视为过期(避免拿很久以前的任务文本乱触发)。
383
+ const USER_TEXT_TTL = 10 * 60 * 1000;
384
+ /** 从 session/event 的 user/message 事件里**防御式**取正文(载荷形状随宿主版本可能不同)。 */
385
+ const textOfUserEvent = (p) => {
386
+ try {
387
+ const d = p?.data ?? p ?? {};
388
+ const cand = [d?.text, d?.content, d?.message?.text, d?.message?.content,
389
+ d?.parts, d?.message?.parts, d?.message];
390
+ for (const c of cand) {
391
+ if (typeof c === 'string' && c.trim()) return c.trim();
392
+ if (Array.isArray(c)) {
393
+ const s = c.map((x) => (typeof x === 'string' ? x : (x && typeof x.text === 'string' ? x.text : ''))).join(' ').trim();
394
+ if (s) return s;
395
+ }
396
+ }
397
+ } catch { /* 取不到就当作没消息 */ }
398
+ return '';
399
+ };
400
+ // ── F09/F10(2026-10-14 外部审查 · GPT):**会话状态按 session 分桶** ─────────────────────────────
401
+ // 原实现把 观测计数(轮/压缩)/失败列表/首用集合/触发去重/去抖状态 全放进程级 ⇒ 换会话后新会话继承旧会话的
402
+ // 「已压缩 N 次」「上次失败是 edit」等状态(上下文污染 + 抑制当前提醒)。现在全部进**会话桶**:
403
+ // 事件先 retarget(sidOf(payload)) 切桶,渲染面取当前桶;会话 id 缺失时归 '(unknown)' 桶(最多 8 桶 LRU)。
404
+ // F10 同批:C3 的「已触发」从**永久布尔**改为**当轮标记**(c3Turn)——不再是「一次触发压制到重载」。
405
+ const sessionStates = new Map();
406
+ let curSid = '(unknown)';
407
+ const sidOf = (p) => {
408
+ try {
409
+ const a = p?.agent ?? p?.data?.agent;
410
+ const s = (typeof p === 'string' ? p : null) ?? p?.id ?? a?.session?.id ?? a?.id ?? p?.session?.id ?? p?.session ?? p?.data?.session ?? p?.sessionId;
411
+ return s === undefined || s === null || s === '' ? null : String(s);
412
+ } catch { return null; }
413
+ };
414
+ const freshState = () => ({
415
+ obs: { turn: 0, steps: 0, compactions: 0, usedToolsN: 0, lastRecallTurn: null, lastCompactionTurn: null },
416
+ recentFailures: [], usedTools: new Set(), announcedTraps: new Set(),
417
+ lastUserText: { text: '', at: 0 }, firedTriggers: new Map(), broughtUp: { at: 0, id: null },
418
+ gateState: { c3Cat: null, c3At: 0, c3Turn: -1, gateCat: null, gateAt: 0, gateTurn: -1 }, preflight: { tool: null, at: 0 },
419
+ });
420
+ const S = () => {
421
+ if (!sessionStates.has(curSid)) sessionStates.set(curSid, freshState());
422
+ const s = sessionStates.get(curSid);
423
+ sessionStates.delete(curSid); sessionStates.set(curSid, s);
424
+ while (sessionStates.size > 8) sessionStates.delete(sessionStates.keys().next().value);
425
+ return s;
426
+ };
427
+ // R04:**无 sid 不继承**——缺身份的输入归 `(unknown)` 桶,绝不沿用上一会话(来源不明 ⇒ 不碰敏感提醒)。
428
+ const retarget = (sid) => { curSid = sid ? String(sid) : '(unknown)'; };
429
+ const renderSidOf = (context) => sidOf(context?.agent ?? context?.session ?? context);
430
+ // Q06:normSid 归一弃用(见 latestIntentGist 的精确优先实现;生产者改存完整 ID)。
431
+ const noteFailure = (tool) => {
432
+ try {
433
+ S().recentFailures.push({ ts: Date.now(), tool: String(tool ?? '?') });
434
+ if (S().recentFailures.length > 20) S().recentFailures.splice(0, S().recentFailures.length - 20);
435
+ } catch { /* 静默 */ }
436
+ };
437
+ // 极限观测(2026-10-11 用户令):把「该查库了」变成**可见信号**,不靠记性——针对"200 轮后忘记查库"的观察。
438
+ const clip = (s) => (s.length <= 80 ? s : s.slice(0, 79) + '…'); // 行宽硬约束的统一裁剪(模块级,供多行共用)
439
+ const isRecallTool = (name) => /^mcp__akasha__/.test(String(name || ''));
440
+ ctx.on('tools/result', (exec, result) => {
441
+ retarget(sidOf(exec));
442
+ const tool = String(exec?.name ?? '?');
443
+ S().usedTools.add(tool); // 首用预告用(2026-10-11)
444
+ S().obs.usedToolsN = S().usedTools.size;
445
+ if (isRecallTool(tool)) S().obs.lastRecallTurn = S().obs.turn; // MCP 召回 ⇒ 刷新"距上次查库"
446
+ else {
447
+ // CLI 查库也算召回(2026-10-11 实测:本会话大量查库走 `node akasha.mjs …`,只认 MCP 会显示"尚未查库"=自欺)
448
+ try {
449
+ // N11(GPT 第三轮):宿主 ToolExecution 的执行参数在 **exec.arguments**(dsh-tools 契约);args/input/params
450
+ // 只作旧夹具兼容——前者优先,避免「真实 CLI 查库后仍显示未查库」。
451
+ const blob = JSON.stringify(exec?.arguments ?? exec?.args ?? exec?.input ?? exec?.params ?? '');
452
+ if (/akasha\.mjs|session\.mjs|mcp\.mjs/.test(blob)) S().obs.lastRecallTurn = S().obs.turn;
453
+ } catch { /* 忽略 */ }
454
+ }
455
+ if (result?.isError === true) noteFailure(exec?.name);
456
+ });
457
+ ctx.on('agent/error', (p) => { retarget(sidOf(p)); noteFailure('agent'); });
458
+ // 轮次与压缩真值:能拿到就用真值,拿不到就保持 0(宁缺勿假)
459
+ try { ctx.on('agent/turn-stopping', (p) => { retarget(sidOf(p)); if (p && typeof p.turn === 'number') S().obs.turn = p.turn; }); } catch { /* 事件不存在 */ }
460
+ // ⚠ 2026-10-16 独立审修复:原 `ctx.on('agent/turn-end', …)` 是**死监听**——宿主不存在该事件
461
+ // (权威面:dsh-scope/scoped-events.generated.js 与 dsh-tool-cordis api-catalog 的 agent/* 列表只有
462
+ // turn-stopping/status/…,无 turn-end;全树 grep 'agent/turn-end' 亦零命中)。原两处注册(本行旧
463
+ // obs.turn 更新 + 下方 review-lag)因此从未触发。改走 **durable 路由** session/event type='turn/end'
464
+ // (与 akasha-hooks / dsh-akasha-session 同一路由;hooks.jsonl 的 kind:'turn-end' 即由它产生)。
465
+ // 审核面滞后(2026-10-16 收 · 独立审 2026-10-16 修):
466
+ // 结果改存**进程级单例**(检查与会话无关:查的是仓库面,按会话分桶只会 N 倍跑进程且显示分散)。
467
+ // 安全:60s 节流、5s 超时、全 try;exit 1(有滞后)是**预期路径**不是故障;任何异常都不得影响回合。
468
+ const REVIEW_LAG_INTERVAL = 60 * 1000;
469
+ // reviewLagState 已在**模块级**(2026-10-16 batch6 补丁 D)——本实例与将来任何实例共用同一份节流与结果。
470
+ // P4b(2026-10-16 审修·观测面):**状态变化才落线**(kind:'review-lag',code + 首行截断)——原实现成功/失败都不留痕,
471
+ // 事后连「钩子跑没跑过」都无从核;不改 60s 节拍,只把结果变化记进 hooks.jsonl 供审计对照。
472
+ // D1(2026-10-16 batch7 · 观测线字段面变更,VERIFY-S2 §5-D1):**raw 记非滞后态的真因**——
473
+ // 1.10.14 里 code:-1 同时承担「工具没装 / 语法错 / 超时 / 其它退出码」四类,事后审计无法区分
474
+ // 「工具没装」与「工具跑了但崩了」。raw 取值域(**凡非滞后态都带 raw**;⚠ 滞后态**同样带 raw**
475
+ // ——batch8·F1 更正:batch7 原文写「滞后态不带」,与代码相反、实测滞后行 = `{"code":1,"raw":1,…}`;
476
+ // `code + line` 已足够表达「有滞后」,故 raw 在此只作退出码确证):
477
+ // · number —— 子进程真实退出码(如 3、1(无 stdout,如语法错))
478
+ // · 'ETIMEDOUT' —— 超时被杀(execFileSync:status 为 null、error.code 为该值;
479
+ // ⚠ 实测这条**同时带** signal='SIGTERM' ⇒ rawOfError 里 code 优先于 signal,才拿到 'ETIMEDOUT')
480
+ // · 'ENOBUFS' —— 子进程 stdout 超 maxBuffer(batch8·F6 更正分类:**不是** spawn 级)
481
+ // · 'SIG:<name>' —— 被信号杀死(batch8·F6:`e.signal` 存在且 **code 缺失** 时记;POSIX 可达,
482
+ // 本机 Windows 不可达——超时路径虽带 SIGTERM,但 code='ETIMEDOUT' 先命中)
483
+ // · 'ENOENT' / 'EACCES' 等 **spawn 级** error.code —— 只在**可执行本体 / cwd 找不到**时出现
484
+ // (batch8·F2 更正:`node <缺脚本>` **不落这一格**——7 种调用形态实测全为 status:1、
485
+ // code=undefined;node 起得来、在模块解析阶段失败,stderr 打 MODULE_NOT_FOUND。脚本路径不存在 /
486
+ // 目录不存在 / 路径是目录三种「工具起不来」⇒ raw=1,见 batch7 landing/tools/probe-h4.mjs
487
+ // 与 batch7 VERIFY-S3 §1.2)
488
+ // · '-' —— 编号与字符串都取不到(兜底)
489
+ // 去重键含 raw(原因不同各留一行,不被去重吞掉);显示面与 `isLag` 判据不变。
490
+ // ⚠ batch8·F5:**键与存储同粒度**——键用截断后的 `shown`(旧写法键用**未截断** `v.line`、
491
+ // 存储 `slice(0,80)` ⇒ 只在第 80 字之后不同的两条真状态会落成两行「除 ts 外逐字节相同」的记录)。
492
+ const noteReviewLag = (v) => {
493
+ try {
494
+ reviewLagState.value = v;
495
+ reviewLagState.checkedAt = Date.now(); // O1:本次检查的时点(与 value 同步更新,不参与判定)
496
+ const shown = String(v.line ?? '').slice(0, 80);
497
+ const key = `${v.code}|${v.raw ?? ''}|${shown}`;
498
+ if (key !== reviewLagState.logged) {
499
+ reviewLagState.logged = key;
500
+ log({ kind: 'review-lag', code: v.code, ...(v.raw !== undefined ? { raw: v.raw } : {}), line: shown });
501
+ }
502
+ } catch { /* 静默:观测线写不了不拖垮回合 */ }
503
+ };
504
+ // D3(2026-10-16 batch7 · 契约路线,VERIFY-S2 §5-D3):**工具首行必须走 stdout**(当前真工具
505
+ // `akasha/tools/review-lag.mjs` 走 console.log,符合;该文件属 `akasha/tools/**`,本批不动,
506
+ // 契约写在消费侧这里)。判据只认 `code===1 && stdout 首条非空行`:真滞后若改走 stderr ⇒ 会
507
+ // 被判成「工具起不来」⇒ 显示面静默、观测线 `-1`(**新的静默失败模式**——BEFORE 1.10.14 至少
508
+ // 还会显示一行「有滞后」)。
509
+ // ⇒ 改工具输出通道(换 stderr / 套一层 wrapper 吞掉 stdout)=改契约,须同批改本判据;
510
+ // 未选「判据放宽到认 stderr 标记」的理由见 batch7 landing README §2-D3:那会为一条**没写下来的
511
+ // 契约**增加新的判据面(stderr 文本匹配),且任何在 stderr 打印该标记的工具都会被算成「有滞后」。
512
+ const runReviewLag = () => {
513
+ try {
514
+ if (Date.now() - reviewLagState.at < REVIEW_LAG_INTERVAL) return;
515
+ reviewLagState.at = Date.now();
516
+ execFileSync(process.execPath, [join(cfg.akashaDir, 'tools', 'review-lag.mjs')],
517
+ {
518
+ // F8(batch8):超时值改 cfg 可注入(默认 5000)——测试实等 5 s 会永久拖慢回归。
519
+ encoding: 'utf8', timeout: cfg.reviewLagTimeoutMs || 5000, stdio: ['ignore', 'pipe', 'pipe'],
520
+ // B(2026-10-16 batch6 加固):宿主里 process.execPath 是 **Electron 壳**,靠宿主 env 的
521
+ // ELECTRON_RUN_AS_NODE=1 才当 node 跑(本机实测继承链上确有该变量)。显式钉住=不赌启动环境:
522
+ // 缺它时这一行会去**拉起整只桌面端**。
523
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
524
+ });
525
+ noteReviewLag({ code: 0, line: '无滞后' });
526
+ } catch (e) {
527
+ // D1:raw = 真因(见 noteReviewLag 头的取值域),code 仍只表达滞后 / 非滞后两态。
528
+ // F6(batch8):分类逻辑提到顶层 `rawOfError`(POSIX 信号 ⇒ 'SIG:<name>';其余同前)
529
+ const raw = rawOfError(e);
530
+ const first = String(e?.stdout ?? '').split('\n').find((l) => l.trim());
531
+ // A(2026-10-16 batch6 修 · 批次 5 复核发现的缺陷 #4):exit 1 **且 stdout 空** ⇒ 工具自身起不来
532
+ // (脚本缺失 / 语法错 / 依赖缺失——node 的启动失败同样 exit 1,实测),不是"有滞后"。
533
+ // 真·有滞后时工具**必打印首行** ⇒ 以 stdout 有无作干净判据;起不来记 code:-1:观测线仍留痕
534
+ // (可事后核"它跑过"),显示面静默。
535
+ const isLag = raw === 1 && !!first;
536
+ noteReviewLag(isLag ? { code: 1, raw, line: first } : { code: -1, raw, line: '' });
537
+ }
538
+ };
539
+ try {
540
+ ctx.on('session/event', (sessOrP, maybeEvent) => {
541
+ // R05(GPT 复核二轮):宿主实际按 **(session, event)** 发布(dsh-session:callbackArgs=[session,event]);
542
+ // 兼容旧单参夹具:event 缺省时退化。身份取 session.id;内容取 event(type/data)。
543
+ const sess = maybeEvent !== undefined ? sessOrP : null;
544
+ const p = maybeEvent !== undefined ? maybeEvent : sessOrP;
545
+ retarget(sidOf(sess) ?? sidOf(p));
546
+ const t = String(p?.type ?? p?.name ?? p?.eventType ?? '');
547
+ // R04/R05:回合真值也接 **turn/start**(取消/异常回合不更新 turn-stopping ⇒ C3 当轮标记须靠 start 失效)。
548
+ if (t === 'turn/start') { const tn = Number(p?.data?.turn ?? p?.turn); if (Number.isFinite(tn)) S().obs.turn = Math.max(S().obs.turn, tn); }
549
+ // 2026-10-16 独立审修复:turn/end 也带 data.turn(dsh-session:data={turn,reason})——补进轮次真值来源;
550
+ // 并在**durable** 的 turn/end 上跑审核面滞后检查(原 agent/turn-end 死监听,见上方注)。
551
+ if (t === 'turn/end') {
552
+ const tn = Number(p?.data?.turn ?? p?.turn);
553
+ if (Number.isFinite(tn)) S().obs.turn = Math.max(S().obs.turn, tn);
554
+ runReviewLag();
555
+ }
556
+ if (t.startsWith('compaction/end') || (t === 'compaction' && p?.phase === 'end')) {
557
+ // N10(GPT 第三轮):**失败压缩不计成功**——宿主失败路径同样发 end(data.error 非空)⇒ 此前把失败算成
558
+ // 「已压缩 N 次」并触发多余补查。成功才计数与更新脆弱窗;失败另计(观测用,不触发提醒)。
559
+ const failed = !!(p?.data && p.data.error) || !!p?.error;
560
+ if (failed) S().obs.compactionFailed = (S().obs.compactionFailed || 0) + 1;
561
+ else {
562
+ S().obs.compactions++;
563
+ S().obs.lastCompactionTurn = S().obs.turn; // 脆弱窗判据(压缩补偿行用)
564
+ }
565
+ }
566
+ // 触发词路由(2026-10-11 解冻后新增,外部对照启发:灵枢的「触发词路由」):
567
+ // 捕获**最近的用户消息文本**,供注入面(system-prompt/assemble 时求值的函数)做触发词匹配——
568
+ // 宿主注入面拿不到当前用户消息,故按灵枢的两步式:**事件里记录、组装时注入**。
569
+ // ⚠ 只存一行文本 + 时间戳(内存、不出进程、不落盘);超时即视为过期。
570
+ if (t === 'user/message') {
571
+ const s = textOfUserEvent(p);
572
+ if (s) { S().lastUserText.text = s.slice(0, 4000); S().lastUserText.at = Date.now(); }
573
+ }
574
+ });
575
+ } catch { /* 事件不存在 */ }
576
+
577
+ // 库脉搏(v1.3;v1.5 起附加回查行):写→可见通道——并行会话 / 脚本写入库后,下一回合即可见并命名新内容。
578
+ registerContext({
579
+ name: 'akasha:pulse',
580
+ order: cfg.pulseOrder,
581
+ text: (context) => {
582
+ retarget(renderSidOf(context));
583
+ try {
584
+ const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
585
+ const lines = [core().renderPulseLine(lib.pulse())];
586
+ const recall = core().renderRecallLine(S().recentFailures, Date.now());
587
+ if (recall) lines.push(recall);
588
+ // 审核面滞后(2026-10-16):改了核心件而独立审核没跟上 ⇒ 每回合带出(不靠人自觉跑 p0)
589
+ // 2026-10-16 审修:读**进程级单例**(原 S().obs 按会话分桶 ⇒ 结果落桶与渲染桶不一致时静默不显示);
590
+ // 行宽受 clip 约束(原样拼是 117 字双「⚠ 审核面滞后」前缀行,超本模块 80 字纪律)。
591
+ const rl = reviewLagState.value;
592
+ if (rl && rl.code === 1 && rl.line) {
593
+ const n = (/:(\d+)\s*个/.exec(rl.line) || [])[1];
594
+ // O1(2026-10-17 batch15):附**检查时点**——判定与节流不动,只让"这结论是什么时候的"可见
595
+ // (旧结果在 60s 节流 / turn-end 驱动的窗口里会与实跑分歧:VERIFY-A4 §5-O1 的 06:35–06:46 即此)。
596
+ // ⚠ 位置选在**前缀括号内**而不是行尾:行尾会被 80 字 clip 先截掉(长明细 + `…` 时点就没了),
597
+ // 放头部 ⇒ 无论尾巴多长,时点一定看得见;尾巴仍是可截断的"明细"。
598
+ const when = reviewLagState.checkedAt ? ` · 检于 ${stampOf(reviewLagState.checkedAt)}` : '';
599
+ lines.push(clip(`⚠ 审核面滞后(改了没审${when}):` + (n ? `${n} 件核心件在审核戳后被改` : rl.line.replace(/^⚠\s*审核面滞后:/, ''))));
600
+ }
601
+ return lines.filter(Boolean).join('\n') || null;
602
+ } catch { return null; }
603
+ }
604
+ });
605
+
606
+ // 称呼行(2026-10-10 用户复盘「长会话中期称呼易漂移」):
607
+ // 规则躺在会话开头那坨注入(akasha:self)里,**执行时看不到** ⇒ 必然漂移。
608
+ // 修法=接进每回合必然经过的执行点:这条 runtime context 行每回合都出现,内容渲染自
609
+ // canon 的称呼体系当前版本(降级为内置短句),并写明执行点要求(交付/验收/复盘的开口句)。
610
+ registerContext({
611
+ name: 'akasha:address',
612
+ order: cfg.addressOrder,
613
+ text: (context) => {
614
+ retarget(renderSidOf(context));
615
+ try {
616
+ const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
617
+ const recs = lib.currentRecords(lib.loadStore('canon').records);
618
+ const hit = recs.filter((r) => String(r.id).startsWith('canon-address-layers')).pop();
619
+ if (hit && typeof hit.claim === 'string') return safeInject(core().renderAddressLine(hit.claim)); // #14:canon claim 是库原文 ⇒ 必须包裹
620
+ } catch { /* 读库失败 → 兜底短句 */ }
621
+ return safeInject(core().renderAddressLine(''));
622
+ }
623
+ });
624
+
625
+ // C3|断言前把关(stateful-fact gate,2026-10-10;评审 v2 定稿=触发改到提问侧)
626
+ // 为什么改触发:context 行在回合开始渲染、回复尚未产生;且本插件看不到用户消息事件 ⇒
627
+ // 取**会话层最近一条 intent 段**(跳过注入样式的段)做 6 类关键词判定。
628
+ // 只提醒不阻断;statefulGate:false ⇒ 提示与事件双停。
629
+ // 2026-10-13(举一反三 S4):取 intent 的读法从「整读 session.jsonl 再 slice(-200)」改为**尾部窗口读**
630
+ // ——该文件已 21 MB 且每回合都在长,原先每次渲染都整文件进内存(实测 57 ms / 19 MB 读入)。
631
+ // 窗口 = 尾部 256 KB;起点切在行中时丢掉半行;从尾向前逐条找非注入 intent。
632
+ // 与旧口径的关系(如实):旧口径按 **200 行** 切——记录变长时会漏掉仍在窗口内的 intent;
633
+ // 新口径按**字节**切,不受单行大小影响(更大记录下更稳,属**语义改进**而非纯等价)。
634
+ // 两个纯函数(tailReadText / latestIntentGist)移到模块顶层导出,供自测做等价对照。
635
+ // 去抖状态(同类 30 分钟内只提醒一次;见下方 c3Cat 判定)
636
+ if (cfg.statefulGate !== false) {
637
+ const STATEFUL = [
638
+ { cat: "发布", re: /已发布|未发布|上架|没过审|已过审|待审/ },
639
+ { cat: "安装", re: /已安装|未安装|装好了|装上了/ },
640
+ { cat: "版本", re: /版本|version/i },
641
+ { cat: "开关/运行", re: /开关|启用|禁用|默认开|默认关|开着|关着|在跑|跑着|还在吗|开着吗|装着吗|有没有开/ },
642
+ { cat: "存在", re: /是否存在|有没有|在不在|还在吗/ },
643
+ ];
644
+ registerContext({
645
+ name: "akasha:stateful",
646
+ order: cfg.statefulOrder,
647
+ text: (context) => {
648
+ retarget(renderSidOf(context));
649
+ try {
650
+ const path = join(cfg.akashaDir, "data", "session.jsonl");
651
+ // 2026-10-15 V02 契约(GPT 复核 plugins N2 收尾):**身份未知 ⇒ 静默**——latestIntentGist 收到
652
+ // '(unknown)'/null/'' 一律 null(不得回退他会话);显式 undefined(任意会话)只保留给离线诊断。
653
+ // (上一版注释写"必须传 undefined",那是中间修法,已被哨兵契约推翻——留此以免再读错。)
654
+ let gist = null;
655
+ try { gist = latestIntentGist(tailReadText(path).text, curSid); } catch { /* 文件缺失/半行等 → 走 null */ }
656
+ if (!gist) return null;
657
+ const hit = STATEFUL.find((k) => k.re.test(gist));
658
+ if (!hit) return null;
659
+ const now = Date.now();
660
+ if (S().gateState.c3Cat === hit.cat && now - S().gateState.c3At < 30 * 60 * 1000) return null; // 同类 30 分钟去重
661
+ S().gateState.c3Cat = hit.cat; S().gateState.c3At = now; S().gateState.c3Turn = S().obs.turn; // F10:当轮标记(非永久布尔)
662
+ try {
663
+ appendFileSync(join(cfg.akashaDir, "logs", "hooks.jsonl"),
664
+ JSON.stringify({ ts: new Date().toISOString(), kind: "stateful-reminder", cat: hit.cat }) + "\n", "utf8");
665
+ } catch { /* 事件失败不影响提示 */ }
666
+ return "[断言提醒] 状态性事实——先查再答(例:versions + dist-tags 两处核对)";
667
+ } catch { return null; }
668
+ }
669
+ });
670
+ }
671
+
672
+ // 门档|重要工作自动挂(2026-10-15;用户令「逻辑上你得在做第一次代码任务或者审计之类的重要工作的时候就开始 3 次循环+独立审核」)
673
+ // 依据:canon-thinkloop-gate-20261015 + canon-gate-v2-20261015(**先 p0 机检 97s,只有升级项才开慢层**——这正是"不让每步都变长"的落点)。
674
+ // 形态:一行 ≤80 字,只提示不阻断;与 C3 同法(尾窗读 intent + 同类 30 分钟去重 + hooks 留痕);gateHints:false ⇒ 提示与事件双停。
675
+ if (cfg.gateHints !== false) { // 默认开;gateHints:false ⇒ 提示与事件双停(与 C1/C3 同规矩)
676
+ registerContext({
677
+ name: 'akasha:gate',
678
+ order: cfg.gateOrder,
679
+ text: (context) => {
680
+ retarget(renderSidOf(context));
681
+ try {
682
+ const path = join(cfg.akashaDir, 'data', 'session.jsonl');
683
+ // 2026-10-15 V02 契约(同 stateful;GPT N2):未知身份 ⇒ 静默('(unknown)'/null/'' ⇒ null),
684
+ // 不得读"全库最后一条 intent"(会被他会话触发)。gate-reminder 计数靠"已知会话 + 触发词"正常路径恢复。
685
+ let gist = null;
686
+ try {
687
+ const w = tailReadText(path);
688
+ gist = latestIntentGist(w.text, curSid);
689
+ // 2026-10-15(GPT 第二份复核·优先①·真根因):**尾窗(256 KiB)截断时"未命中"是静默的** ✗ ——
690
+ // 现场快照里最后 58 条全是 node、最近 intent 在 ~460 KB 之前 ⇒ **即使 ID 完全正确也找不到**(她双重复现)。
691
+ // 契约:**不许静默** —— 窗口不足且未命中时落一条观测线(不是"没有意图",是看不见)。
692
+ // 2026-10-16 审修:① **去重**——原实现每次渲染都写(实测 10:06:45–10:17:15Z 写 80 行 ≈ 6.6 行/分,
693
+ // 多会话并发时更密),对齐本插件其余钩子(同类 30 分钟去重)的纪律,改 **10 分钟/会话**;
694
+ // ② **字段**——`bytes` 是读入字节数(截断时=窗口值),原记成 fileBytes 无法反映"差多远" ⇒ 记真
695
+ // 大小 `size`,另留 readBytes;③ 未知身份渲染恒 null(V02 契约静默)≠ 窗口问题,不落这条线。
696
+ if (!gist && w.truncated && curSid !== '(unknown)') {
697
+ const nowMs = Date.now();
698
+ if (nowMs - (S().gateState.winMissAt || 0) > 10 * 60 * 1000) {
699
+ S().gateState.winMissAt = nowMs;
700
+ appendFileSync(join(cfg.akashaDir, 'logs', 'hooks.jsonl'), JSON.stringify({
701
+ ts: new Date().toISOString(), kind: 'gate-window-miss',
702
+ note: '尾窗截断且未命中 intent(256KiB 窗口不足)——不是"没有意图",是看不见;需扩大窗口或改为带索引的检索',
703
+ windowBytes: TAIL_WINDOW_BYTES, fileBytes: w.size ?? w.bytes, readBytes: w.bytes, sid: String(curSid),
704
+ }) + '\n', 'utf8');
705
+ }
706
+ }
707
+ } catch { /* 容缺 */ }
708
+ const hit = gateTriggerOf(gist);
709
+ if (!hit) return null;
710
+ const now = Date.now();
711
+ if (S().gateState.gateCat === hit.cat && now - S().gateState.gateAt < 30 * 60 * 1000) return null; // 同类 30 分钟去重
712
+ S().gateState.gateCat = hit.cat; S().gateState.gateAt = now; S().gateState.gateTurn = S().obs.turn;
713
+ try {
714
+ appendFileSync(join(cfg.akashaDir, 'logs', 'hooks.jsonl'),
715
+ JSON.stringify({ ts: new Date().toISOString(), kind: 'gate-reminder', cat: hit.cat }) + "\n", "utf8");
716
+ } catch { /* 事件失败不影响提示 */ }
717
+ return `[门档] 本轮属${hit.cat}类:先跑 p0 机检(akasha/tools/gate.mjs p0,97s),有升级项才开慢层`;
718
+ } catch { return null; }
719
+ }
720
+ });
721
+ }
722
+
723
+ // C1|易错点前置(pre-flight hint,2026-10-10 解冻后实现;评审 v2 定稿)
724
+ // 依据:可预防漏召率 91.2%(回合级)、会话级 100% ⇒ 缺的不是知识,是"动作前把库拉进来"。
725
+ // 形态:一行 ≤80 字,只提示不阻断;关(preflightHints:false)则**提示与事件双停**(不写假触发)。
726
+ // 近似说明(如实):context 行在**回合开始**渲染,此刻还不知道本回合会调用什么工具,
727
+ // 故用"最近失败的工具"作为**预提示**(近 30 分钟窗口 + 同工具去重);"本回合尚未查库"这一前置
728
+ // 在渲染时无法判定,改为"上一个回合没有查库动作时更倾向提示"的弱化版(v1 取"总是可提示",由去重与上限约束)。
729
+ if (cfg.preflightHints !== false) {
730
+ registerContext({
731
+ name: 'akasha:preflight',
732
+ order: cfg.preflightOrder,
733
+ text: (context) => {
734
+ retarget(renderSidOf(context));
735
+ try {
736
+ if (S().gateState.c3Turn === S().obs.turn) return null; // F10:仅同轮让位 // C3 优先(同回合只出一条)
737
+ const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
738
+ const now = Date.now();
739
+ const WINDOW = 30 * 60 * 1000;
740
+ const recent = (S().recentFailures || []).filter((f) => now - Number(f.ts ?? f.at) <= WINDOW); // 字段是 ts(见 noteFailure)
741
+ const map = lib.RECALL_TRAP_MAP || {};
742
+ // 2026-10-11 修复(用户裁定:反射要能随工程知识纠错与优化):
743
+ // **反射文本随条目走** —— 不再只认写死的表。
744
+ // ① 链感知解析(id 或 id-rN 取链尾);② 条目缺失/已退役 ⇒ **不打**(fail closed,不执行过期反射);
745
+ // ③ 优先用条目自带 `hint`(≤80 字反射文本:**改条目=改反射**);④ `hint_until` 过期 ⇒ 跳过;
746
+ // ⑤ 内置表降级为**过渡兜底**;⑥ 都取不到 ⇒ 不打(宁缺勿滥)。
747
+ // 该表仅作过渡:条目补上 hint 后即成为唯一来源。
748
+ const FALLBACK_ACTIONS = {
749
+ 'canon-trap-edit-context-mismatch': '先 read 原文再改、改完复核',
750
+ 'canon-trap-read-offset-range': '先用 grep 拿真实行号再读',
751
+ 'canon-trap-grep-exit2-path': '换浅范围/排除 node_modules 再 grep',
752
+ 'canon-trap-unknown-tool-name': '只从本会话可见工具面选名字',
753
+ 'canon-trap-provider-method-list': '先列方法清单再调用',
754
+ };
755
+ let canonRecs = [];
756
+ try { canonRecs = lib.currentRecords(lib.loadStore('canon').records); } catch { /* 读不到 ⇒ 只走兜底 */ }
757
+ const resolveEntry = (cid) => canonRecs.filter((r) => String(r.id) === cid || String(r.id).startsWith(cid + '-r')).pop() || null;
758
+ const isRetired = (rec) => !!rec && (/已退役|retired/i.test(String(rec.claim ?? '')) || String(rec.id).endsWith('-retired'));
759
+ const today = new Date().toISOString().slice(0, 10);
760
+ /** 某工具的首选反射:条目 `hint` 优先 → 兜底表;退役/过期/缺失一律跳过;取不到返回 null(fail closed)。
761
+ * ⚠ 口径:**库可读时,条目是唯一来源**——该条目没有 hint 也**不**回落兜底(否则"退役/过期"会被兜底表救活,
762
+ * 等于过期反射照打)。兜底表只在**库读不到**时生效。 */
763
+ const hintForTool = (tool) => {
764
+ const ids = map[tool];
765
+ if (!ids || !ids.length) return null;
766
+ for (const cand of ids) {
767
+ if (canonRecs.length) {
768
+ const rec = resolveEntry(cand);
769
+ if (!rec) continue; // 链上已无此条 ⇒ 跳过
770
+ if (isRetired(rec)) continue; // 已退役 ⇒ 不执行过期反射
771
+ if (rec.hint_until && String(rec.hint_until) < today) continue; // 反射有效期已过 ⇒ 跳过
772
+ if (typeof rec.hint === 'string' && rec.hint.trim()) return { id: cand, act: rec.hint.trim(), hintSource: 'entry' };
773
+ continue; // 有条目但无 hint ⇒ 该候选作罢,不回落
774
+ }
775
+ if (FALLBACK_ACTIONS[cand]) return { id: cand, act: FALLBACK_ACTIONS[cand], hintSource: 'fallback' };
776
+ }
777
+ return null;
778
+ };
779
+ const emitPreflight = (payload) => {
780
+ try { // 事件:关闭时走不到这里(双停)
781
+ appendFileSync(join(cfg.akashaDir, 'logs', 'hooks.jsonl'),
782
+ JSON.stringify({ ts: new Date().toISOString(), kind: 'preflight', ...payload }) + '\n', 'utf8');
783
+ } catch { /* 事件失败不影响提示 */ }
784
+ };
785
+ const trim80 = (s) => (s.length <= 80 ? s : s.slice(0, 79) + '…');
786
+
787
+ // ① 失败驱动(原逻辑):近 30 分钟内有过失败 ⇒ 给"那件工具"的反射
788
+ if (recent.length) {
789
+ const tool = String(recent[recent.length - 1].tool || '');
790
+ const fresh = !(S().preflight.tool === tool && now - S().preflight.at < WINDOW); // 同工具 30 分钟去重
791
+ if (fresh && map[tool]) {
792
+ const h = hintForTool(tool);
793
+ if (h) {
794
+ S().preflight.tool = tool; S().preflight.at = now;
795
+ emitPreflight({ tool, entry: h.id, hintSource: h.hintSource, mode: 'failure' });
796
+ return trim80('[坑前提示] ' + tool + ' 前:' + safeInjectInline(h.act) + ' —— 条目 ' + h.id);
797
+ }
798
+ }
799
+ }
800
+
801
+ // 条件事实(供条目 `when` 判定;#9 条件谓词化的落地):只含**可判定**的现场事实。
802
+ const condCtx = {
803
+ turn: S().obs.turn,
804
+ date: today,
805
+ usedTools: [...S().usedTools],
806
+ failedRecently: recent.length > 0,
807
+ };
808
+ // 条目 `when` 闸(可选):不满足就不打(把"不适用条件"变成可判定的谓词,而不是靠人记)。
809
+ const whenOk = (rec) => {
810
+ if (!rec || rec.when === undefined || rec.when === null) return true;
811
+ try { return lib.evalCondition(rec.when, condCtx); }
812
+ catch (e) { emitPreflight({ entry: String(rec.id), mode: 'when-invalid', error: String(e?.message ?? e).slice(0, 120) }); return false; } // 非法条件⇒不打(fail closed)
813
+ };
814
+
815
+ // ①b 触发词路由(2026-10-11 解冻后新增;外部对照启发=灵枢的「触发词路由」):
816
+ // 与①/②都不同——它**不依赖历史失败、也不依赖工具名**,而是按**当前任务文本**命中条目自带的
817
+ // `triggers` ⇒ 在**动手前**把库拉进来。这正是台账诊断的那句「规则躺在库里、执行点没有接线」的解药。
818
+ // 字段与 hint 同构:`hint`(≤80 字反射)+ `triggers`(触发词数组);退役 / `hint_until` 过期 / 无 hint 一律不打。
819
+ // 去重:同条目 30 分钟一次(与①同窗);条数上限:每回合最多一条(本函数只 return 一条)。
820
+ if (cfg.triggerRouting !== false) {
821
+ const txt = (S().lastUserText.text && now - S().lastUserText.at <= USER_TEXT_TTL) ? S().lastUserText.text : '';
822
+ if (txt) {
823
+ const matched = (rec) => {
824
+ const trs = Array.isArray(rec.triggers) ? rec.triggers : [];
825
+ return trs.find((t) => { const s = String(t).trim(); return s && txt.includes(s); }) ?? null;
826
+ };
827
+ const hit = canonRecs.find((r) => {
828
+ if (isRetired(r)) return false;
829
+ if (r.hint_until && String(r.hint_until) < today) return false;
830
+ if (typeof r.hint !== 'string' || !r.hint.trim()) return false;
831
+ if (!whenOk(r)) return false; // #9:条目 `when` 不满足 ⇒ 不打
832
+ return matched(r) !== null;
833
+ });
834
+ if (hit) {
835
+ const key = String(hit.id);
836
+ if (now - (S().firedTriggers.get(key) ?? 0) >= WINDOW) {
837
+ const tr = matched(hit);
838
+ S().firedTriggers.set(key, now);
839
+ emitPreflight({ entry: key, trigger: tr, hintSource: 'entry', mode: 'trigger' });
840
+ return trim80('[触发] ' + tr + ' ⇒ ' + safeInjectInline(String(hit.hint).trim()) + ' —— 条目 ' + key);
841
+ }
842
+ }
843
+ }
844
+ }
845
+
846
+ // ①c「自动端上来」(2026-10-11;补截图里点名的第二个缺口"只做到提示我去查"):
847
+ // 与①b 的差别:①b 靠**条目自带触发词**(人写好才命中);①c 是在**触发词没命中时**退化为
848
+ // **一次轻量词法召回** —— 按当前任务把最相关的一条**直接端上来**,而不是提示"你去查"。
849
+ // 纪律:上限 1 条 · 阈值 ≥2 分(防误端)· 同一条消息只算一次 · 与①b 互斥 · 可关(autoBringUp)· 失败静默。
850
+ if (cfg.autoBringUp !== false) {
851
+ const txt = (S().lastUserText.text && now - S().lastUserText.at <= USER_TEXT_TTL) ? S().lastUserText.text : '';
852
+ if (txt && S().broughtUp.at !== S().lastUserText.at) {
853
+ try {
854
+ // 分词修正(2026-10-11 实测):`tokenize` 把中文长句当成**一个 token** ⇒ 每条最多拿 0.9 的中文回退分
855
+ // ⇒ 阈值 2 永远够不到(146 条并列 0.90,等于"要么全端、要么都不端")。故此处**额外做 bigram 展开**
856
+ // (只对 CJK 连续串);**不改全局 tokenize**,避免污染既有检索口径。实测后 top 得分 26.9/21.9/20.9,区分度良好。
857
+ const base = lib.tokenize(txt);
858
+ const bi = [];
859
+ for (const t of base) {
860
+ if (!/\u4e00-\u9fff/.test(t)) continue;
861
+ const s = t.replace(/[^\u4e00-\u9fff]/g, '');
862
+ for (let i = 0; i + 1 < s.length; i += 1) bi.push(s.slice(i, i + 2));
863
+ }
864
+ const toks = [...base, ...bi].slice(0, 24);
865
+ let best = null;
866
+ if (toks.length) {
867
+ for (const r of canonRecs) {
868
+ if (isRetired(r)) continue;
869
+ const claim = String(r.claim ?? '').slice(0, 2000);
870
+ if (!claim) continue;
871
+ const hay = (String(r.id) + ' ' + claim + ' ' + (Array.isArray(r.tags) ? r.tags.join(' ') : '')).toLowerCase();
872
+ let score = 0;
873
+ for (const tk of toks) score += lib.matchScore(hay, tk);
874
+ if (!best || score > best.score) best = { score, rec: r };
875
+ }
876
+ }
877
+ // 接受判据(2026-10-11 实测标定):**归一 ≥1.0 且 绝对分 ≥8** 双条件。
878
+ // 切题样本:归一 1.59/1.68/3.45(分 15.9/26.9/51.8)⇒ 全端;
879
+ // 不切题样本:归一 0.82/0.45/0.29(分 4.9/4.9/2.0)⇒ 全不端 ⇒ 完美分离。
880
+ const norm = best ? best.score / toks.length : 0;
881
+ const accept = !!best && best.score >= 8 && norm >= 1.0;
882
+ S().broughtUp.at = S().lastUserText.at;
883
+ S().broughtUp.id = accept ? String(best.rec.id) : null;
884
+ if (accept && whenOk(best.rec)) {
885
+ emitPreflight({ entry: String(best.rec.id), score: best.score, norm: Number(norm.toFixed(2)), mode: 'bring-up' });
886
+ return trim80('[端上来] ' + safeInjectInline(String(best.rec.claim).slice(0, 44)) + ' —— ' + String(best.rec.id).slice(0, 26));
887
+ }
888
+ } catch { /* 静默:端不上来不影响回合 */ }
889
+ }
890
+ }
891
+
892
+ // ② 首用预告(2026-10-11 新增;补"漏召"的结构缺口):
893
+ // 渲染时**不知道本回合会用什么工具**,但知道**本会话还没用过哪些** ⇒ 逐个把"未用过的陷阱工具"预告一遍,
894
+ // 每个工具只预告一次(用过即止)= 把"事后回查"提前成"动手前预检",不依赖历史失败。
895
+ if (cfg.preflightFirstUse !== false) {
896
+ const nextTool = Object.keys(map).find((t) => !S().usedTools.has(t) && !S().announcedTraps.has(t));
897
+ if (nextTool) {
898
+ const h = hintForTool(nextTool);
899
+ if (h) {
900
+ S().announcedTraps.add(nextTool);
901
+ emitPreflight({ tool: nextTool, entry: h.id, hintSource: h.hintSource, mode: 'first-use' });
902
+ return trim80('[坑前提示·首用] ' + nextTool + ' 还没用过:' + safeInjectInline(h.act) + ' —— 条目 ' + h.id);
903
+ }
904
+ }
905
+ }
906
+ return null;
907
+ } catch { return null; }
908
+ }
909
+ });
910
+ }
911
+
912
+ // 极限观测行(2026-10-11,用户令):轮次维度的机械钩子——把「该查库了」变成可见信号,不靠记性。
913
+ // 只在知道轮次时渲染(拿不到真值就保持静默,不伪造)。硬约束同 C1:**一行、≤80 字**。
914
+ if (cfg.extremeObs !== false) {
915
+ registerContext({
916
+ name: 'akasha:extreme',
917
+ order: (cfg.pulseOrder ?? 132) + 2,
918
+ text: (context) => {
919
+ retarget(renderSidOf(context));
920
+ try {
921
+ if (!S().obs.turn) return null;
922
+ const gap = S().obs.lastRecallTurn === null ? '(本进程尚未查库)' : '距今 ' + Math.max(0, S().obs.turn - S().obs.lastRecallTurn) + ' 轮';
923
+ // 口径标注(2026-10-11):轮次/压缩/查库均为**本进程**统计——重启即归零;不标会被读成"整场会话"
924
+ const line = '[极限观测·本进程] 轮=' + S().obs.turn + ' 压缩=' + S().obs.compactions + ' 上次查库=' + gap;
925
+ return line.length <= 80 ? line : line.slice(0, 79) + '…';
926
+ } catch { return null; }
927
+ }
928
+ });
929
+ }
930
+
931
+ // 压缩补偿行(2026-10-11 用户方向:"针对压缩轮次增加,逐步强行增加使用库查记忆进行补全")。
932
+ // 依据(当晚实测):① 压缩摘要只保话题、**丢约定**——四条核心约定存活率 0–21%,而**每回合注入的称呼层 100% 存活**;
933
+ // ② 「丢记忆」实例(turn 220)落在**压缩后 15 轮内**(压缩在 205)。
934
+ // ⇒ 分级补偿:**脆弱窗**(距上次压缩 ≤15 轮)要求"重要结论先落库";**压缩 ≥3** 且本回合尚未查库 ⇒ 要求先查;
935
+ // **压缩 ≥5** ⇒ 升级为强制;**查过即不再催**(闭环,避免噪音)。
936
+ const RECALL_WINDOW = 15;
937
+ if (cfg.compensate !== false) {
938
+ registerContext({
939
+ name: 'akasha:compensate',
940
+ order: (cfg.pulseOrder ?? 132) + 3,
941
+ text: (context) => {
942
+ retarget(renderSidOf(context));
943
+ try {
944
+ if (!S().obs.turn || !S().obs.compactions) return null; // 没压缩过 ⇒ 不打扰
945
+ const sinceComp = S().obs.lastCompactionTurn === null ? null : Math.max(0, S().obs.turn - S().obs.lastCompactionTurn);
946
+ const recalledRecently = S().obs.lastRecallTurn !== null && (S().obs.turn - S().obs.lastRecallTurn) <= 2;
947
+ const inWindow = sinceComp !== null && sinceComp <= RECALL_WINDOW;
948
+ if (S().obs.compactions >= 5 && !recalledRecently) {
949
+ return clip('[压缩补偿·强制] 本会话已压缩 ' + S().obs.compactions + ' 次:关键动作前先 akasha_lookup/brief 补全,再动手');
950
+ }
951
+ if (S().obs.compactions >= 3 && !recalledRecently) {
952
+ return clip('[压缩补偿] 已压缩 ' + S().obs.compactions + ' 次' + (inWindow ? '(脆弱窗 距上次 ' + sinceComp + ' 轮)' : '') + ':本回合先查库再答');
953
+ }
954
+ if (inWindow) {
955
+ return clip('[压缩补偿·脆弱窗] 距上次压缩 ' + sinceComp + ' 轮:重要结论先落库,动手前先查');
956
+ }
957
+ return null;
958
+ } catch { return null; }
959
+ }
960
+ });
961
+ }
962
+
963
+ // 核心约定常驻行(2026-10-11 用户裁定):**防目标漂移**——压缩丢的是"我们怎么做事"(实测五条约定存活 0–21%),
964
+ // 而每回合注入的称呼层 100% 存活 ⇒ 把核心约定也做成**常驻**。**库驱动**:渲染时读 canon 的
965
+ // `canon-core-conventions` 当前版本的 `line` 字段 ⇒ **改约定=改条目**(不碰代码);读不到 ⇒ 内置兜底(永不为空)。
966
+ // 行宽**破例放宽到 160 字**(其余行均为 80):它是漂移锚,值得多占一行。
967
+ if (cfg.conventions !== false) {
968
+ const CONV_ID = 'canon-core-conventions';
969
+ const FALLBACK_CONVENTIONS = '库无界·装载有界|来源态四标签|修订链:追加不改原文|零编造(红线)|称呼:Master/大肥鱼';
970
+ const clipLong = (s) => (s.length <= 160 ? s : s.slice(0, 159) + '…');
971
+ registerContext({
972
+ name: 'akasha:conventions',
973
+ order: (cfg.pulseOrder ?? 132) + 4,
974
+ text: (context) => {
975
+ retarget(renderSidOf(context));
976
+ try {
977
+ const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
978
+ const recs = lib.currentRecords(lib.loadStore('canon').records);
979
+ const rec = recs.filter((r) => String(r.id) === CONV_ID || String(r.id).startsWith(CONV_ID + '-r')).pop();
980
+ const text = (rec && typeof rec.line === 'string' && rec.line.trim()) ? rec.line.trim() : FALLBACK_CONVENTIONS;
981
+ return safeInject(clipLong('[约定] ' + text)); // F06(2026-10-14 外部审查):库原文可能含合法模板示例(如 {{unknown}})——出口整体包裹,防宿主插值抛错
982
+ } catch { return safeInject(clipLong('[约定] ' + FALLBACK_CONVENTIONS)); }
983
+ }
984
+ });
985
+ }
986
+
987
+ // ① 语义钩子 + 显式钩子(2026-10-11 用户令「开搞」;补设计稿 §2.4 五类钩子中缺的两类)
988
+ // · **显式钩子**("上次那个"):读**本会话最近 intent 段**(会话层=用户消息的摘要)⇒ 检测指代词 ⇒ 检索六库 ⇒ **一行**指路。
989
+ // ⚠ 已知限制(如实):数据源是**摘要**而非原话,摘要可能丢掉指代词 ⇒ **宁可漏触发不误触发**;
990
+ // 若要原话级,需让 hooks 侧记录 user-message 行(待办)。
991
+ // · **语义钩子**("当前处境作为查询"):本会话近 30 分钟内有工具失败 ⇒ 用工具名检索六库 ⇒ **一行**指路。
992
+ // 两钩子**每回合最多出一行**,优先级 显式 > 语义;都无触发 ⇒ **静默**(不制造噪音);可关 `recallHook:false`。
993
+ if (cfg.recallHook !== false) {
994
+ const PRONOUN = /(上次|之前|上回|那天|那个|说过|记得|当时|前面说|前面提)/;
995
+ // N08(GPT 第三轮):按当前身份过滤;unknown 不读共享最后一条。
996
+ // 与 C3 共用完整 ID 精确读取;当前会话无记录时静默,不猜旧数据的别名。
997
+ const recentIntentGist = () => {
998
+ try {
999
+ const sid = curSid;
1000
+ if (!sid || sid === '(unknown)') return '';
1001
+ const txt = tailReadText(join(cfg.akashaDir, 'data', 'session.jsonl')).text;
1002
+ return String(latestIntentGist(txt, sid) || '');
1003
+ } catch { return ''; }
1004
+ };
1005
+ const clip120 = (s) => (s.length <= 120 ? s : s.slice(0, 119) + '…');
1006
+ registerContext({
1007
+ name: 'akasha:recall',
1008
+ order: (cfg.pulseOrder ?? 132) + 5,
1009
+ text: (context) => {
1010
+ retarget(renderSidOf(context));
1011
+ try {
1012
+ const lib = require_(join(cfg.akashaDir, 'lib.mjs'));
1013
+ // 显式钩子(指代)
1014
+ const gist = recentIntentGist();
1015
+ if (gist && PRONOUN.test(gist)) {
1016
+ // 去掉**所有**指代词(2026-10-11 修:replace 无 g 标志时只替一次,残留「那个」会污染查询)
1017
+ const q = gist.replace(new RegExp(PRONOUN.source, 'g'), ' ').replace(/\s+/g, ' ').trim().slice(0, 60) || gist.slice(0, 60);
1018
+ const r = lib.lookupDetailed(q, { limit: 2, contextBudget: false, via: 'probe' }); // via:'probe'(2026-10-14 复检)——机制探针,不进检索面指标
1019
+ const ids = (r.hits || []).map((h) => String(h.id)).slice(0, 2).join(' · ');
1020
+ if (ids) return safeInject(clip120('[指代检索] 像是问旧事 → 先看:' + ids)); // F06:库侧内容出口必须包裹(防模板插值)
1021
+ }
1022
+ // 语义钩子(近失败)
1023
+ const f = S().recentFailures[S().recentFailures.length - 1];
1024
+ if (f && S().obs.turn && Date.now() - f.ts < 30 * 60 * 1000) {
1025
+ const r = lib.lookupDetailed(String(f.tool || ''), { limit: 2, contextBudget: false, via: 'probe' }); // 同上:机制探针(近失败回查),不进检索面指标
1026
+ const top = (r.hits || [])[0];
1027
+ if (top) return safeInject(clip120('[处境检索] ' + f.tool + ' 近 30 分钟内失败过 → 相关条目 ' + top.id + ':' + String(top.snippet || '').slice(0, 42))); // F06 同上
1028
+ }
1029
+ return null;
1030
+ } catch { return null; }
1031
+ }
1032
+ });
1033
+ }
1034
+
1035
+ // 空闲触发(事件失败开放)。
1036
+ ctx.on('agent/status', (payload) => {
1037
+ try { if (payload?.status === 'idle') maybeSleep('idle'); } catch { /* 静默 */ }
1038
+ });
1039
+
1040
+ // 定时兜底:宿主长期不空闲也能按节奏睡。
1041
+ // ⚠️ timer 是「混入服务」:访问 ctx.interval 需要 inject:['timer'],否则属性访问本身抛出
1042
+ // "cannot get property \"timer\" without inject"——v1.1.0 事故:整个 apply 失败、插件静默失活。
1043
+ // ctx.interval 返回的停止函数再挂到本插件自己的 effect 上,卸载即停。
1044
+ if (typeof ctx.interval === 'function') {
1045
+ try {
1046
+ const stop = ctx.interval(() => { try { maybeSleep('timer'); } catch { /* 静默 */ } }, cfg.timerCheckMs);
1047
+ ctx.effect(() => () => { try { stop(); } catch { /* 静默 */ } });
1048
+ } catch { /* 静默 */ }
1049
+ }
1050
+
1051
+ // 首次激活即跑一次,此后按去抖节奏。
1052
+ const boot = readState();
1053
+ if (!boot.lastRunAt) runSleep('activate');
1054
+
1055
+ // C(2026-10-16 batch6):**装载即查一次**——结果从**第一回合**起可见(原先首个 turn/end 之前渲染
1056
+ // ⇒ 重启后要等第二回合才显示)。代价:宿主启动多一次 ~0.1 s 同步子进程。
1057
+ // ⚠ 位置纪律:**放在激活睡眠之后**——本检查自己要往 hooks.jsonl 落一行观测线(kind:'review-lag'),
1058
+ // 若排在激活睡眠之前,那一行会被算进启动那次的「已处理行数」水位线(selftest 实测 4≠3)——
1059
+ // 那是自产的记账噪声,不是库的进展。
1060
+ try { runReviewLag(); } catch { /* 静默:装载期任何异常都不得影响插件加载 */ }
1061
+
1062
+ log({ kind: 'sleep-armed', pid: process.pid, minIntervalHours: cfg.minIntervalHours, timerCheckMs: cfg.timerCheckMs });
1063
+ if (llmSkipNote) log({ kind: 'wake-note-llm-skip', message: llmSkipNote });
1064
+ }