@nightpainters/dsh-ling 1.5.1 → 1.5.3
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/ACCESS-DESIGN.md +10 -7
- package/CHANGELOG.md +140 -3
- package/DESIGN.md +162 -18
- package/README.md +66 -32
- package/lib/audit/access-log.js +181 -25
- package/lib/audit/index.js +45 -3
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +1183 -88
- package/lib/host/api.js +716 -83
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/genesis.js +27 -5
- package/lib/host/guard.js +39 -2
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- package/lib/host/inject.js +49 -12
- package/lib/host/l1.js +11 -4
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +425 -33
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/persona.js +38 -7
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +688 -0
- package/lib/host/snapshot.js +48 -7
- package/lib/host/summarizer.js +159 -3
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +22 -29
- package/package.json +51 -4
- package/tools/apply-access-log-patch.mjs +1 -1
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// dsh-ling host — E0「那一行跟着会话走」(session progress line)。
|
|
2
|
+
//
|
|
3
|
+
// 病灶:`[记忆·开场]` 里「本会话自己那一行」= 标题 + 摘要第一句,而两者同源
|
|
4
|
+
// (`summarizer.js` 都取自首条真人消息)→ 会话越长,那一行恒等,等于同一句话印两遍。
|
|
5
|
+
// 裁定的形态是 **A · 只追不替**:只用 `agent/pre-step` 的 `decision.messages` **追加**一条
|
|
6
|
+
// 带自标识序号的活行,**绝不**调用带 `surfaceOp` 的 `session.append`(不引入 replace 契约面)。
|
|
7
|
+
// 旧行留在历史里(平台无删除),序号让它们读起来是"历史"而不是"矛盾"。
|
|
8
|
+
//
|
|
9
|
+
// 档位(2026-09-23 主人拍板,替换旧的 15min + 10 轮双门):
|
|
10
|
+
// D = 只在 `step === 1`(新轮次第一批)考虑追加 —— "在新轮次开始时插进来";
|
|
11
|
+
// C = "内容真变了才追加" —— 三道**同源**判据,任一说"没变"就不追加:
|
|
12
|
+
// ① 逐字节:候选与上次真追加的 `st.text` 相同;
|
|
13
|
+
// ② `counts.lastMd5`:md5(候选) 与上次记录的 md5 相同(裁定点名的那道);
|
|
14
|
+
// ③ 实质键 `st.key` = md5(标题 + 最新进展):剥掉 `#N`/`第 N 轮`/`HH:MM` 这些易变段。
|
|
15
|
+
// 为什么非要 ③:正文自带 `#N`,而"试一次"就会让它 +1 ⇒ 只有 ①② 时恒判"变了",闸等于没有(实测钉过)。
|
|
16
|
+
// ⇒ 旧档位 T/K(`TIER_MS_DEFAULT / TIER_TURNS_DEFAULT`)已废但保留导出,探针里 `tier.active:false`。
|
|
17
|
+
// ⇒ 净效果:同一个"最新进展"在整段会话里只追加一次;发言一变,下一轮立刻追加一条(无时间门)。
|
|
18
|
+
//
|
|
19
|
+
// 平台契约(宿主 0.1.7-rc.2,行号为 checkout):
|
|
20
|
+
// · `agent/pre-step` 载荷 `{agent, messages, turn, step, signal}`(runtime-types.d.ts:304-310),
|
|
21
|
+
// 派发见 dsh-agent-loop/lib/index.js:911-918;`step === 1 && messages.length > 0`
|
|
22
|
+
// = 每轮用户发言后、模型首次开口前(:953 `step = phase.step + 1`,:1023 每轮 `phase.step = 0`)。
|
|
23
|
+
// ⚠️ :962-966 首步**空批次直接收轮** ⇒ 往 `decision.messages` 塞东西必须以
|
|
24
|
+
// `messages.length > 0` 为门,否则把本该结束的轮次顶开一步。
|
|
25
|
+
// · 我们返回的消息由**宿主自己**追加(:1046 `session.append("user/message", message, {surfaceOp:"append"})`),
|
|
26
|
+
// 我们只提供 `UserMessage` 形状:`{id, role:'user', source:{kind}, content:[]}`
|
|
27
|
+
// (dsh-session/lib/index.js:1151-1163 强制)。
|
|
28
|
+
// · `user/message` 永不被过滤(:214-227)⇒ 想"作废"一条**不要**给空文本,本模块无作废路径。
|
|
29
|
+
// · 客户端只有 `source.kind === 'user'` 才当输入气泡,其余走 contextMessage ⇒ **不动 client.js**。
|
|
30
|
+
//
|
|
31
|
+
// 热路径三条锁:① 闸门最前置;② 通过后只做 O(surface 节点数) 纯内存扫描 + O(1) 单行读
|
|
32
|
+
// (禁 summarizeDsh 全表扫、禁 runDeepPass、禁文件 IO);③ 全 handler try/catch 退化成 `return d`。
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { pick } from './util.js';
|
|
35
|
+
import { firstSentence } from './l1.js';
|
|
36
|
+
import { extractMessageText } from './snapshot.js';
|
|
37
|
+
import { canInject, kindOfAgent, sessionIdOfAgent } from './inject.js';
|
|
38
|
+
|
|
39
|
+
/** 我们那条活行的 `source.kind` —— 也是"认出自己"的唯一判据(不用记下的 seq:折叠后可能已不在面上)。 */
|
|
40
|
+
export const SESSION_LINE_SOURCE = 'dsh-ling:session-line';
|
|
41
|
+
/** ⚠️ **已废档位,不再生效** —— 2026-09-23 主人拍板:那一行表达「最新进展」,档位改为 D+C
|
|
42
|
+
* (轮次边界刷新 + 内容真变了才追加)。这两个常量 + `tierPass` **只保留导出**(测试/探针引用),
|
|
43
|
+
* handler 侧再也不拿它们当闸门:探针里 `tierActive: false` 就是"它不在生效"的显式标记。
|
|
44
|
+
* 保留而不删的理由:① 有测试与文档引用;② 留着"最小间隔"这条路将来若要重启,口径可查。 */
|
|
45
|
+
export const TIER_MS_DEFAULT = 15 * 60 * 1000;
|
|
46
|
+
/** ⚠️ **已废档位,不再生效**(见 `TIER_MS_DEFAULT` 注释:档位改 D+C,双门撤除)。 */
|
|
47
|
+
export const TIER_TURNS_DEFAULT = 10;
|
|
48
|
+
/** 整行上限(与 `l1.js` 的 SUMMARY_CHARS_DEFAULT 同量级)。 */
|
|
49
|
+
export const LINE_MAX_CHARS = 110;
|
|
50
|
+
/** 「最新进展」上限:真人发言首句。 */
|
|
51
|
+
export const PROGRESS_MAX_CHARS = 40;
|
|
52
|
+
/** 标题段**显示兜底**上限(超出留 `…`,见 `clip`)。
|
|
53
|
+
* ⚠️ **与 `retitle.js` 的同名常量语义不同,别把两者"统一"成一个数**(E1 判定,2026-10-01):
|
|
54
|
+
* · `retitle.js:TITLE_MAX_CHARS`(= 58)= **生成上限** —— `heuristicTitle()` 切句读/硬截的落点,
|
|
55
|
+
* 它决定"机器造出来的标题最长多少字"(`summarizer.js` / `import-file.js` 只拿它当兜底);
|
|
56
|
+
* · 本常量(= 60)= **显示兜底** —— `resolveTitle()` 给那一行印标题时,把**任何来源**的标题硬夹到这里:
|
|
57
|
+
* 除本机生成(≤58)外,还有源库自带标题(不做首句截断)与主人手改(`memory.renameTitle` 上限 120)。
|
|
58
|
+
* 不变式:**本值必须 ≥ `retitle.js` 的生成上限** —— 否则机器刚造出来的标题会在那一行里被**再切一刀**,
|
|
59
|
+
* "那一行印的标题"与"记忆里的标题"当场对不上。60 = 58 + 2,余量留给非本机来源的短标题。
|
|
60
|
+
* (两处靠模块边界分开;**没有任何模块同时 import 这两个常量**,将来若要改名以消除同名,
|
|
61
|
+
* 建议本处叫 `LINE_TITLE_MAX_CHARS` —— 属结构调整,未做。) */
|
|
62
|
+
export const TITLE_MAX_CHARS = 60;
|
|
63
|
+
export const KV_LAST = 'sessline.last.';
|
|
64
|
+
export const KV_COUNTS = 'sessline.counts.';
|
|
65
|
+
export const KV_DUPES = 'sessline.dupes.';
|
|
66
|
+
export const KV_ERR = 'sessline.err';
|
|
67
|
+
|
|
68
|
+
/** md5(仅用于"这条是不是新文本"的去重与探针展示,不是安全边界)。 */
|
|
69
|
+
export function md5(text) {
|
|
70
|
+
return createHash('md5').update(String(text), 'utf8').digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function oneLine(t) {
|
|
74
|
+
return String(t ?? '').replace(/\s+/g, ' ').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 硬夹到 n 字(超出留 `…`)。 */
|
|
78
|
+
function clip(s, n) {
|
|
79
|
+
const t = String(s ?? '');
|
|
80
|
+
const k = Math.max(1, Number(n) || 0);
|
|
81
|
+
return t.length <= k ? t : t.slice(0, k - 1) + '…';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 本地时刻 HH:MM(与主人看到的钟一致;探针里用它判断"文本是否随对话变")。 */
|
|
85
|
+
export function hhmm(at) {
|
|
86
|
+
const d = new Date(Number(at) || Date.now());
|
|
87
|
+
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 「最新进展」:真人发言首句,≤40 字。
|
|
91
|
+
* 复用 `l1.firstSentence` 的句读切法(它 cap ≤40 时按 40 处理),**再夹一次** ——
|
|
92
|
+
* 它会为省略号多留一个字(`40 字 + …`),这里保证严格 ≤40。 */
|
|
93
|
+
export function progressLine(text, cap = PROGRESS_MAX_CHARS) {
|
|
94
|
+
const t = oneLine(text);
|
|
95
|
+
if (!t) return '';
|
|
96
|
+
return clip(firstSentence(t, cap), cap);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 内容公式(照裁定,不另设计):
|
|
101
|
+
* `[本次会话·进展 #N] {标题} —— 最近:{最新真人发言首句} · 第 {turn} 轮 · {HH:MM}`
|
|
102
|
+
* 整行 ≤ maxChars:标题优先(它是锚),剩余空间给进展;进展只剩不到 8 字就整段不带
|
|
103
|
+
* (宁可是一行干净的标题,也不要 3 个字的残句)。两者皆空 ⇒ 返回 ''(调用方据此不写)。
|
|
104
|
+
*/
|
|
105
|
+
export function buildSessionLineText({ title, progress, turn, ordinal, at = Date.now(), maxChars = LINE_MAX_CHARS } = {}) {
|
|
106
|
+
const head = `[本次会话·进展 #${Number(ordinal) || 1}] `;
|
|
107
|
+
// 轮次段:turn 缺失/非法时**整段省略**(省略 = 陈述"不知道",与台账 B66 一致);
|
|
108
|
+
// 绝不写 `第 0 轮` —— 那是假读数(`/state` 面板路径上没有 turn 来源)。
|
|
109
|
+
const turnNum = Number(turn);
|
|
110
|
+
const hasTurn = Number.isFinite(turnNum) && turnNum > 0;
|
|
111
|
+
const tail = (hasTurn ? ` · 第 ${turnNum} 轮` : '') + ` · ${hhmm(at)}`;
|
|
112
|
+
const mid = ' —— 最近:';
|
|
113
|
+
const t0 = oneLine(title);
|
|
114
|
+
const p0 = oneLine(progress);
|
|
115
|
+
if (!t0 && !p0) return '';
|
|
116
|
+
const room = Math.max(8, Number(maxChars) - head.length - tail.length - mid.length);
|
|
117
|
+
let tt = t0;
|
|
118
|
+
let pp = p0;
|
|
119
|
+
if (tt.length + pp.length > room) {
|
|
120
|
+
tt = clip(tt, Math.min(tt.length || room, room));
|
|
121
|
+
const rest = room - tt.length;
|
|
122
|
+
pp = rest >= 8 ? clip(pp, rest) : '';
|
|
123
|
+
}
|
|
124
|
+
return head + tt + (pp ? mid + pp : '') + tail;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 扫一遍会话面(纯内存):回答两件事 —— ① 我们那条**还在不在**;② 面上最后一条的**文本**
|
|
129
|
+
* (供逐字节硬闸)。顺带记下首条/末条**真人**发言(标题缺失时的兜底与"最新进展"的兜底)。
|
|
130
|
+
* `nodes` 是 seq 数组(公开访问:官方 pruner:139、compaction-basic:869 在用),
|
|
131
|
+
* `eventAt(seq)` 取回事件。**不用**"上次记下的 seq"定位:折叠后它可能已不在面上。
|
|
132
|
+
*/
|
|
133
|
+
export function scanSessionLineSurface(nodes, eventAt) {
|
|
134
|
+
const out = { count: 0, lastText: '', lastSeq: -1, seqs: [], firstUser: '', latestUser: '' };
|
|
135
|
+
for (const seq of Array.isArray(nodes) ? nodes : []) {
|
|
136
|
+
let ev;
|
|
137
|
+
try {
|
|
138
|
+
ev = eventAt(seq);
|
|
139
|
+
} catch {
|
|
140
|
+
continue; // 单点取回失败不该让整段扫描失败
|
|
141
|
+
}
|
|
142
|
+
if (!ev || ev.type !== 'user/message') continue;
|
|
143
|
+
const kind = ev?.data?.source?.kind;
|
|
144
|
+
if (kind === SESSION_LINE_SOURCE) {
|
|
145
|
+
out.count += 1;
|
|
146
|
+
out.lastSeq = Number(seq);
|
|
147
|
+
out.seqs.push(Number(seq));
|
|
148
|
+
out.lastText = String(extractMessageText(ev) ?? ''); // 不折空白:硬闸要逐字节比
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (kind === 'user') {
|
|
152
|
+
const t = oneLine(extractMessageText(ev));
|
|
153
|
+
if (t) {
|
|
154
|
+
if (!out.firstUser) out.firstUser = t;
|
|
155
|
+
out.latestUser = t;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 实质内容键:**只含"标题 + 最新进展"**,剥掉易变段(`#N` 序号 / `第 N 轮` / `HH:MM`)。
|
|
163
|
+
* 用途:内容闸(C)的实质判据 —— 那一行表达「最新进展」,标点式的序号与钟点不算"进展变了";
|
|
164
|
+
* 若拿带序号的整行去比,`#N` 每次尝试都会 +1 ⇒ 闸门永远为"变了",等于没有闸(实测过)。
|
|
165
|
+
* 剥离(而非删除)易变段 = 不动那一行的呈现形态,只动"什么算变"。 */
|
|
166
|
+
export function progressKey(title, progress) {
|
|
167
|
+
return md5(oneLine(title) + '\u0000' + oneLine(progress));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* **C 闸「内容真变了?」的唯一判据** —— 写路径(`maybeSessionLine`)与只读预览(`sessionLinePreview`)
|
|
172
|
+
* 共用**这一处**,谁都不许在自己那头另写一份。
|
|
173
|
+
* 为什么必须共用:两条路曾各自内联判据,写路径 3 条、预览只有 2 条(少 md5)
|
|
174
|
+
* ⇒ 同一份状态能出现「预览说会追加、写路径判 same」的分叉 —— 属"读数与行为不一致",最误导人。
|
|
175
|
+
*
|
|
176
|
+
* 三条判据,任一说"没变"就算没变(`changed:false`):
|
|
177
|
+
* ① 逐字节:`candidate` 与上次那条的原文相同。⚠️ **两条路的基准不同源** ——
|
|
178
|
+
* 写路径取 kv `st.text`,预览取**面上那条**(HTTP 面板路径常常根本没有会话面);
|
|
179
|
+
* ② `md5(candidate)` 与上次记录的 md5 相同(kv `sessline.last.*.md5`,裁定点名要用的那道):
|
|
180
|
+
* 它的基准**两条路都读得到** ⇒ 这是两条路唯一真正共享的判据,**一条都不许缺**;
|
|
181
|
+
* ③ 实质键:`progressKey(标题, 最新进展)` 与上次追加时记的相同(剥掉 `#N`/`第 N 轮`/`HH:MM`)。
|
|
182
|
+
* ⚠️ 预览的键基准可能退让到"从上次那条正文里反解出的标题/进展"(面板看不到进展时),与写路径未必同源
|
|
183
|
+
* —— 那是**已知且有意**的差异,注释见 `sessionLinePreview` ④。
|
|
184
|
+
* `candidate` 为空串不做额外守卫:记录里的 md5 一律来自**非空**正文(`maybeSessionLine` 从不追加空文本),
|
|
185
|
+
* 故 `md5('')` 不可能等于任何一条记录。
|
|
186
|
+
* @returns {{changed:boolean, textSame:boolean, md5Same:boolean, keySame:boolean}}
|
|
187
|
+
*/
|
|
188
|
+
export function contentChanged({ candidate = '', lastText = '', lastMd5 = '', lastKey = '', curKey = '' } = {}) {
|
|
189
|
+
const textSame = !!lastText && String(candidate) === String(lastText);
|
|
190
|
+
const md5Same = !!lastMd5 && md5(candidate) === String(lastMd5);
|
|
191
|
+
const keySame = !!lastKey && !!curKey && String(curKey) === String(lastKey);
|
|
192
|
+
return { changed: !(textSame || md5Same || keySame), textSame, md5Same, keySame };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 从我们那条活行的正文里把"标题 + 进展"两段拆出来(给预览/自愈当比较基准)。
|
|
196
|
+
* 正文形如 `[本次会话·进展 #N] {标题} —— 最近:{进展} · 第 T 轮 · HH:MM`,尾部整段是格式位。
|
|
197
|
+
* 解不出(老格式/被夹过)⇒ 两段都空。 */
|
|
198
|
+
function partsFromLineText(text) {
|
|
199
|
+
const t = String(text ?? '');
|
|
200
|
+
const m = /^\[本次会话·进展 #\d+\]\s*([\s\S]*)$/.exec(t);
|
|
201
|
+
if (!m) return { title: '', progress: '' };
|
|
202
|
+
// 只剥**行尾**的格式段;`第 N 轮` 那一段可能不存在(缺 turn 时省略)。⚠️ 别用贪婪/可选组混写:
|
|
203
|
+
// `(.*?)\s*(?:· 第 N 轮 …)?\s*$` 会把"结尾恰好像 HH:MM 的进展"也吞掉(实测踩过)。
|
|
204
|
+
let body = m[1].trim();
|
|
205
|
+
body = body.replace(/\s*·\s*\d{2}:\d{2}\s*$/, '');
|
|
206
|
+
body = body.replace(/\s*·\s*第\s*\d+\s*轮\s*$/, '');
|
|
207
|
+
const i = body.indexOf(' —— 最近:');
|
|
208
|
+
return i >= 0
|
|
209
|
+
? { title: body.slice(0, i).trim(), progress: body.slice(i + ' —— 最近:'.length).trim() }
|
|
210
|
+
: { title: body.trim(), progress: '' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 从我们那条活行的正文里把"实质键"反解出来(给老记录/自愈用)。解不出 ⇒ ''。 */
|
|
214
|
+
function keyFromLineText(text) {
|
|
215
|
+
const { title, progress } = partsFromLineText(text);
|
|
216
|
+
return title || progress ? progressKey(title, progress) : '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** ⚠️ **档位双门(纯函数)—— 已不在决策路径上**(D+C 之后没有任何调用方:`maybeSessionLine`
|
|
220
|
+
* 不再调它)。保留导出只为:测试钉住它的算术 + 将来若要恢复"最小间隔"有现成口径。
|
|
221
|
+
* 旧语义:**两道门都要过**才算过;`state` 是上次追加的记录(内存优先,kv 兜底)。 */
|
|
222
|
+
export function tierPass({ state, turn, now = Date.now(), tierMs = TIER_MS_DEFAULT, tierTurns = TIER_TURNS_DEFAULT } = {}) {
|
|
223
|
+
const at = Number(state?.at) || 0;
|
|
224
|
+
const lastTurn = Number(state?.turn) || 0;
|
|
225
|
+
const cur = Number(turn) || 0;
|
|
226
|
+
return (now - at) >= Number(tierMs) && (cur - lastTurn) >= Number(tierTurns);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 决策表(纯函数,逐行对应裁定)。**D+C 版**(2026-09-23 主人拍板):
|
|
231
|
+
* D(`step === 1`)已由 handler 的轮次边界闸门保证 —— 本函数被调用时必是"新轮次第一批";
|
|
232
|
+
* C(`changed`)是这里唯一的追加闸:内容没真变就不追加(**永久留痕的平台,刷新 = 再插一条**)。
|
|
233
|
+
*
|
|
234
|
+
* 分支逐条(顺序即优先级,先命中先返回):
|
|
235
|
+
* 面上 > 1 条 → 'dupes':形态 A 不作废旧副本(没有删除操作,也不许 replace)
|
|
236
|
+
* 候选为空 → 'empty':绝不给空文本的 user/message
|
|
237
|
+
* 面上没有我们的行 → 'absent':**追加**(与 `changed` 无关 —— 行被折叠吞了,补一条是对的;
|
|
238
|
+
* 这也正是"面上若没有我们的行,**即便文本与 lastMd5 相同也照样追加**"那条旧测试钉的语义)
|
|
239
|
+
* 面上有 + `changed === false` → 'same':内容真没变,**不追加**
|
|
240
|
+
* 其余(面上有 + 内容变了) → 'ok':追加一条新的(旧的留在历史里,#N 递增)
|
|
241
|
+
* ⚠️ 没有 'tier' 分支了(双门撤除)。
|
|
242
|
+
*
|
|
243
|
+
* @param {object} o
|
|
244
|
+
* @param {{count:number}} o.onSurface 扫面结果,只用 `count`
|
|
245
|
+
* @param {string} o.candidate 本次候选正文
|
|
246
|
+
* @param {string} o.lastMd5 KV_LAST 里记的上一次真追加的 `md5(text)`(无记录 ⇒ '')
|
|
247
|
+
* @param {boolean} o.changed 内容真变了(`maybeSessionLine` 按上面三道判据算出来)
|
|
248
|
+
*/
|
|
249
|
+
export function decideSessionLine({ onSurface, candidate = '', lastMd5 = '', changed = true } = {}) {
|
|
250
|
+
const s = onSurface || { count: 0 };
|
|
251
|
+
const count = Number(s.count) || 0;
|
|
252
|
+
if (count > 1) return { action: 'skip', reason: 'dupes' };
|
|
253
|
+
if (!candidate) return { action: 'skip', reason: 'empty' };
|
|
254
|
+
if (count === 0) return { action: 'append', reason: 'absent' };
|
|
255
|
+
if (!changed) return { action: 'skip', reason: 'same' };
|
|
256
|
+
return { action: 'append', reason: 'ok' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------- kv 侧(全部 O(1) 单行读写)
|
|
260
|
+
|
|
261
|
+
function safeParse(raw) {
|
|
262
|
+
try {
|
|
263
|
+
return raw ? JSON.parse(String(raw)) : null;
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function blankCounts() {
|
|
270
|
+
return { append: 0, skippedTier: 0, skippedSame: 0, dupes: 0 };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function readCounts(memory, sid) {
|
|
274
|
+
try {
|
|
275
|
+
const v = safeParse(memory?.kvGet?.(KV_COUNTS + sid));
|
|
276
|
+
if (!v) return blankCounts();
|
|
277
|
+
return {
|
|
278
|
+
append: Number(v.append) || 0,
|
|
279
|
+
skippedTier: Number(v.skippedTier) || 0,
|
|
280
|
+
skippedSame: Number(v.skippedSame) || 0,
|
|
281
|
+
dupes: Number(v.dupes) || 0,
|
|
282
|
+
};
|
|
283
|
+
} catch {
|
|
284
|
+
return blankCounts();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function writeCounts(memory, sid, c) {
|
|
289
|
+
try {
|
|
290
|
+
memory?.kvSet?.(KV_COUNTS + sid, JSON.stringify(c));
|
|
291
|
+
} catch { /* 计数写不上不影响注入 */ }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function bumpCounts(memory, sid, field) {
|
|
295
|
+
const c = readCounts(memory, sid);
|
|
296
|
+
c[field] = (Number(c[field]) || 0) + 1;
|
|
297
|
+
writeCounts(memory, sid, c);
|
|
298
|
+
return c;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 上次追加记录:`{at, turn, md5, key, chars, text, seq, appendCount, replaceCount, kind}`。
|
|
302
|
+
* `key` = 实质内容键(标题+最新进展,剥掉 `#N`/轮次/HH:MM),内容闸的实质判据(新增字段)。
|
|
303
|
+
* `replaceCount` 恒 0 —— 形态 A 没有替换路径,留着是为了探针里一眼看出"确实一次都没替"。 */
|
|
304
|
+
function blankState() {
|
|
305
|
+
return { at: 0, turn: 0, md5: '', key: '', chars: 0, text: '', seq: null, appendCount: 0, replaceCount: 0, kind: '' };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** seq 归一:**保留 null**(不能用 `Number.isFinite(Number(v))` —— `Number(null)===0` 会把
|
|
309
|
+
* "还不知道"(null)悄悄变成 0,于是自愈判据 `st.seq === null` 在落盘一轮之后永远为假)。 */
|
|
310
|
+
function seqOrNull(v) {
|
|
311
|
+
if (v === null || v === undefined || v === '') return null;
|
|
312
|
+
const n = Number(v);
|
|
313
|
+
return Number.isFinite(n) ? n : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function writeLast(memory, sid, st) {
|
|
317
|
+
try {
|
|
318
|
+
memory?.kvSet?.(KV_LAST + sid, JSON.stringify({
|
|
319
|
+
at: Number(st.at) || 0,
|
|
320
|
+
turn: Number(st.turn) || 0,
|
|
321
|
+
md5: String(st.md5 || ''),
|
|
322
|
+
key: String(st.key || ''),
|
|
323
|
+
chars: Number(st.chars) || 0,
|
|
324
|
+
text: String(st.text || ''),
|
|
325
|
+
seq: seqOrNull(st.seq),
|
|
326
|
+
appendCount: Number(st.appendCount) || 0,
|
|
327
|
+
replaceCount: Number(st.replaceCount) || 0,
|
|
328
|
+
kind: String(st.kind || ''),
|
|
329
|
+
}));
|
|
330
|
+
} catch { /* 留痕失败不影响注入 */ }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 进程内 Map 优先;没有(重启/首次)才付一次 O(1) kv 单行读 ——
|
|
334
|
+
* 这样重启后档位判定照旧有效(否则"重启即多追加一条",与裁定里"不会多出一条"相悖)。 */
|
|
335
|
+
function readState(memory, sid, stateMap) {
|
|
336
|
+
const mem = stateMap.get(sid);
|
|
337
|
+
if (mem) return mem;
|
|
338
|
+
const v = safeParse(memory?.kvGet?.(KV_LAST + sid));
|
|
339
|
+
const rec = v
|
|
340
|
+
? {
|
|
341
|
+
at: Number(v.at) || 0,
|
|
342
|
+
turn: Number(v.turn) || 0,
|
|
343
|
+
md5: String(v.md5 || ''),
|
|
344
|
+
key: String(v.key || ''),
|
|
345
|
+
chars: Number(v.chars) || 0,
|
|
346
|
+
text: String(v.text || ''),
|
|
347
|
+
seq: seqOrNull(v.seq),
|
|
348
|
+
appendCount: Number(v.appendCount) || 0,
|
|
349
|
+
replaceCount: Number(v.replaceCount) || 0,
|
|
350
|
+
kind: String(v.kind || ''),
|
|
351
|
+
}
|
|
352
|
+
: blankState();
|
|
353
|
+
stateMap.set(sid, rec);
|
|
354
|
+
return rec;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 标题:仍是锚。优先用**已有的**概述标题(主键单行读,不是全表扫);
|
|
358
|
+
* 概述还没生成时退回"首条真人发言的首句"(与概述器同源,只是不落库)。 */
|
|
359
|
+
function resolveTitle(memory, sid, fallbackText) {
|
|
360
|
+
try {
|
|
361
|
+
const ov = memory?.overviewById?.('dsh', sid);
|
|
362
|
+
const t = oneLine(ov?.title);
|
|
363
|
+
if (t) return clip(t, TITLE_MAX_CHARS);
|
|
364
|
+
} catch { /* 读不到概述就用兜底 */ }
|
|
365
|
+
return clip(oneLine(fallbackText), TITLE_MAX_CHARS);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 本轮用户批次里最后一条**真人**发言(pre-step 的 `messages` 尚未入面,故从载荷取)。 */
|
|
369
|
+
function latestUserTextFromMessages(messages) {
|
|
370
|
+
const arr = Array.isArray(messages) ? messages : [];
|
|
371
|
+
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
|
372
|
+
const m = arr[i];
|
|
373
|
+
if (m?.source?.kind !== 'user') continue;
|
|
374
|
+
const t = oneLine(extractMessageText({ data: m }));
|
|
375
|
+
if (t) return t;
|
|
376
|
+
}
|
|
377
|
+
return '';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* IO 半(可单测):扫面 → 候选文本 → **内容真变了闸(C)** → 可能构造一条消息。
|
|
382
|
+
* **档位双门(15min / 10 轮)已撤**:本函数只在 `step === 1`(轮次边界,D)被 handler 调到,
|
|
383
|
+
* 追加与否只看 `counts.lastMd5` 与 `md5(candidate)` 是否不同。`tierMs/tierTurns` 形参保留但**不再参与判定**。
|
|
384
|
+
* 不做任何 `session.append`(追加是宿主的活,见 :1046);不写文件;不跑概述器。
|
|
385
|
+
* @returns {{action:'append'|'skip', reason:string, message?:object, text?:string, ordinal?:number}}
|
|
386
|
+
*/
|
|
387
|
+
export function maybeSessionLine({ memory, agent, payload, stateMap, now = Date.now(), tierMs = TIER_MS_DEFAULT, tierTurns = TIER_TURNS_DEFAULT } = {}) {
|
|
388
|
+
const sid = String(sessionIdOfAgent(agent) || '');
|
|
389
|
+
const session = pick(() => agent?.session);
|
|
390
|
+
if (!sid || !session || typeof session.eventAt !== 'function') return { action: 'skip', reason: 'no-session' };
|
|
391
|
+
const nodes = session.surface?.nodes;
|
|
392
|
+
if (!Array.isArray(nodes)) return { action: 'skip', reason: 'no-surface' };
|
|
393
|
+
const scan = scanSessionLineSurface(nodes, (seq) => session.eventAt(seq));
|
|
394
|
+
const st = readState(memory, sid, stateMap);
|
|
395
|
+
|
|
396
|
+
// ① 脏状态(面上 >1 条):形态 A 不作废旧副本 ⇒ 只留痕 + 探针可见,不写历史。
|
|
397
|
+
if (scan.count > 1) {
|
|
398
|
+
bumpCounts(memory, sid, 'dupes');
|
|
399
|
+
try {
|
|
400
|
+
memory?.kvSet?.(KV_DUPES + sid, JSON.stringify({ at: now, count: scan.count, seqs: scan.seqs.slice(-8) }));
|
|
401
|
+
} catch { /* 留痕失败不影响判定 */ }
|
|
402
|
+
return { action: 'skip', reason: 'dupes', count: scan.count };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ② seq 自愈(每会话至多一次):本行入日志的 seq 在决策时还不知道(追加发生在我们返回之后),
|
|
406
|
+
// 下一轮从面上就能读到真值 —— 届时补记,之后不再写(kv `last.seq` 不再为 null)。
|
|
407
|
+
// ⚠️ 只补 `seq`/`text`/`md5`(留痕对齐面),**绝不在这里改 `appendCount`**:
|
|
408
|
+
// 序号是"面上那条的编号 +1",自愈改它会让下一次候选的 `#N` 凭空 +1 ⇒ 内容闸(C)对"内容没变"永远失效。
|
|
409
|
+
if (scan.count === 1 && st.seq === null && Number(scan.lastSeq) >= 0) {
|
|
410
|
+
st.seq = Number(scan.lastSeq);
|
|
411
|
+
if (scan.lastText) {
|
|
412
|
+
st.text = scan.lastText;
|
|
413
|
+
st.md5 = md5(scan.lastText);
|
|
414
|
+
if (!st.key) st.key = keyFromLineText(scan.lastText);
|
|
415
|
+
}
|
|
416
|
+
stateMap.set(sid, st); // 回写进程内 Map:否则同一个 handler 的下一次调用又读到旧记录
|
|
417
|
+
writeLast(memory, sid, st);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ③ 候选文本:到这里才付 O(1) 单行读(overviewById)与字符串拼接。
|
|
421
|
+
const turn = Number(payload?.turn) || 0;
|
|
422
|
+
const progress = progressLine(latestUserTextFromMessages(payload?.messages) || scan.latestUser);
|
|
423
|
+
const title = resolveTitle(memory, sid, scan.firstUser);
|
|
424
|
+
const ordinal = (Number(st.appendCount) || 0) + 1;
|
|
425
|
+
const candidate = buildSessionLineText({ title, progress, turn, ordinal, at: now });
|
|
426
|
+
|
|
427
|
+
// ④ C 闸:内容真变了?**三条判据,任一说"没变"就不追加** ——
|
|
428
|
+
// ① 字面原文:候选与上次真追加的 `st.text` 逐字节相同(裁定原话那一侧);
|
|
429
|
+
// ② `counts.lastMd5`:`md5(候选)` 与上次记录的 md5 相同(裁定点名要用的那道判据);
|
|
430
|
+
// ③ 实质键 `st.key`:`progressKey(标题, 最新进展)` 与上次追加时记的相同(剥掉 `#N`/轮次/HH:MM)。
|
|
431
|
+
// 为什么要 ③:只留 ①② 时,`#N` 每尝试一次就 +1 ⇒ 恒判"变了",闸等于没有(实测钉过);
|
|
432
|
+
// 为什么要 ①②:同一轮内同参重入时,只有逐字节那一侧才拦得住。`st.key` 是**新增字段**,
|
|
433
|
+
// 不动任何已有 kv 的含义;老记录没有它 ⇒ 第一次一律追加一条,之后照常。
|
|
434
|
+
// ⚠️ 这三条**只在 `contentChanged` 里实现一处**(旧写法在本函数内联三条、预览那头内联两条 ⇒ 曾能分叉);
|
|
435
|
+
// 本路径的基准:① kv `st.text` ② kv `md5` ③ kv `key`。
|
|
436
|
+
const lastMd5 = String(st.md5 || '');
|
|
437
|
+
const lastKey = String(st.key || '');
|
|
438
|
+
const curKey = progressKey(title, progress);
|
|
439
|
+
const changed = contentChanged({ candidate, lastText: st.text, lastMd5, lastKey, curKey }).changed;
|
|
440
|
+
const verdict = decideSessionLine({ onSurface: scan, candidate, lastMd5, changed });
|
|
441
|
+
if (verdict.action !== 'append') {
|
|
442
|
+
// kv 语义**不变**:`skippedSame` = "被内容闸挡下"(旧义是"面末条逐字节相同",新义是"与 lastMd5/面末条相同",
|
|
443
|
+
// 都是"内容没变 ⇒ 不追加");`dupes` 仍只表示"面上 >1 条"这一种脏状态,绝不混用。
|
|
444
|
+
// `skippedTier` 已无写入点(档位撤除),老库里那个计数是历史值 —— 别改它的含义,也别清。
|
|
445
|
+
if (verdict.reason === 'same') bumpCounts(memory, sid, 'skippedSame');
|
|
446
|
+
return { action: 'skip', reason: verdict.reason, text: candidate };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ⑤ 真追加:`{...decision, messages:[...]}` 由 handler 负责(保留 startsRequestSeries)。
|
|
450
|
+
const message = {
|
|
451
|
+
id: `sessline-${sid}-${ordinal}-${Number(now).toString(36)}`,
|
|
452
|
+
role: 'user',
|
|
453
|
+
source: { kind: SESSION_LINE_SOURCE },
|
|
454
|
+
content: [{ type: 'text', text: candidate }],
|
|
455
|
+
};
|
|
456
|
+
st.at = now;
|
|
457
|
+
st.turn = turn;
|
|
458
|
+
st.md5 = md5(candidate);
|
|
459
|
+
st.key = curKey;
|
|
460
|
+
st.chars = candidate.length;
|
|
461
|
+
st.text = candidate;
|
|
462
|
+
st.seq = null; // 宿主还没追加:下一轮自愈
|
|
463
|
+
st.appendCount = ordinal;
|
|
464
|
+
st.replaceCount = 0;
|
|
465
|
+
st.kind = 'append';
|
|
466
|
+
stateMap.set(sid, st);
|
|
467
|
+
writeLast(memory, sid, st);
|
|
468
|
+
bumpCounts(memory, sid, 'append');
|
|
469
|
+
return { action: 'append', reason: verdict.reason, message, text: candidate, ordinal, chars: candidate.length };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------- handler(顺序即纪律)
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 造一个 `agent/pre-step` waterfall 监听器。顺序不可调换:
|
|
476
|
+
* ① 单独一个 try 只做委托(**必须** `return next()`;不返回会让 :920 读 `decision.kind` 抛 TypeError
|
|
477
|
+
* ⇒ :993 catch ⇒ `turnEnds={kind:'error'}` ⇒ **该轮直接作废报错**)。`next()` 只调一次。
|
|
478
|
+
* ② 非 enter(`reject`)⇒ 原样返回,**零写操作**。
|
|
479
|
+
* ③ 轮次边界闸门最前置:`step === 1 && messages.length > 0`(空批次首步直接收轮,:962-966)。
|
|
480
|
+
* ④ 按 agent 种类过滤(全局注册会收到所有 agent,含子代理)。
|
|
481
|
+
* ⑤ 业务段整段 try/catch:出错只留证(`sessline.err`)+ 退化成"不注入" ——
|
|
482
|
+
* **绝不在 catch 里再调 next()**(waterfall 再调会重跑下游链)。
|
|
483
|
+
*/
|
|
484
|
+
export function createSessionLineHandler(memory, settings, cfg = {}) {
|
|
485
|
+
const stateMap = new Map();
|
|
486
|
+
// ⚠️ `cfg.tierMs / cfg.tierTurns` 仍然读进来(兼容老配置,不报错),但 **D+C 之后不再参与任何判定** ——
|
|
487
|
+
// 写进 `maybeSessionLine` 也只是形参。改这两个值不会改变行为;要改行为请改决策表(见 `decideSessionLine`)。
|
|
488
|
+
const tierMs = Number(cfg.tierMs) > 0 ? Number(cfg.tierMs) : TIER_MS_DEFAULT;
|
|
489
|
+
const tierTurns = Number(cfg.tierTurns) > 0 ? Number(cfg.tierTurns) : TIER_TURNS_DEFAULT;
|
|
490
|
+
return async function sessionLinePreStep(payload, next) {
|
|
491
|
+
let d;
|
|
492
|
+
try {
|
|
493
|
+
d = await next();
|
|
494
|
+
} catch (e) {
|
|
495
|
+
throw e; // 委托失败不属于我们兜底的范围(下游自己的错)
|
|
496
|
+
}
|
|
497
|
+
if (!d || d.kind !== 'enter') return d;
|
|
498
|
+
if (payload?.step !== 1 || !payload?.messages?.length) return d;
|
|
499
|
+
try {
|
|
500
|
+
if (payload.signal?.aborted) return d;
|
|
501
|
+
const agent = payload.agent;
|
|
502
|
+
if (!agent || !canInject(kindOfAgent(agent))) return d;
|
|
503
|
+
const s = settings && typeof settings.get === 'function' ? settings.get() : (settings || {});
|
|
504
|
+
if (s?.memory?.sessionProgressLine === false) return d; // 免重启回退开关
|
|
505
|
+
const r = maybeSessionLine({ memory, agent, payload, stateMap, tierMs, tierTurns });
|
|
506
|
+
if (r.action !== 'append' || !r.message) return d;
|
|
507
|
+
return { ...d, messages: [...d.messages, r.message] }; // 保留 startsRequestSeries
|
|
508
|
+
} catch (e) {
|
|
509
|
+
try {
|
|
510
|
+
memory?.kvSet?.(KV_ERR, JSON.stringify({ at: Date.now(), msg: String(e?.message ?? e).slice(0, 200) }));
|
|
511
|
+
} catch { /* 留证都失败就只能放弃留证 */ }
|
|
512
|
+
return d; // 异常一律退化成"不注入"
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 注册(与全库其它事件同款:`ctx.on` 返回 disposer)。拿不到 ctx.on 就退化为空操作。 */
|
|
518
|
+
export function wireSessionLine(ctx, memory, settings, cfg = {}) {
|
|
519
|
+
if (!ctx || typeof ctx.on !== 'function') return () => {};
|
|
520
|
+
try {
|
|
521
|
+
const dispose = ctx.on('agent/pre-step', createSessionLineHandler(memory, settings, cfg));
|
|
522
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
523
|
+
} catch (e) {
|
|
524
|
+
console.debug('[dsh-ling] agent/pre-step registration failed', e);
|
|
525
|
+
return () => {};
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------- 只读探针
|
|
530
|
+
|
|
531
|
+
/** 探针里的档位标记:`active:false` = **双门已废、不在生效**(主人要一眼看出它还在不在)。
|
|
532
|
+
* 常量仍带出去(有读者按 `tier.ms/turns` 取值),`active` 是新增的判据。 */
|
|
533
|
+
function tierProbe() {
|
|
534
|
+
return { ms: TIER_MS_DEFAULT, turns: TIER_TURNS_DEFAULT, active: false, gate: 'D+C' };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 只读预览:此刻"应该"追加的那一条活行 + 档位状态。**不写任何 kv、不改任何状态。**
|
|
538
|
+
* 调用方(HTTP 面板)每打开一次就会调一次 ⇒ 这里**绝不允许** `kvSet`。
|
|
539
|
+
* 判定口径与 `maybeSessionLine` 逐条对齐(同一张决策表、同一套内容判据),
|
|
540
|
+
* 差别只有一处:预览拿不到 `payload.messages`,所以若 `memory` 上挂着该会话且面上有真人发言,
|
|
541
|
+
* 就用面上末条真人发言当"最新进展";拿不到就只印标题。
|
|
542
|
+
*
|
|
543
|
+
* ⚠️ turn 的语义(集成要求,别乱改):
|
|
544
|
+
* · **判定侧(`wouldAppend` / `blocked`)与 turn 完全无关** —— 内容比较用的是"标题 + 最新真人发言首句",
|
|
545
|
+
* 不含轮次号(`progressKey` 只含这两样)⇒ 同一个面/库,传不传 turn 结论一致;
|
|
546
|
+
* · **正文侧 `text` 依赖 turn**:`turn` 缺失/非法时 `text` 返回 **null**(不是 ''、更不是"第 0 轮"),
|
|
547
|
+
* `blocked` 仍照常判定 —— 面板路径上没有 turn 来源时,宁可不给正文,也不给假读数(台账 B66)。
|
|
548
|
+
* @returns {{text:string|null, ordinal:number, wouldAppend:boolean, blocked:string, last:object|null, counts:object|null}}
|
|
549
|
+
*/
|
|
550
|
+
export function sessionLinePreview(memory, settings, sessionId, { turn } = {}) {
|
|
551
|
+
const sid = String(sessionId || '');
|
|
552
|
+
const out = { text: null, ordinal: 1, wouldAppend: false, blocked: 'absent', last: null, counts: null };
|
|
553
|
+
const turnNum = Number(turn);
|
|
554
|
+
const hasTurn = Number.isFinite(turnNum) && turnNum > 0;
|
|
555
|
+
if (!sid) return { ...out, blocked: 'empty' };
|
|
556
|
+
try {
|
|
557
|
+
// ① 开关(免重启回退):与 handler 同一口径。
|
|
558
|
+
const s = settings && typeof settings.get === 'function' ? settings.get() : (settings || {});
|
|
559
|
+
if (s?.memory?.sessionProgressLine === false) return { ...out, blocked: 'disabled' };
|
|
560
|
+
out.counts = readCounts(memory, sid);
|
|
561
|
+
out.last = safeParse(memory?.kvGet?.(KV_LAST + sid));
|
|
562
|
+
const appendCount = Number(out.last?.appendCount) || Number(out.counts?.append) || 0;
|
|
563
|
+
const ordinal = appendCount + 1;
|
|
564
|
+
out.ordinal = ordinal;
|
|
565
|
+
// ② 扫面(有就扫,没有就按"扫不到"处理)—— 纯内存读,不写盘。
|
|
566
|
+
let scan = { count: 0, latestUser: '', firstUser: '' };
|
|
567
|
+
try {
|
|
568
|
+
const sess = memory?.session || memory?.sessions?.get?.(sid) || null;
|
|
569
|
+
const nodes = sess?.surface?.nodes;
|
|
570
|
+
if (Array.isArray(nodes) && typeof sess.eventAt === 'function') {
|
|
571
|
+
scan = scanSessionLineSurface(nodes, (seq) => sess.eventAt(seq));
|
|
572
|
+
}
|
|
573
|
+
} catch { /* 拿不到会话面不影响预览(只是少了个进展兜底) */ }
|
|
574
|
+
// ③ 标题来自已有概述(缺失时退回面上首条真人发言);进展 = 面上末条真人发言。
|
|
575
|
+
const title = resolveTitle(memory, sid, scan.firstUser);
|
|
576
|
+
const progress = progressLine(scan.latestUser);
|
|
577
|
+
// ④ 判定与 turn **无关**(集成要求)。**HTTP 路径上没有会话面**(实测:`memory.session` 不存在)⇒
|
|
578
|
+
// 这时只能拿"标题"当比较基准(进展看不到就退回上次那条里记的进展)——否则 key 会随"看不见"
|
|
579
|
+
// 而每次都变,面板永远读成"会追加"(哑读数)。
|
|
580
|
+
const lastMd5 = String(out.last?.md5 || '');
|
|
581
|
+
const lastKey = String(out.last?.key || '');
|
|
582
|
+
const lastParts = partsFromLineText(out.last?.text);
|
|
583
|
+
const effTitle = title || lastParts.title;
|
|
584
|
+
const effProgress = progress || lastParts.progress;
|
|
585
|
+
const basis = progress ? 'title+progress' : (scan.latestUser ? 'title+progress' : 'title-only');
|
|
586
|
+
const curKey = effTitle || effProgress ? progressKey(effTitle, effProgress) : '';
|
|
587
|
+
const judgeText = buildSessionLineText({ title: effTitle, progress: effProgress, ordinal, at: Date.now() }); // 无轮次版
|
|
588
|
+
// ④′ 判据**与写路径同一处**(`contentChanged`):旧写法这里只内联两条(逐字节 + 实质键),
|
|
589
|
+
// 少了 md5 那条 ⇒ 当 kv 记着 md5 而 `last.text` 缺失/不新鲜时,预览说"会追加"、写路径判 same
|
|
590
|
+
// (同一份状态两种读数)。逐字节那条的基准是**面上那条**(没有面时为空 ⇒ 该条不参与,与旧写法一致)。
|
|
591
|
+
const judge = contentChanged({
|
|
592
|
+
candidate: judgeText, lastText: scan.count === 1 ? scan.lastText : '', lastMd5, lastKey, curKey,
|
|
593
|
+
});
|
|
594
|
+
const changed = judge.changed;
|
|
595
|
+
if (process.env.DSH_LING_DBG) console.log('[pv]', JSON.stringify({ cnt: scan.count, ord: ordinal, basis, lastKey: lastKey.slice(0, 6), curKey: curKey.slice(0, 6), lastParts, effTitle, effProgress, textSame: judge.textSame, md5Same: judge.md5Same, keySame: judge.keySame, judge: judgeText }));
|
|
596
|
+
const verdict = decideSessionLine({ onSurface: scan, candidate: judgeText, lastMd5, changed });
|
|
597
|
+
out.blocked = verdict.reason;
|
|
598
|
+
out.wouldAppend = verdict.action === 'append';
|
|
599
|
+
out.basis = basis; // 'title+progress' | 'title-only' —— 面板可据此说明"进展没看到"
|
|
600
|
+
// ⑤ 正文只在**知道轮次**时给:不知道就 null(绝不"第 0 轮"式假读数);判定不受影响。
|
|
601
|
+
out.text = hasTurn ? buildSessionLineText({ title: effTitle, progress: effProgress, turn: turnNum, ordinal, at: Date.now() }) : null;
|
|
602
|
+
return out;
|
|
603
|
+
// ⑤ 正文只在**知道轮次**时给:不知道就 null(绝不"第 0 轮"式假读数);判定不受影响。
|
|
604
|
+
out.text = hasTurn ? buildSessionLineText({ title, progress, turn: turnNum, ordinal, at: Date.now() }) : null;
|
|
605
|
+
return out;
|
|
606
|
+
} catch {
|
|
607
|
+
return { ...out, blocked: 'empty' }; // 任何异常 ⇒ 报"算不出",绝不抛到 HTTP 层
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** 某会话的探针视图(挂 `/state`):计数 + 最近一条活行 + 脏记录 + **富字段**(委托 `sessionLinePreview`)。
|
|
612
|
+
* 签名向后兼容:`opts.turn` 可选,不传就只看"上次真追加"的真实读数(不带轮次,text=null)。 */
|
|
613
|
+
export function sessionLineState(memory, sessionId, opts = {}) {
|
|
614
|
+
const sid = String(sessionId || '');
|
|
615
|
+
if (!sid) return null;
|
|
616
|
+
const read = (k) => { try { return safeParse(memory?.kvGet?.(k)); } catch { return null; }; };
|
|
617
|
+
const base = {
|
|
618
|
+
source: SESSION_LINE_SOURCE,
|
|
619
|
+
tier: tierProbe(),
|
|
620
|
+
counts: readCounts(memory, sid),
|
|
621
|
+
last: read(KV_LAST + sid),
|
|
622
|
+
dupes: read(KV_DUPES + sid),
|
|
623
|
+
};
|
|
624
|
+
let p = { text: null, ordinal: (Number(base.last?.appendCount) || Number(base.counts?.append) || 0) + 1, wouldAppend: false, blocked: 'empty', last: base.last, counts: base.counts };
|
|
625
|
+
try {
|
|
626
|
+
p = sessionLinePreview(safeMemoryOf(memory), opts.settings, sid, { turn: opts.turn });
|
|
627
|
+
} catch { /* 预览任何异常都不该把 /state 打挂 */ }
|
|
628
|
+
return { ...base, preview: p, text: p.text, ordinal: p.ordinal, wouldAppend: p.wouldAppend, blocked: p.blocked, tierActive: false };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** `sessionLinePreview` 的 memory 只读包装:探针路径绝不允许写(双保险,防止将来误用 kvSet)。
|
|
632
|
+
* ⚠️ `session` / `sessions` 必须用 **getter** 转发 —— 对象字面量里的 `session: memory.session` 是
|
|
633
|
+
* 取值快照,而探针在会话面挂上之前就先构造过这个包装(实测踩过一次:面上有真人发言却读到空进展)。 */
|
|
634
|
+
function safeMemoryOf(memory) {
|
|
635
|
+
if (!memory || typeof memory !== 'object') return memory;
|
|
636
|
+
return {
|
|
637
|
+
kvGet: (k) => memory.kvGet?.(k),
|
|
638
|
+
kvList: (p) => memory.kvList?.(p),
|
|
639
|
+
overviewById: (s, c) => memory.overviewById?.(s, c),
|
|
640
|
+
get session() { return memory.session; },
|
|
641
|
+
get sessions() { return memory.sessions; },
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 全局探针视图(挂 `/health`):按会话汇总"追加了几次 / 跳过几次 / 有没有重复"。
|
|
646
|
+
* 目的:验收时**不必离线解 zstd 会话日志**就能回答这三个问题。 */
|
|
647
|
+
export function sessionLineProbe(memory, { limit = 8 } = {}) {
|
|
648
|
+
const out = {
|
|
649
|
+
source: SESSION_LINE_SOURCE,
|
|
650
|
+
tier: tierProbe(),
|
|
651
|
+
err: safeParse(memory?.kvGet?.(KV_ERR)),
|
|
652
|
+
totals: { sessions: 0, append: 0, skippedTier: 0, skippedSame: 0, dupes: 0 },
|
|
653
|
+
sessions: [],
|
|
654
|
+
};
|
|
655
|
+
try {
|
|
656
|
+
const counts = new Map();
|
|
657
|
+
const lasts = new Map();
|
|
658
|
+
const dupes = new Map();
|
|
659
|
+
for (const row of memory?.kvList?.(KV_COUNTS) || []) counts.set(String(row.key).slice(KV_COUNTS.length), safeParse(row.value));
|
|
660
|
+
for (const row of memory?.kvList?.(KV_LAST) || []) lasts.set(String(row.key).slice(KV_LAST.length), safeParse(row.value));
|
|
661
|
+
for (const row of memory?.kvList?.(KV_DUPES) || []) dupes.set(String(row.key).slice(KV_DUPES.length), safeParse(row.value));
|
|
662
|
+
const ids = [...new Set([...counts.keys(), ...lasts.keys(), ...dupes.keys()])];
|
|
663
|
+
const rows = ids.map((sid) => {
|
|
664
|
+
const c = counts.get(sid) || blankCounts();
|
|
665
|
+
const l = lasts.get(sid) || null;
|
|
666
|
+
const d = dupes.get(sid) || null;
|
|
667
|
+
out.totals.sessions += 1;
|
|
668
|
+
out.totals.append += Number(c.append) || 0;
|
|
669
|
+
out.totals.skippedTier += Number(c.skippedTier) || 0;
|
|
670
|
+
out.totals.skippedSame += Number(c.skippedSame) || 0;
|
|
671
|
+
out.totals.dupes += Number(c.dupes) || 0;
|
|
672
|
+
return {
|
|
673
|
+
sessionId: sid,
|
|
674
|
+
append: Number(c.append) || 0,
|
|
675
|
+
skippedTier: Number(c.skippedTier) || 0,
|
|
676
|
+
skippedSame: Number(c.skippedSame) || 0,
|
|
677
|
+
dupes: Number(c.dupes) || 0,
|
|
678
|
+
last: l ? { at: l.at ?? null, turn: l.turn ?? null, seq: l.seq ?? null, md5: l.md5 ?? null, key: l.key ?? null, chars: l.chars ?? null, text: l.text ?? '' } : null,
|
|
679
|
+
dupesInfo: d,
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
rows.sort((a, b) => (Number(b.last?.at) || 0) - (Number(a.last?.at) || 0));
|
|
683
|
+
out.sessions = rows.slice(0, Math.max(1, Number(limit) || 8));
|
|
684
|
+
} catch (e) {
|
|
685
|
+
out.error = String(e?.message ?? e).slice(0, 200);
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
}
|