@nightpainters/dsh-ling 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESS-DESIGN.md +119 -0
- package/CHANGELOG.md +627 -0
- package/DESIGN.md +461 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +236 -0
- package/cordis.patch.yml +16 -0
- package/lib/audit/access-log.js +533 -0
- package/lib/audit/index.js +92 -0
- package/lib/audit/patch-spec.js +195 -0
- package/lib/client.js +5069 -0
- package/lib/host/api.js +2584 -0
- package/lib/host/archive.js +194 -0
- package/lib/host/autotree.js +131 -0
- package/lib/host/backfill.js +267 -0
- package/lib/host/classify.js +10 -0
- package/lib/host/clock-skill.js +141 -0
- package/lib/host/clock.js +132 -0
- package/lib/host/corpus-suggest.js +139 -0
- package/lib/host/deepsummary.js +338 -0
- package/lib/host/dsweb-summary.js +225 -0
- package/lib/host/feedback.js +119 -0
- package/lib/host/freeze.js +144 -0
- package/lib/host/genesis.js +176 -0
- package/lib/host/guard.js +184 -0
- package/lib/host/habit-gen.js +273 -0
- package/lib/host/import-file.js +291 -0
- package/lib/host/import-log.js +129 -0
- package/lib/host/inject-common.js +25 -0
- package/lib/host/inject.js +285 -0
- package/lib/host/l1.js +282 -0
- package/lib/host/lifecycle.js +367 -0
- package/lib/host/memory.js +2389 -0
- package/lib/host/mode.js +101 -0
- package/lib/host/persona.js +214 -0
- package/lib/host/retitle.js +214 -0
- package/lib/host/rules.js +195 -0
- package/lib/host/scan-dsweb.js +111 -0
- package/lib/host/seal.js +17 -0
- package/lib/host/settings-file.js +49 -0
- package/lib/host/snapshot.js +168 -0
- package/lib/host/suggestions.js +47 -0
- package/lib/host/summarizer.js +103 -0
- package/lib/host/tone-advice.js +112 -0
- package/lib/host/tools.js +1067 -0
- package/lib/host/tree-ops.js +330 -0
- package/lib/host/util.js +165 -0
- package/lib/host/zstd.js +63 -0
- package/lib/index.js +263 -0
- package/package.json +69 -0
- package/tools/apply-access-log-patch.mjs +295 -0
package/lib/host/l1.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// dsh-ling host — L1 opener selection v0 (DESIGN §6.2).
|
|
2
|
+
// heat = α·recency + β·freq + γ·importance (exponential decay, half-life configurable);
|
|
3
|
+
// mode weights by category (knowledge/daily/feeling); optional keyword pre-match.
|
|
4
|
+
import { toEpochMs, isoDate } from './util.js';
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 86_400_000;
|
|
7
|
+
|
|
8
|
+
export function decayWeight(daysSince, halfLifeDays) {
|
|
9
|
+
if (halfLifeDays <= 0) return 0;
|
|
10
|
+
return Math.pow(0.5, daysSince / halfLifeDays);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function computeHeat(row, nowMs, cfg) {
|
|
14
|
+
const c = cfg || {};
|
|
15
|
+
const half = Number(c.halfLifeDays ?? 90);
|
|
16
|
+
const t = toEpochMs(row.updated_at || row.started_at);
|
|
17
|
+
// 解析不出来时按"刚更新"处理(不惩罚未知),但绝不再让坏串长期拿满分:
|
|
18
|
+
// 只要能被 toEpochMs 认出(含 "1789006881011.0"),就照真实时间衰减。
|
|
19
|
+
const days = t === null ? 0 : Math.max(0, (nowMs - t) / DAY_MS);
|
|
20
|
+
const recency = decayWeight(days, half);
|
|
21
|
+
const freq = Math.min(1, (Number(row.hit_count ?? 0) + Number(row.importance ?? 0)) / 20);
|
|
22
|
+
const alpha = Number(c.heatAlpha ?? 0.5);
|
|
23
|
+
const beta = Number(c.heatBeta ?? 0.3);
|
|
24
|
+
const gamma = Number(c.heatGamma ?? 0.2);
|
|
25
|
+
return alpha * recency + beta * freq + gamma * Math.min(1, Number(row.importance ?? 0));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CATEGORY_DEFAULTS = { knowledge: 1, daily: 0.5, feeling: 0.3 };
|
|
29
|
+
|
|
30
|
+
/** 3-gram set of a token (CJK-agnostic). */
|
|
31
|
+
function grams3(s) {
|
|
32
|
+
const out = new Set();
|
|
33
|
+
for (let i = 0; i + 3 <= s.length; i++) out.add(s.slice(i, i + 3));
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* keyword overlap between query tokens and row keywords:
|
|
39
|
+
* hit when a pair (1) contains the other (len>=2), or (2) shares a 3-gram —
|
|
40
|
+
* robust to token-window offsets (query 4-grams vs stored 6-grams).
|
|
41
|
+
*/
|
|
42
|
+
function kwOverlap(queryKeywords, rowKeywords) {
|
|
43
|
+
if (!queryKeywords || !queryKeywords.length || !rowKeywords || !rowKeywords.length) return 0;
|
|
44
|
+
let hit = 0;
|
|
45
|
+
for (const q of queryKeywords) {
|
|
46
|
+
const qs = String(q).toLowerCase();
|
|
47
|
+
if (qs.length < 2) continue;
|
|
48
|
+
const qg = grams3(qs);
|
|
49
|
+
for (const k of rowKeywords) {
|
|
50
|
+
const ks = String(k).toLowerCase();
|
|
51
|
+
if (ks.length < 2) continue;
|
|
52
|
+
if (ks.includes(qs) || qs.includes(ks)) {
|
|
53
|
+
hit += 1;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (qg.size) {
|
|
57
|
+
let shared = false;
|
|
58
|
+
for (let i = 0; i + 3 <= ks.length; i++) {
|
|
59
|
+
if (qg.has(ks.slice(i, i + 3))) {
|
|
60
|
+
shared = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (shared) {
|
|
65
|
+
hit += 1;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return hit;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const KW_BONUS_PER_HIT = 0.6;
|
|
75
|
+
const KW_BONUS_CAP = 1.8;
|
|
76
|
+
/** 「有信息量的摘要」加权(2026-09-16):只有标题的行(如全部 dsweb 行)不加分,
|
|
77
|
+
* 真正带摘要的行加分 —— 用相对分差把"信息密度低"的行往后压,而不是硬过滤。
|
|
78
|
+
* 判定:摘要去掉"标题前缀"与"— N 条消息"尾巴后仍 ≥ 20 字才算有信息量。 */
|
|
79
|
+
const SUMMARY_BONUS_DEFAULT = 0.25;
|
|
80
|
+
|
|
81
|
+
export function summaryWeight(row) {
|
|
82
|
+
const s = String(row?.summary || '').trim();
|
|
83
|
+
if (!s) return 0;
|
|
84
|
+
const t = String(row?.title || '').trim();
|
|
85
|
+
let body = s;
|
|
86
|
+
if (t && body.startsWith(t)) body = body.slice(t.length).replace(/^[\s::—\-]+/, '');
|
|
87
|
+
body = body.replace(/\s*[—\-]\s*\d+\s*条消息\s*$/, '').trim();
|
|
88
|
+
return body.length >= 20 ? 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 主干枝 id(与 `memory.js` 的 `TRUNK_ID` 一致;算法模块不耦合存储层,故此处独立定义)。 */
|
|
92
|
+
const TRUNK_BRANCH = 'trunk';
|
|
93
|
+
/** 血缘档位兜底:未知枝按"旁系"算(与 memory.js 的 `LINEAGE_SIDE` 一致)。 */
|
|
94
|
+
const LINEAGE_SIDE_FALLBACK = 0.4;
|
|
95
|
+
|
|
96
|
+
/** D9-a:取当前会话的血缘上下文(所属枝 + 枝权重表 + 会话→枝 表)。
|
|
97
|
+
* 任一步不可用则返回 null —— 此时不加权(等价于旧行为),保证向后兼容。 */
|
|
98
|
+
/** D9-a/D9-b:取当前会话的血缘上下文(所属枝 + 枝权重表 + 会话→枝 表 + 矛盾降权表)。
|
|
99
|
+
* **矛盾降权与血缘正交** —— 即使不传 sessionId(不加血缘权重),矛盾降权也必须生效;
|
|
100
|
+
* 两者都拿不到时才返回 null(等价于旧行为),保证向后兼容。 */
|
|
101
|
+
function lineageContext(store, sessionId) {
|
|
102
|
+
try {
|
|
103
|
+
if (!store) return null;
|
|
104
|
+
const sid = String(sessionId || '');
|
|
105
|
+
const conflicts = typeof store.conflictDowngradeMap === 'function' ? store.conflictDowngradeMap() : null;
|
|
106
|
+
const noLineage = !sid || typeof store.branchOfSession !== 'function';
|
|
107
|
+
const branch = noLineage ? TRUNK_BRANCH : store.branchOfSession(sid);
|
|
108
|
+
const weights = noLineage
|
|
109
|
+
? null
|
|
110
|
+
: (typeof store.lineageWeightMap === 'function' ? store.lineageWeightMap(branch) : null);
|
|
111
|
+
const sessionBranch = noLineage
|
|
112
|
+
? null
|
|
113
|
+
: (typeof store.sessionBranchMap === 'function' ? store.sessionBranchMap() : null);
|
|
114
|
+
// 方案 A(2026-09-21):非会话源的**显式**归属覆盖层 —— dsweb/import 的历史条目没有会话身份,
|
|
115
|
+
// 靠 session_meta 推不出枝。与血缘正交(枝归属不依赖"当前会话"),故独立于 noLineage 取值。
|
|
116
|
+
const convBranch = typeof store.convBranchMap === 'function' ? store.convBranchMap() : null;
|
|
117
|
+
if (!weights && !conflicts) return null;
|
|
118
|
+
return { branch, weights, sessionBranch, convBranch, conflicts };
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** D9-a:一条记忆行所属枝的血缘档位。
|
|
125
|
+
* 方案 A(2026-09-21):`conv_branch` 显式覆盖**优先** —— dsweb/import 的历史条目靠它挂枝;
|
|
126
|
+
* 未覆盖时,dsh 源走会话推导,其余归主干。 */
|
|
127
|
+
function lineageOf(ctx, row) {
|
|
128
|
+
if (!ctx || !ctx.weights) return 1;
|
|
129
|
+
const src = String(row?.source || '');
|
|
130
|
+
const cid = String(row?.conv_id || '');
|
|
131
|
+
const ov = ctx.convBranch && ctx.convBranch.size ? ctx.convBranch.get(src + '\u0000' + cid) : undefined;
|
|
132
|
+
if (ov) return ctx.weights.get(ov) ?? LINEAGE_SIDE_FALLBACK;
|
|
133
|
+
let bid = TRUNK_BRANCH;
|
|
134
|
+
if (src === 'dsh' && ctx.sessionBranch) {
|
|
135
|
+
bid = ctx.sessionBranch.get(cid) || TRUNK_BRANCH;
|
|
136
|
+
}
|
|
137
|
+
return ctx.weights.get(bid) ?? LINEAGE_SIDE_FALLBACK;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** D9-b:矛盾降权因子。命中 `"source\u0000convId"` 即乘系数(默认 0.3)。
|
|
141
|
+
* 规则见 `memory.js` 的 `conflictDowngradeMap()`:复盘裁定的按裁定,**未复盘的以最新为准**;
|
|
142
|
+
* 只降权,不删除、不改写内容 —— 这是记忆树的核心不变量。 */
|
|
143
|
+
function conflictOf(ctx, row) {
|
|
144
|
+
if (!ctx || !ctx.conflicts || !ctx.conflicts.size) return 1;
|
|
145
|
+
const key = String(row?.source || '') + '\u0000' + String(row?.conv_id || '');
|
|
146
|
+
return ctx.conflicts.get(key) ?? 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pick Top-N overviews for the L1 opener.
|
|
151
|
+
* @param {MemoryStore} store
|
|
152
|
+
* @param {object} opts { mode, keywords[], categoryWeights, maxItems, budgetChars, sessionId }
|
|
153
|
+
* `sessionId`(D9-a 起):用于血缘加权 —— 不传则不加权(旧行为)。
|
|
154
|
+
* @returns {{items: Array, dropped: number, totalChars: number}}
|
|
155
|
+
*/
|
|
156
|
+
export function selectL1(store, opts) {
|
|
157
|
+
const o = opts || {};
|
|
158
|
+
const mode = o.mode === 'work' ? 'work' : 'life';
|
|
159
|
+
const weights = Object.assign({}, CATEGORY_DEFAULTS, (o.categoryWeights || {})[mode] || {});
|
|
160
|
+
const max = Math.max(1, Math.min(50, Number(o.maxItems ?? 8)));
|
|
161
|
+
const budget = Math.max(200, Number(o.budgetChars ?? 6000)); // ~1.5 chars/token 中文估
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const summaryBonus = Number.isFinite(Number(o.summaryBonus)) ? Number(o.summaryBonus) : SUMMARY_BONUS_DEFAULT;
|
|
164
|
+
// 2026-09-21:归档会话不参与召回 —— 归档是主人的明确动作("这条别再提了"),
|
|
165
|
+
// 而此前 selectL1 直接吃 listOverviews 全量:归档只影响界面,召回照旧(用户实测反馈)。
|
|
166
|
+
// 只对 source='dsh' 生效:archived 是会话级标记,历史网页端/导入条目没有会话行。
|
|
167
|
+
const archived = (() => {
|
|
168
|
+
try {
|
|
169
|
+
return typeof store.archivedConvIdSet === 'function' ? store.archivedConvIdSet() : new Set();
|
|
170
|
+
} catch {
|
|
171
|
+
return new Set();
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
// Part G G2(2026-09-25):被"遗忘"的条目不再参与召回。
|
|
175
|
+
// 遗忘是**软标记**(库里行还在,归档文件也在),所以撤销标记就恢复 —— 但召回必须真的不再选它,
|
|
176
|
+
// 否则"忘了"只是界面上的幻觉:主人说要忘,我却还在每一次开口时想起来。
|
|
177
|
+
// 键是 `source|conv_id`:历史网页端与导入条目没有会话行,不能只按会话判。
|
|
178
|
+
const forgotten = (() => {
|
|
179
|
+
try {
|
|
180
|
+
return typeof store.forgottenConvIdSet === 'function' ? store.forgottenConvIdSet() : new Set();
|
|
181
|
+
} catch {
|
|
182
|
+
return new Set();
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
const all = store.listOverviews({ onlyOk: true });
|
|
186
|
+
// D9-a 血缘加权(2026-09-18):当前会话所属枝 → 每条记忆所属枝的档位
|
|
187
|
+
// (同枝 1.0 / 祖先 0.7 / 旁系或后代 0.4)。取"最弱环"单值,**不做连乘** ——
|
|
188
|
+
// `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失(见 `memory.js` 的 `LINEAGE_*`)。
|
|
189
|
+
// 记忆行不冗余存 branch,靠 `session_meta.branch_id` 推导(见内部记忆树设计笔记 §2.2)。
|
|
190
|
+
const ctx = lineageContext(store, o.sessionId);
|
|
191
|
+
const scored = all
|
|
192
|
+
.map((row) => {
|
|
193
|
+
const heat = computeHeat(row, now, {});
|
|
194
|
+
const catW = weights[row.category] ?? weights.daily ?? 0.5;
|
|
195
|
+
const kw = kwOverlap(o.keywords || [], row.keywords);
|
|
196
|
+
const rich = summaryWeight(row) * summaryBonus;
|
|
197
|
+
const raw = catW * heat + Math.min(KW_BONUS_CAP, kw * KW_BONUS_PER_HIT) + (row.importance >= 1 ? 0.8 : 0) + rich;
|
|
198
|
+
const lineage = lineageOf(ctx, row);
|
|
199
|
+
// D9-b:矛盾降权 —— 检出矛盾但未复盘时"以最新为准",旧的一方乘 CONFLICT_DOWNWEIGHT(0.3)。
|
|
200
|
+
// 只降权、不删除、不改内容(设计稿 §4 的核心不变量)。
|
|
201
|
+
const conf = conflictOf(ctx, row);
|
|
202
|
+
return { row, score: raw * lineage * conf, lineage, raw, heat, kw, rich, conf };
|
|
203
|
+
})
|
|
204
|
+
.filter((x) => x.score > 0
|
|
205
|
+
&& !(x.row.source === 'dsh' && archived.has(String(x.row.conv_id)))
|
|
206
|
+
&& !forgotten.has(String(x.row.source) + '|' + String(x.row.conv_id)))
|
|
207
|
+
.sort((a, b) => b.score - a.score || String(b.row.updated_at || '').localeCompare(String(a.row.updated_at || '')));
|
|
208
|
+
const chosen = scored.slice(0, max);
|
|
209
|
+
// budget-aware trim
|
|
210
|
+
const items = [];
|
|
211
|
+
let totalChars = 0;
|
|
212
|
+
for (const c of chosen) {
|
|
213
|
+
const line = lineFor(c.row, { maxChars: o.maxLineChars, summaryChars: o.summaryChars });
|
|
214
|
+
if (items.length >= max || totalChars + line.length > budget) break;
|
|
215
|
+
items.push({ ...c.row, score: +c.score.toFixed(3), lineage: +Number(c.lineage).toFixed(2), conf: +Number(c.conf ?? 1).toFixed(2), raw: +Number(c.raw).toFixed(3), kw: c.kw, rich: c.rich, line });
|
|
216
|
+
totalChars += line.length;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
items,
|
|
220
|
+
dropped: scored.length - items.length,
|
|
221
|
+
totalChars,
|
|
222
|
+
mode,
|
|
223
|
+
budget,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const SUMMARY_CHARS_DEFAULT = 110;
|
|
228
|
+
/** 取"第一句":按中文句末标点(。;!?及英文 !?;)切;切完仍超 cap 则硬截并标 …。
|
|
229
|
+
* cap ≤ 0 = 不截(供测试与向后兼容)。 */
|
|
230
|
+
export function firstSentence(text, cap = SUMMARY_CHARS_DEFAULT) {
|
|
231
|
+
const t = String(text || '').trim();
|
|
232
|
+
if (!t) return '';
|
|
233
|
+
const m = t.match(/^[\s\S]*?[。;!?!?;]/);
|
|
234
|
+
let head = (m ? m[0] : t).trim();
|
|
235
|
+
const limit = Number(cap) > 0 ? Math.max(40, Number(cap)) : 0;
|
|
236
|
+
if (limit && head.length > limit) {
|
|
237
|
+
const slice = head.slice(0, limit);
|
|
238
|
+
const cut = Math.max(slice.lastIndexOf(','), slice.lastIndexOf(','), slice.lastIndexOf('、'));
|
|
239
|
+
head = (cut >= Math.floor(limit * 0.6) ? slice.slice(0, cut) : slice) + '…';
|
|
240
|
+
}
|
|
241
|
+
return head;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 一行 L1 记忆(两段结构 + 允许缺省,2026-09-16 与用户定案):
|
|
246
|
+
* 有摘要 → `标题 —— 摘要第一句`(第一句超 summaryChars 再硬截)
|
|
247
|
+
* 无摘要 → 只用标题(信息量优先,不截;仍受 maxChars 兜底)
|
|
248
|
+
* 摘要自带标题(概述器/深摘的常态)→ 不重复前缀,只留正文
|
|
249
|
+
* @param {object} row
|
|
250
|
+
* @param {{maxChars?:number, summaryChars?:number}} opts
|
|
251
|
+
*/
|
|
252
|
+
function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT } = {}) {
|
|
253
|
+
const date = isoDate(row.updated_at || row.started_at);
|
|
254
|
+
const tags = Array.isArray(row.domain_tags) && row.domain_tags.length ? `[${row.domain_tags.slice(0, 2).join('/')}]` : `[${row.category}]`;
|
|
255
|
+
const title = String(row.title || '').trim();
|
|
256
|
+
const summary = String(row.summary || '').trim();
|
|
257
|
+
// 摘要去掉标题前缀,只留正文(摘要自带标题时不重复)
|
|
258
|
+
let body = summary;
|
|
259
|
+
if (title && body.startsWith(title)) body = body.slice(title.length).replace(/^[\s::—-]+/, '');
|
|
260
|
+
if (body === title) body = '';
|
|
261
|
+
const text = (title && body) ? `${title} —— ${firstSentence(body, summaryChars)}` : (title || firstSentence(body, summaryChars));
|
|
262
|
+
// 兜底硬顶:只对病态长标题生效(摘要侧已由 summaryChars 控住)
|
|
263
|
+
const cap = Number(maxChars) > 0 ? Math.max(60, Number(maxChars)) : 0;
|
|
264
|
+
let out = text;
|
|
265
|
+
if (cap && out.length > cap) {
|
|
266
|
+
const slice = out.slice(0, cap);
|
|
267
|
+
const cut = Math.max(slice.lastIndexOf('。'), slice.lastIndexOf(';'), slice.lastIndexOf('!'), slice.lastIndexOf('?'), slice.lastIndexOf('\n'));
|
|
268
|
+
out = (cut >= Math.floor(cap * 0.5) ? slice.slice(0, cut + 1) : slice) + '…';
|
|
269
|
+
}
|
|
270
|
+
const srcName = row.source === 'dsh' ? 'DSH 会话' : row.source === 'import' ? '文件导入' : '历史会话';
|
|
271
|
+
// DSH 会话 id 一律以 "session-" 开头,取前 8 位会全都一样 —— 这时取前缀之后的那一段才可区分
|
|
272
|
+
const cid = String(row.conv_id || '');
|
|
273
|
+
const short = cid.startsWith('session-') ? cid.slice(8, 16) : cid.slice(0, 8);
|
|
274
|
+
return `- ${tags} ${date ? `(${date})` : ''}${out ? ' ' + out : ''} (来源: ${srcName}/${short})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function formatL1Section(result) {
|
|
278
|
+
if (!result || !result.items.length) return '';
|
|
279
|
+
const lines = result.items.map((i) => i.line);
|
|
280
|
+
if (result.dropped > 0) lines.push(`(已省略 ${result.dropped} 条较低相关记忆)`);
|
|
281
|
+
return '[记忆·开场]\n' + lines.join('\n');
|
|
282
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// dsh-ling host — lifecycle wiring (REPORT-02 / DESIGN §4.4):
|
|
2
|
+
// session events → freeze gate state, snapshot freeze, incremental capture.
|
|
3
|
+
// Every handler is defensive (payload shapes verified at M1b live probe).
|
|
4
|
+
import { svc, pick, isMemoryEligibleHeader, sessionKind, utcIso } from './util.js';
|
|
5
|
+
import { TRUNK_ID } from './memory.js';
|
|
6
|
+
import { freezeSnapshotForSession, invalidateSession, refreshIdleSnapshot } from './inject.js';
|
|
7
|
+
import { summarizeDsh } from './summarizer.js';
|
|
8
|
+
import { extractMessageText, messageSourceKind } from './snapshot.js';
|
|
9
|
+
import { currentMode } from './mode.js';
|
|
10
|
+
|
|
11
|
+
export function wireLifecycle(ctx, gate, memory, settings, config) {
|
|
12
|
+
const disposers = [];
|
|
13
|
+
const wrap = (label, fn) => {
|
|
14
|
+
const handler = (...args) => {
|
|
15
|
+
try {
|
|
16
|
+
fn(...args);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.debug(`[dsh-ling] ${label} handler error`, e);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return handler;
|
|
22
|
+
};
|
|
23
|
+
// 清掉上次的诊断残留,保证每次重启后诊断只反映本轮真实情况
|
|
24
|
+
memory.kvSet('dbg.first_event_sample', '');
|
|
25
|
+
memory.kvSet('dbg.event_no_session', '');
|
|
26
|
+
memory.kvSet('lifecycle.wired', '1');
|
|
27
|
+
|
|
28
|
+
const inTrackedWorkspace = () => {
|
|
29
|
+
const t = config?.trackWorkspaces || settings.get().memory?.trackWorkspaces;
|
|
30
|
+
return !Array.isArray(t) || t.includes('*');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---- 会话启动(新会话 / resume / clear):顶层会话定稿快照 ----
|
|
34
|
+
// 事件名跨 cohort:`agent/created`(DSH ≥0.1.7)/ `agent/session-start`(≤0.1.6)
|
|
35
|
+
const startHandler = wrap('session-start', (payload) => {
|
|
36
|
+
if (!inTrackedWorkspace()) return;
|
|
37
|
+
const agent = pick(() => payload?.agent, () => payload?.session?.agent, () => payload?.agentCtx);
|
|
38
|
+
const sessionId = pick(
|
|
39
|
+
() => agent?.session?.id,
|
|
40
|
+
() => payload?.session?.id,
|
|
41
|
+
() => payload?.sessionId,
|
|
42
|
+
() => payload?.agent?.sessionId,
|
|
43
|
+
);
|
|
44
|
+
if (!sessionId) return;
|
|
45
|
+
const header = pick(() => agent?.session?.header, () => payload?.session?.header, () => payload?.header);
|
|
46
|
+
if (header && !isMemoryEligibleHeader(header)) return; // 子代理不建快照(R5);D9-a:分叉会话要建
|
|
47
|
+
// D9-a:分叉会话归枝(建枝 + 挂会话 + 定水位),之后照常定稿快照 —— 它能读到血缘链记忆
|
|
48
|
+
if (sessionKind(header) === 'fork') ensureForkBranch(memory, agent, header, String(sessionId));
|
|
49
|
+
const source = pick(() => payload?.source, () => agent?.sessionStartSource);
|
|
50
|
+
const meta = memory.sessionMeta(String(sessionId));
|
|
51
|
+
// 模式初始化(修法 A,2026-09-11):
|
|
52
|
+
// 空会话(含 DSH 复用的空壳/临时文件)不固化模式 —— 清掉旧值,跟随"当前默认";
|
|
53
|
+
// 只有真正说过话的会话才保留自己的模式;"首次真人输入"时才写下那一刻的默认。
|
|
54
|
+
const hasContent = memory.hasUserTurns(String(sessionId));
|
|
55
|
+
let mode = null;
|
|
56
|
+
if (!hasContent) {
|
|
57
|
+
memory.clearSessionMode(String(sessionId));
|
|
58
|
+
const last = settings.get().mode?.lastMode;
|
|
59
|
+
mode = (last === 'work' || last === 'life') ? last : platformDefaultMode(ctx, settings);
|
|
60
|
+
} else {
|
|
61
|
+
// resume/clear/compact = 保留既有 meta(该会话自己选过的模式优先)
|
|
62
|
+
if (source !== 'startup' && source !== undefined && meta?.mode && (meta.mode === 'work' || meta.mode === 'life')) {
|
|
63
|
+
mode = meta.mode;
|
|
64
|
+
}
|
|
65
|
+
if (!mode) mode = platformDefaultMode(ctx, settings);
|
|
66
|
+
memory.setSessionMode(String(sessionId), mode);
|
|
67
|
+
}
|
|
68
|
+
gate.ensure(String(sessionId), { mode });
|
|
69
|
+
freezeSnapshotForSession(gate, memory, settings, agent);
|
|
70
|
+
});
|
|
71
|
+
// ⚠️ 事件改名(2026-09-25 夜**实测**):`agent/session-start` 在 DSH **0.1.7** 的
|
|
72
|
+
// `node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\**` 里**一个都不存在**;
|
|
73
|
+
// 现在叫 **`agent/created`**,载荷是 `{ agent, source, signal }` —— 与本 handler 的
|
|
74
|
+
// 取值路径(`payload.agent` / `payload.source`)**完全一致**,只有事件名对不上。
|
|
75
|
+
// 后果极隐蔽:事件名不存在 ⇒ handler 永不触发 ⇒ **没有快照 ⇒ 记忆与人格注入整段
|
|
76
|
+
// 静默空白**(只剩时间锚),没有报错、没有日志、库里也不留痕迹。现场取证:
|
|
77
|
+
// `last_hit_at` 与所有 `branch_log_wm.<会话>` 全部停在升级之前,`snap.refresh:*` 无新键。
|
|
78
|
+
// 这正是内部踩坑记录 **B19** 说的那类:**跨进程契约(事件名/字段)驱动的判据,平台升级后
|
|
79
|
+
// 必须复核一遍** —— 而且失败形态是静默的。
|
|
80
|
+
// 修法:**两个名字都注册**(跨 cohort 兼容,不赌平台只留哪一个),加短窗口去重
|
|
81
|
+
// (将来两者同存时不会定稿两次 → 不会把 hit_count 刷两遍)。
|
|
82
|
+
const freezeSeen = new Map();
|
|
83
|
+
const dedupStart = (payload) => {
|
|
84
|
+
try {
|
|
85
|
+
const sid = pick(
|
|
86
|
+
() => payload?.agent?.session?.id,
|
|
87
|
+
() => payload?.session?.id,
|
|
88
|
+
() => payload?.sessionId,
|
|
89
|
+
() => payload?.agent?.sessionId,
|
|
90
|
+
);
|
|
91
|
+
const key = sid ? String(sid) : '';
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (key && freezeSeen.get(key) && now - freezeSeen.get(key) < 3000) return; // 双事件同发时只定稿一次
|
|
94
|
+
if (key) freezeSeen.set(key, now);
|
|
95
|
+
} catch { /* 去重失败就照常执行,宁可多定稿一次也不漏 */ }
|
|
96
|
+
return startHandler(payload);
|
|
97
|
+
};
|
|
98
|
+
disposers.push(ctx.on('agent/created', dedupStart)); // DSH ≥0.1.7
|
|
99
|
+
disposers.push(ctx.on('agent/session-start', dedupStart)); // DSH ≤0.1.6(旧名,保留兼容)
|
|
100
|
+
|
|
101
|
+
// ---- agent/status: idle⇄running → 冻结门;running→idle flush pending ----
|
|
102
|
+
disposers.push(
|
|
103
|
+
ctx.on('agent/status', wrap('status', (payload) => {
|
|
104
|
+
const obs = gate.observeStatusEvent(payload);
|
|
105
|
+
let sid = null;
|
|
106
|
+
let running = null;
|
|
107
|
+
if (obs) {
|
|
108
|
+
sid = obs.sessionId;
|
|
109
|
+
running = obs.running === true;
|
|
110
|
+
} else {
|
|
111
|
+
sid = pick(() => payload?.agent?.session?.id, () => payload?.agent?.id, () => payload?.id);
|
|
112
|
+
const raw = pick(() => payload?.status, () => payload?.state);
|
|
113
|
+
if (sid && typeof raw === 'string') running = raw === 'running';
|
|
114
|
+
}
|
|
115
|
+
if (!sid || running === null) return;
|
|
116
|
+
const flippedIdle = gate.setRunning(String(sid), running);
|
|
117
|
+
if (flippedIdle) {
|
|
118
|
+
// 空闲边界:对会话日志做水位差分捕捉(与 session/event 双保险,幂等)
|
|
119
|
+
const sess = pick(() => payload?.agent?.session, () => payload?.session, () => payload?.agentSession);
|
|
120
|
+
try {
|
|
121
|
+
captureSessionDiff(memory, sess, settings);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.debug('[dsh-ling] idle-diff capture failed', e);
|
|
124
|
+
}
|
|
125
|
+
// B:空闲边界刷新 —— 记忆长进之后,把该会话的 L1 追平到最新。
|
|
126
|
+
// 只可能在 running→idle 时发生,故 D4(运行中注入面逐字节不变)天然保持。
|
|
127
|
+
try {
|
|
128
|
+
refreshIdleSnapshot(gate, memory, settings, String(sid), {
|
|
129
|
+
summarize: () => summarizeDsh(memory, { force: false }),
|
|
130
|
+
});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.debug('[dsh-ling] idle refresh failed', e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ---- session/event: 逐条增量捕捉(顶层会话;被动记录,不受冻结门限制) ----
|
|
139
|
+
// 契约:('session/event', (session, event) => void)
|
|
140
|
+
// 真实事件形状(磁盘取证):{ type, seq, time(ms), data:{ content|message.content, source } }
|
|
141
|
+
disposers.push(
|
|
142
|
+
ctx.on('session/event', wrap('event-capture', (session, event) => {
|
|
143
|
+
const type = event?.type;
|
|
144
|
+
if (!event) {
|
|
145
|
+
if (!memory.kvGet('dbg.first_event_sample')) {
|
|
146
|
+
try {
|
|
147
|
+
memory.kvSet('dbg.first_event_sample', JSON.stringify({
|
|
148
|
+
note: 'event 参数缺失', sessionKeys: session ? Object.keys(session).slice(0, 10) : null,
|
|
149
|
+
}).slice(0, 300));
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (type !== 'user/message' && type !== 'assistant/message') return;
|
|
155
|
+
// 只留真人消息(排除 plugin/skill-catalog/goal 注入)
|
|
156
|
+
if (type === 'user/message' && messageSourceKind(event) !== 'user') return;
|
|
157
|
+
const sessionId = pick(
|
|
158
|
+
() => session?.id,
|
|
159
|
+
() => session?.header?.id,
|
|
160
|
+
() => session?.sessionId,
|
|
161
|
+
);
|
|
162
|
+
if (!sessionId) {
|
|
163
|
+
if (!memory.kvGet('dbg.event_no_session')) {
|
|
164
|
+
memory.kvSet('dbg.event_no_session', JSON.stringify({ type, eventKeys: Object.keys(event).slice(0, 10) }).slice(0, 300));
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const header = pick(() => session?.header, () => session?.headerMeta);
|
|
169
|
+
if (header && !isMemoryEligibleHeader(header)) return; // 子代理/嵌套不入库(D9-a:分叉会话入枝)
|
|
170
|
+
const text = extractMessageText(event);
|
|
171
|
+
if (!text) return;
|
|
172
|
+
const seq = Number(event?.seq ?? (memory.rawTurnCount(sessionId) + 1));
|
|
173
|
+
const time = Number(event?.time);
|
|
174
|
+
const isUser = type.startsWith('user');
|
|
175
|
+
memory.appendRawTurn(String(sessionId), {
|
|
176
|
+
seq,
|
|
177
|
+
role: isUser ? 'user' : 'assistant',
|
|
178
|
+
ts: time && !Number.isNaN(time) ? new Date(time).toISOString() : null,
|
|
179
|
+
model: null,
|
|
180
|
+
text,
|
|
181
|
+
});
|
|
182
|
+
if (isUser) stampModeIfFirstInput(memory, settings, String(sessionId));
|
|
183
|
+
})),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// ---- session/disposed: 归档点(会话拆除后,天然安全) ----
|
|
187
|
+
disposers.push(
|
|
188
|
+
ctx.on('session/disposed', wrap('disposed', (payload) => {
|
|
189
|
+
const sessionId = pick(() => payload?.session?.id, () => payload?.sessionId, () => payload?.id);
|
|
190
|
+
if (!sessionId) return;
|
|
191
|
+
const sid = String(sessionId);
|
|
192
|
+
memory.markArchived(sid);
|
|
193
|
+
// (2026-09-16 用户定案 A)归档**不做自动清理**:归档只是标记"该会话结束了",
|
|
194
|
+
// 而 session/disposed 对每个关闭的会话都会触发 —— 自动删概述会长期把记忆库掏空。
|
|
195
|
+
// 清理改为人工:①DSH 官方「设置 → 归档会话」里直接删;②tools/clean-archived.mjs --write(先看清单)。
|
|
196
|
+
// 归档的语义保留为"停更":概述器跳过 archived=1 的会话(见 summarizer.js);该会话一旦有新轮次即复活。
|
|
197
|
+
})),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
for (const d of disposers) {
|
|
202
|
+
try {
|
|
203
|
+
d();
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** D9-a(2026-09-18):分叉会话自动建枝。
|
|
210
|
+
* 分叉 = 主人显式开的并行工作线,它**有自己的记忆**(写在所属枝里),同时**能读祖先链**。
|
|
211
|
+
* - 父枝:父会话所属枝(逐级上溯,天然支持"枝上再分枝");
|
|
212
|
+
* - 水位:分叉点之前属于父枝的历史,不重复计入本枝(见内部记忆树设计笔记 §6.1);
|
|
213
|
+
* - 枝名:先留空,命名沿用 D2 的 retitle 链路(retitleOne 会补)。
|
|
214
|
+
* 幂等:重启/resume 时已挂枝则直接返回。 */
|
|
215
|
+
function ensureForkBranch(memory, session, header, sessionId) {
|
|
216
|
+
try {
|
|
217
|
+
const parentSid = String(pick(() => header?.parentSession) ?? '');
|
|
218
|
+
if (!parentSid) return memory.branchOfSession(sessionId);
|
|
219
|
+
const existing = memory.branchOfSession(sessionId);
|
|
220
|
+
if (existing && existing !== TRUNK_ID) return existing; // 已建过
|
|
221
|
+
const parentBranch = memory.branchOfSession(parentSid);
|
|
222
|
+
let forkSeq =
|
|
223
|
+
Number(pick(() => session?.inheritedEventCount, () => session?.header?.inheritedEventCount) ?? 0) || 0;
|
|
224
|
+
// 兜底(2026-09-19 实机教训):平台没给出 inheritedEventCount 时水位会被写成 0,
|
|
225
|
+
// 于是 idle 边界上 captureSessionDiff 会把 seed(父会话继承来的全部历史)当成新轮次灌进枝。
|
|
226
|
+
// 取不到就退回"快照里已有的最大 seq"(≈ 分叉点),让水位落在正确位置。
|
|
227
|
+
if (!forkSeq && typeof session?.snapshotEvents === 'function') {
|
|
228
|
+
try {
|
|
229
|
+
const evs = session.snapshotEvents();
|
|
230
|
+
if (Array.isArray(evs) && evs.length) {
|
|
231
|
+
forkSeq = evs.reduce((mx, e) => Math.max(mx, Number(e?.seq ?? 0)), 0);
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
/* 仍是 0:由 captureSessionDiff 的 isOwnSeq 过滤兜底 */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const branchId = memory.createBranch({
|
|
238
|
+
kind: 'branch',
|
|
239
|
+
parentId: parentBranch,
|
|
240
|
+
forkAt: parentSid,
|
|
241
|
+
forkSeq,
|
|
242
|
+
name: branchSeedName(memory, parentSid),
|
|
243
|
+
});
|
|
244
|
+
memory.setSessionBranch(sessionId, branchId);
|
|
245
|
+
if (memory.kvGet('wm:' + sessionId) == null) memory.kvSet('wm:' + sessionId, String(forkSeq));
|
|
246
|
+
memory.kvSet(
|
|
247
|
+
'branch.last_created',
|
|
248
|
+
JSON.stringify({ branchId, parentSid, parentBranch, forkSeq, at: utcIso() }).slice(0, 300),
|
|
249
|
+
);
|
|
250
|
+
return branchId;
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.debug('[dsh-ling] ensureForkBranch failed', e);
|
|
253
|
+
return TRUNK_ID;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** D9-a 枝名种子:先借源会话的标题(器灵起名的第一层),复盘时可改、可上锁。
|
|
258
|
+
* 枝刚建立时还没有自己的内容,故这里不调模型 —— 免得"每开一个分叉就打一次 LLM"。 */
|
|
259
|
+
function branchSeedName(memory, parentSid) {
|
|
260
|
+
try {
|
|
261
|
+
const t = memory.overviewTitleOf(parentSid);
|
|
262
|
+
return t ? String(t).slice(0, 40) : '';
|
|
263
|
+
} catch {
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 首次真人输入时才固化会话模式(修法 A:空会话永远跟随当前默认)。 */
|
|
269
|
+
function stampModeIfFirstInput(memory, settings, sessionId) {
|
|
270
|
+
const sid = String(sessionId);
|
|
271
|
+
const meta = memory.sessionMeta(sid);
|
|
272
|
+
if (meta && (meta.mode === 'work' || meta.mode === 'life')) return false;
|
|
273
|
+
memory.setSessionMode(sid, currentMode(memory, settings, sid));
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 平台默认模型 → 模式:effort low/off=生活;max=工作;high/未知=回退 lastMode。 */
|
|
278
|
+
function platformDefaultMode(ctx, settings) {
|
|
279
|
+
try {
|
|
280
|
+
const adm = svc(ctx, 'agentDefaultModel');
|
|
281
|
+
const sel = adm && typeof adm.currentSelection === 'function' ? adm.currentSelection() : null;
|
|
282
|
+
const eff = sel && sel.reasoningEffort;
|
|
283
|
+
if (eff === 'low' || eff === 'off') return 'life';
|
|
284
|
+
if (eff === 'max') return 'work';
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.debug('[dsh-ling] platformDefaultMode failed', e);
|
|
287
|
+
}
|
|
288
|
+
const last = settings.get().mode?.lastMode;
|
|
289
|
+
return last === 'work' ? 'work' : 'life';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 空闲边界差分捕捉:直接对会话快照事件日志按水位线(wm:<sid>)追加新增的
|
|
294
|
+
* user/assistant 消息;以 (session_id, seq) 为主键幂等,与 session/event 订阅双保险。
|
|
295
|
+
* @returns 新增入库条数
|
|
296
|
+
*/
|
|
297
|
+
export function captureSessionDiff(memory, session, settings = null) {
|
|
298
|
+
if (!session) return 0;
|
|
299
|
+
const sid = pick(() => session?.id, () => session?.header?.id);
|
|
300
|
+
if (!sid) return 0;
|
|
301
|
+
const header = pick(() => session?.header, () => session?.headerMeta);
|
|
302
|
+
if (header && !isMemoryEligibleHeader(header)) return 0; // 子代理不入库(R5);D9-a:分叉会话入枝
|
|
303
|
+
const kind = sessionKind(header);
|
|
304
|
+
if (typeof session.snapshotEvents !== 'function') return 0;
|
|
305
|
+
let events;
|
|
306
|
+
try {
|
|
307
|
+
events = session.snapshotEvents();
|
|
308
|
+
} catch {
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(events)) return 0;
|
|
312
|
+
// D9-a:分叉会话的 seed 前缀是"父会话的历史",不是这条枝新产生的记忆 ——
|
|
313
|
+
// 平台用 isOwnSeq(seq >= inheritedEventCount) 标记自有事件;顶层会话该值为 0,过滤对它们无影响。
|
|
314
|
+
if (kind === 'fork' && typeof session.isOwnSeq === 'function') {
|
|
315
|
+
try {
|
|
316
|
+
events = events.filter((ev) => session.isOwnSeq(Number(ev?.seq ?? 0)));
|
|
317
|
+
} catch {
|
|
318
|
+
/* 过滤失败就按全量走,水位线仍是一道保险 */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const key = 'wm:' + sid;
|
|
322
|
+
let wm = Number(memory.kvGet(key) || 0);
|
|
323
|
+
let lastSeq = wm;
|
|
324
|
+
let added = 0;
|
|
325
|
+
let userAdded = 0;
|
|
326
|
+
const typeCount = {};
|
|
327
|
+
const msgLike = [];
|
|
328
|
+
for (const ev of events) {
|
|
329
|
+
const s = Number(ev?.seq ?? 0);
|
|
330
|
+
if (s > lastSeq) lastSeq = s;
|
|
331
|
+
if (!s || s <= wm) continue;
|
|
332
|
+
const type = ev?.type;
|
|
333
|
+
if (!type) continue;
|
|
334
|
+
typeCount[type] = (typeCount[type] || 0) + 1;
|
|
335
|
+
if (String(type).includes('message') && msgLike.length < 3) {
|
|
336
|
+
const data = ev?.data ?? {};
|
|
337
|
+
msgLike.push({
|
|
338
|
+
type,
|
|
339
|
+
dataKeys: Object.keys(data).slice(0, 8),
|
|
340
|
+
msgKeys: data?.message && typeof data.message === 'object' ? Object.keys(data.message).slice(0, 8) : null,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (type !== 'user/message' && type !== 'assistant/message') continue;
|
|
344
|
+
// 只留真人消息
|
|
345
|
+
if (type === 'user/message' && messageSourceKind(ev) !== 'user') continue;
|
|
346
|
+
const text = extractMessageText(ev);
|
|
347
|
+
if (!text) continue;
|
|
348
|
+
const time = Number(ev?.time);
|
|
349
|
+
memory.appendRawTurn(String(sid), {
|
|
350
|
+
seq: s,
|
|
351
|
+
role: type.startsWith('user') ? 'user' : 'assistant',
|
|
352
|
+
ts: time && !Number.isNaN(time) ? new Date(time).toISOString() : null,
|
|
353
|
+
model: null,
|
|
354
|
+
text,
|
|
355
|
+
});
|
|
356
|
+
added += 1;
|
|
357
|
+
if (type.startsWith('user')) userAdded += 1;
|
|
358
|
+
}
|
|
359
|
+
if (lastSeq > wm) memory.kvSet(key, String(lastSeq));
|
|
360
|
+
if (userAdded > 0 && settings) stampModeIfFirstInput(memory, settings, sid);
|
|
361
|
+
if (added === 0 && !memory.kvGet('dbg.diff_types') && Object.keys(typeCount).length) {
|
|
362
|
+
try {
|
|
363
|
+
memory.kvSet('dbg.diff_types', JSON.stringify({ types: typeCount, msgLike }).slice(0, 900));
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
return added;
|
|
367
|
+
}
|